Skip to content
AB

How I Structure TypeScript Lambda Features Without a Heavy Framework

A feature-first structure keeps Lambda handlers thin, business rules testable, and AWS and MongoDB details at the edges.

Ashish BhakhandPublished

My early Lambda functions started as one file: parse the request, query MongoDB, apply rules, and return JSON. That works until several handlers need the same rule and every test requires AWS-shaped events and database mocks.

I now organize code by feature and keep four responsibilities visible:

Code
features/
  invitations/
    handler.ts
    application/
      accept-invitation.ts
    domain/
      invitation.ts
    repository/
      invitation-repository.ts
    infrastructure/
      mongo-invitation-repository.ts

The names matter less than the direction of dependencies.

Start with the use case#

Application code describes the operation without knowing Lambda or MongoDB:

Code
export interface InvitationRepository {
  findByToken(token: string): Promise<Invitation | null>;
  markAccepted(id: string, userId: string): Promise<void>;
}

export function acceptInvitation(repository: InvitationRepository) {
  return async (token: string, userId: string) => {
    const invitation = await repository.findByToken(token);

    if (!invitation || invitation.expiresAt < new Date()) {
      throw new InvitationUnavailableError();
    }

    await repository.markAccepted(invitation.id, userId);
  };
}

The use case depends on a small port. MongoDB is one possible adapter, not the definition of an invitation.

Make the handler the composition root#

The handler translates AWS input and wires concrete dependencies:

Code
const repository = new MongoInvitationRepository(getDatabase());
const run = acceptInvitation(repository);

export const handler = withHttp(async (request) => {
  const input = acceptInvitationSchema.parse(request.body);
  await run(input.token, request.auth.userId);

  return { statusCode: 204, body: "" };
});

This is the right place to know about API Gateway, authentication context, response shapes, and concrete adapters. It is the wrong place for the invitation rules themselves.

Keep boundaries practical#

I do not create an interface for every class. A port earns its place when it separates business logic from I/O, has multiple implementations, or makes an important boundary testable. A pure formatting helper does not need repository ceremony.

Feature-first organization also makes ownership clear. Everything needed to understand invitations lives together. Shared platform code should contain real cross-cutting policy such as authentication, logging, and database connection management-not a miscellaneous folder for unrelated helpers.

This structure adds a few files, but it removes a more expensive cost: reading AWS and database details while trying to understand a business decision. The framework stays at the edge, and the useful part of the system remains ordinary TypeScript.

← Back to all writing