Last updated: Apr 29, 2026
Domain-Driven Design (DDD)
Domain-Driven Design (DDD) is a software design approach that puts the business domain at the centre of every architectural decision. Rather than starting with databases or frameworks, you start with domain modelling — building an expressive model that reflects how the business actually works, then letting that model drive the code structure.
Collaborating with Domain Experts
The richest domain models emerge when developers and subject-matter experts (SMEs) build them together. DDD starts by getting everyone in the same room to discuss the problem space and how the problem is solved today.
Ubiquitous Language
The shared vocabulary you develop isn’t just for meetings — use it everywhere: code, tests, docs, and UI. If domain experts call it a “Policy”, the code should have a Policy class, not a Contract or Agreement.
// Language mismatch — confusing for both sides
class Contract {
activate() {
/* ... */
}
}
// Ubiquitous language — mirrors the domain
class Policy {
issue() {
/* ... */
}
cancel(reason: CancellationReason) {
/* ... */
}
}
When the language is inconsistent, translation bugs creep in. When it’s consistent, misunderstandings surface early and the model stays honest.
Layered Architecture
DDD applications typically separate concerns into four layers, each with a clear dependency direction — outer layers depend on inner ones, never the reverse.
┌──────────────────────────────────────┐
│ Interface / UI │ ← Controllers, REST endpoints, CLI
├──────────────────────────────────────┤
│ Application │ ← Use cases, orchestration, DTOs
├──────────────────────────────────────┤
│ Domain │ ← Entities, Value Objects, Domain Services
├──────────────────────────────────────┤
│ Infrastructure │ ← DB, messaging, external APIs
└──────────────────────────────────────┘
▲ dependencies point inward
Bounded Contexts
A bounded context draws a clear boundary around a subset of the business domain — each context owns its own concepts, terminology, and rules, ensuring internal consistency. The same real-world thing naturally takes a different shape depending on which problem you’re solving:
┌─ Rider Context ───────────────┐ ┌─ Driver Context ────────────────┐
│ │ │ │
│ Trip │ │ Trip │
│ - pickup │ │ - route │
│ - dropoff │ │ - earnings │
│ - fareEstimate │ │ - acceptanceRate │
│ - rating │ │ │
│ │ │ Vehicle ── License │
│ Payment Method │ │ │
│ Saved Addresses │ │ Shift Schedule ── Payout │
│ Ride History │ │ │
└────────────────────────────────┘ └──────────────────────────────────┘
“Trip” exists in both contexts but means different things — the Rider cares about pickup, dropoff, and fare; the Driver cares about the route and earnings. Forcing these into a single model would make both contexts worse. Different bounded contexts communicate through well-defined interfaces (events, APIs, shared kernel), minimizing confusion and ambiguity across the system.
Anti-Corruption Layer (ACL)
Anti-corruption layers are well-contained adapters between a bounded context and other models or external systems. They keep domain services clean and simple — easy to unit test in isolation, dealing exclusively in the entities they’re associated with.
interface RiderTripRequest {
tripId: string;
pickup: { lat: number; lng: number; label: string };
dropoff: { lat: number; lng: number; label: string };
fareEstimate: number;
riderRating: number;
}
class RiderContextAdapter {
toDispatchRequest(request: RiderTripRequest): DispatchRequest {
return new DispatchRequest(
TripId.from(request.tripId),
Route.between(request.pickup, request.dropoff),
Earnings.fromFare(request.fareEstimate),
);
}
}
The Driver context never works with RiderTripRequest directly — it only sees DispatchRequest, Route, Earnings. The adapter transforms what’s relevant (coordinates become a Route, fare becomes Earnings) and discards the rest (riderRating). Without an ACL, the foreign model leaks into your domain, coupling it to something you don’t control.
Entities vs Value Objects
Entities have a unique identity that persists across state changes. Two entities with the same attributes are still different if their IDs differ.
class Trip {
constructor(
readonly id: TripId,
private route: Route,
private status: TripStatus,
private driver?: DriverId,
) {}
assignDriver(driverId: DriverId) {
if (this.status !== "pending") throw new Error("Trip already assigned");
this.driver = driverId;
this.status = "assigned";
}
complete() {
if (this.status !== "in_progress") throw new Error("Trip not in progress");
this.status = "completed";
}
}
Value Objects are defined entirely by their attributes — they have no IDs and are immutable. Two value objects with the same data are interchangeable.
class Route {
constructor(
readonly pickup: Coordinate,
readonly dropoff: Coordinate,
readonly distanceKm: number,
) {}
estimateDuration(avgSpeedKmh: number): Minutes {
return new Minutes(Math.ceil((this.distanceKm / avgSpeedKmh) * 60));
}
}
Aggregates
An aggregate is a cluster of domain objects treated as a single unit for data changes. One entity acts as the aggregate root — the only entry point for external access. You load, save, and enforce rules on the whole aggregate at once.
In a ride-sharing app, a Trip is the aggregate root. It owns its Route, Fare, and status transitions. No outside code reaches in to modify the route directly — everything goes through Trip:
class Trip {
constructor(
readonly id: TripId,
private route: Route,
private status: TripStatus,
private fare: Earnings,
private driver?: DriverId,
) {}
accept(driverId: DriverId): DomainEvent[] {
if (this.status !== "pending") throw new Error("Trip not available");
this.driver = driverId;
this.status = "assigned";
return [new TripAccepted(this.id, driverId, this.route)];
}
complete(): DomainEvent[] {
if (this.status !== "in_progress") throw new Error("Trip not in progress");
this.status = "completed";
return [new TripCompleted(this.id, this.fare)];
}
}
Aggregates define transaction boundaries — everything inside one succeeds or fails atomically. If you model a driver reassignment as two separate operations (remove from Driver A, assign to Driver B), one can succeed while the other fails, leaving the trip assigned to both or neither. Keep it inside the Trip aggregate and the whole operation is atomic. When you need to coordinate across aggregates, use domain events instead of stretching a single transaction.
Trade-offs
| Benefit | Cost |
|---|---|
| Code mirrors the business — easier to reason about | Requires upfront investment in domain learning |
| Bounded contexts enable team autonomy | Context mapping adds integration complexity |
| Aggregates enforce consistency | Designing aggregate boundaries is non-trivial |
| Ubiquitous language reduces miscommunication | Maintaining the language takes discipline |
DDD pays off most in complex domains with rich business rules. For simple CRUD apps, the overhead rarely justifies itself.