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:
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:
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:
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.