ArchitectureServerlessDDDTypeScript

DDD in Serverless: Breaking the Monolith Mindset

By Ezequiel Carrizo
Ezquiel, the author Picture
Published on
Code architecture diagram

Most serverless projects start simple: a few Lambda functions, each a self-contained script. But as the application grows, you start seeing the same patterns repeated across functions: data access, business rules, validation. Without structure, serverless becomes a distributed ball of mud.

Domain-Driven Design offers a way out. Here is how I applied DDD principles to a serverless backend running on AWS Lambda.

Hi there! Want to support me?

The Project Structure

The key insight is that DDD layers map naturally to serverless functions. Each Lambda is the entry point, but the domain logic lives in separated layers that can be shared and tested independently.

apps/backend/src/
├── domain/ # Entities, value objects, repository interfaces
│ ├── models/ # Domain models (User, Session, Interview)
│ ├── repositories/ # Repository interfaces (IUserRepository)
│ ├── errors/ # Domain-specific exceptions
│ └── types/ # Shared domain types
├── application/ # Use cases and service orchestration
│ ├── services/ # SessionScoring, InterviewService, ResumeAnalysis
│ └── constants/ # Business constants
├── infrastructure/ # External implementations (DynamoDB, AWS SDK)
├── interfaces/ # HTTP handlers (controllers) and DTOs
│ ├── http/
│ │ ├── handlers/ # Lambda handler functions
│ │ ├── dto/ # Request/response validation (class-validator)
│ │ └── middleware/# Auth, validation, error handling
│ └── events/ # SQS/SNS event handlers
└── triggers/ # Cognito triggers, cron jobs

Bounded Contexts Without Microservices

In a traditional microservices architecture, each bounded context gets its own deployable. In serverless, each Lambda function is its own deployable, but they all share the same codebase. DDD's bounded contexts become folder-level boundaries rather than service-level boundaries.

The application has clear bounded contexts: authentication, sessions, billing, documents, skills, stories, and interview series. Each context owns its data (separate DynamoDB tables), its business rules, and its API routes. They communicate through well-defined interfaces, not shared state.

Repository Pattern with DynamoDB

The repository pattern abstracts data access behind an interface. Your domain logic never knows it is talking to DynamoDB — it just calls sessionRepository.findById(id). This makes the domain layer testable with mock repositories and keeps infrastructure concerns where they belong.

// Domain layer — repository interface
export interface ISessionRepository {
findById(sessionId: string): Promise<Session | null>
findByUser(userId: string, limit: number): Promise<Session[]>
save(session: Session): Promise<void>
delete(sessionId: string): Promise<void>
}
// Infrastructure layer — DynamoDB implementation
import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb'
export class DynamoSessionRepository implements ISessionRepository {
constructor(private readonly docClient: DynamoDBDocument) {}
async findById(sessionId: string): Promise<Session | null> {
const result = await this.docClient.get({
TableName: process.env.SESSIONS_TABLE_NAME!,
Key: { sessionId },
})
return result.Item ? Session.fromDynamoItem(result.Item) : null
}
async save(session: Session): Promise<void> {
await this.docClient.put({
TableName: process.env.SESSIONS_TABLE_NAME!,
Item: session.toDynamoItem(),
})
}
}

The Session model is a proper domain entity with behavior, not a dumb data bag. It knows how to serialize itself to DynamoDB format and how to validate its own invariants.

Hi there! Want to support me?

Dependency Injection with tsyringe

A common objection to serverless is the cold start penalty. Surely a DI container makes it worse? In practice, tsyringe adds negligible overhead to cold starts (single-digit milliseconds) while giving you clean, testable code. The trade-off is worth it.

// Registering services with tsyringe
import { container } from 'tsyringe'
// Infrastructure
container.register('ISessionRepository', {
useClass: DynamoSessionRepository,
})
// Application services
container.register('InterviewService', {
useClass: InterviewService,
})
// In a Lambda handler
export const handler = async (event: APIGatewayProxyEventV2) => {
const interviewService = container.resolve<InterviewService>('InterviewService')
const result = await interviewService.processSession(sessionId)
return { statusCode: 200, body: JSON.stringify(result) }
}

Each Lambda handler resolves its dependencies from the shared container. The container is configured once at module load time, so on warm starts there is zero overhead.

DTOs and Validation

Request validation uses class-validator decorators on DTO classes. This gives you declarative validation that runs before any business logic, with meaningful error messages. The class-transformer library handles the conversion from plain API Gateway event objects to typed DTO instances.

When DDD Is Overkill

DDD shines in applications with complex business rules. For a simple CRUD API or a small number of endpoints, the overhead of layers and interfaces is not justified. But once you have multiple bounded contexts, complex workflows, and a team of developers, the structure pays for itself. Your future self will thank you when you can change the database implementation without touching a single line of business logic.