Domain-Driven Design: Essential Building Blocks
Entities, value objects, aggregates, and bounded contexts — the DDD patterns that keep complex business logic organized without over-engineering simple systems.

Domain-Driven Design is a set of patterns for organizing complex business logic. It's not about using specific frameworks or folder structures — it's about modeling your software around the actual business domain, using a shared language between developers and domain experts. The tactical patterns (entities, value objects, aggregates) give you concrete tools for implementing that model.
Entities vs. Value Objects
An entity has a unique identity that persists over time. A value object is defined entirely by its attributes — two value objects with the same attributes are interchangeable.
// Entity: identified by its ID, attributes can change
class Order {
constructor(
readonly id: string,
private items: OrderItem[],
private status: OrderStatus
) {}
addItem(item: OrderItem): void {
if (this.status !== "draft") {
throw new Error("Cannot modify a non-draft order");
}
this.items.push(item);
}
// Two orders with the same items are still different orders
equals(other: Order): boolean {
return this.id === other.id;
}
}
// Value Object: defined by its attributes, immutable
class Money {
constructor(
readonly amount: number,
readonly currency: string
) {
if (amount < 0) throw new Error("Amount cannot be negative");
if (!currency.match(/^[A-Z]{3}$/)) throw new Error("Invalid currency code");
}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error("Cannot add different currencies");
}
return new Money(this.amount + other.amount, this.currency);
}
// Two Money objects with the same amount and currency are equal
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
}Aggregates
An aggregate is a cluster of entities and value objects with a single root entity that controls all access. External code only references the aggregate through its root — this enforces consistency rules.
// ❌ Direct access to internals — no consistency guarantees
const order = await orderRepo.findById(orderId);
const item = order.items[0];
item.quantity = 100; // Modified directly, bypassing business rules
await orderItemRepo.save(item);
// ✅ Access through aggregate root — invariants are enforced
class Order {
private items: OrderItem[] = [];
private status: OrderStatus = "draft";
updateItemQuantity(itemId: string, newQuantity: number): void {
if (this.status !== "draft") {
throw new Error("Cannot modify a submitted order");
}
const item = this.items.find(i => i.id === itemId);
if (!item) throw new Error(`Item ${itemId} not found`);
if (newQuantity <= 0) {
this.items = this.items.filter(i => i.id !== itemId);
return;
}
if (newQuantity > 100) {
throw new Error("Maximum quantity per item is 100");
}
item.updateQuantity(newQuantity);
}
get totalAmount(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.lineTotal),
new Money(0, "USD")
);
}
}
// External code uses the aggregate root
const order = await orderRepo.findById(orderId);
order.updateItemQuantity(itemId, 5);
await orderRepo.save(order); // Saves the entire aggregateBounded Contexts
A bounded context defines the boundary within which a model applies. The same real-world concept (e.g., "User") can have different representations in different contexts.
// ❌ Shared "User" model across the entire system
interface User {
id: string;
name: string;
email: string;
shippingAddress: Address; // Only relevant in orders
creditScore: number; // Only relevant in payments
loginAttempts: number; // Only relevant in auth
preferredCategories: string[]; // Only relevant in recommendations
}
// ✅ Each bounded context has its own model
// Auth context — cares about credentials
interface AuthUser {
id: string;
email: string;
passwordHash: string;
loginAttempts: number;
lockedUntil: Date | null;
}
// Order context — cares about shipping
interface OrderCustomer {
id: string;
name: string;
shippingAddress: Address;
loyaltyTier: "standard" | "premium";
}
// Payment context — cares about billing
interface PaymentCustomer {
id: string;
defaultPaymentMethod: string;
billingAddress: Address;
creditLimit: Money;
}Domain Events
Domain events capture important things that happen within a bounded context. They're the primary mechanism for cross-context communication.
interface DomainEvent {
type: string;
occurredAt: string;
aggregateId: string;
}
class Order {
private domainEvents: DomainEvent[] = [];
submit(): void {
if (this.items.length === 0) {
throw new Error("Cannot submit an empty order");
}
this.status = "submitted";
this.submittedAt = new Date();
// Record the domain event
this.domainEvents.push({
type: "order.submitted",
occurredAt: new Date().toISOString(),
aggregateId: this.id,
});
}
pullDomainEvents(): DomainEvent[] {
const events = [...this.domainEvents];
this.domainEvents = [];
return events;
}
}
// Repository publishes events after saving
class OrderRepository {
async save(order: Order): Promise<void> {
await this.db.save(order);
const events = order.pullDomainEvents();
for (const event of events) {
await this.eventBus.publish(event);
}
}
}Repository Pattern
Repositories abstract the persistence mechanism. The domain model doesn't know about databases — it works with pure objects.
// ❌ Domain logic coupled to database queries
async function submitOrder(orderId: string) {
const row = await db.query("SELECT * FROM orders WHERE id = $1", [orderId]);
const items = await db.query("SELECT * FROM order_items WHERE order_id = $1", [orderId]);
// Reconstruct domain object from raw rows...
// Business logic mixed with SQL
}
// ✅ Repository returns domain objects
interface OrderRepository {
findById(id: string): Promise<Order | null>;
save(order: Order): Promise<void>;
findByCustomer(customerId: string, status?: OrderStatus): Promise<Order[]>;
}
// Application service uses the repository
class OrderService {
constructor(private orderRepo: OrderRepository) {}
async submitOrder(orderId: string): Promise<void> {
const order = await this.orderRepo.findById(orderId);
if (!order) throw new Error("Order not found");
order.submit(); // Pure domain logic
await this.orderRepo.save(order); // Persistence abstracted
}
}Key Takeaways
- Entities have identity, value objects have attributes — choose the right modeling tool for each concept
- Aggregates enforce consistency boundaries — all modifications go through the aggregate root
- Bounded contexts prevent monolithic models — the same concept means different things in different parts of the system
- Domain events enable context communication — publish events after saving, let other contexts react
- Repositories hide persistence details — domain objects shouldn't know about databases or ORMs
- DDD is for complex domains — simple CRUD applications don't benefit from this level of modeling


