Domain-Driven Design Patterns and Practices
Domain-Driven Design (DDD) is a software development approach that focuses on modeling complex business domains through strategic and tactical patterns. DDD helps teams create software that closely aligns with business needs while maintaining technical excellence and adaptability.
Strategic Domain-Driven Design
Bounded Contexts
Bounded contexts define the boundaries within which a particular domain model applies:
Context Mapping Patterns:
Sales Context ──► Customer Context ──► Support Context
│ │ │
└─ Shared Kernel ────┼─ Customer ─────────┘
│
└─ Separate Ways
Context Relationships:
- Shared Kernel: Common subset of domain model shared between contexts
- Customer-Supplier: One context depends on another
- Conformist: Follows the model of another context
- Anti-Corruption Layer: Translates between different models
Ubiquitous Language
Domain Expert Collaboration:
Business Analyst ──► Domain Expert ──► Developer ──► Code
│ │ │ │
└─ Business Terms ─────┼─ Ubiquitous ──┼─ Domain ─┘
│ Language │ Objects
└───────────────┘
Language Evolution:
- Start with business terminology
- Refine through conversations
- Reflect changes in code immediately
- Use the same terms in documentation, tests, and discussions
Tactical Domain-Driven Design
Domain Layer Patterns
Entities
Entities represent domain objects with identity and lifecycle:
Entity Characteristics:
class Customer {
private readonly id: CustomerId;
private name: PersonName;
private email: EmailAddress;
private status: CustomerStatus;
private addresses: Address[];
constructor(id: CustomerId, name: PersonName, email: EmailAddress) {
this.id = id;
this.name = name;
this.email = email;
this.status = CustomerStatus.ACTIVE;
this.addresses = [];
}
changeName(newName: PersonName): void {
if (this.status !== CustomerStatus.ACTIVE) {
throw new DomainError('Cannot change name of inactive customer');
}
this.name = newName;
this.addDomainEvent(new CustomerNameChanged(this.id, newName));
}
equals(other: Customer): boolean {
return this.id.equals(other.id);
}
}
Value Objects
Value objects are immutable and defined by their attributes:
Value Object Implementation:
class Money {
private readonly amount: number;
private readonly currency: Currency;
constructor(amount: number, currency: Currency) {
if (amount < 0) {
throw new DomainError('Amount cannot be negative');
}
this.amount = amount;
this.currency = currency;
}
add(other: Money): Money {
if (!this.currency.equals(other.currency)) {
throw new DomainError('Cannot add different currencies');
}
return new Money(this.amount + other.amount, this.currency);
}
multiply(factor: number): Money {
return new Money(this.amount * factor, this.currency);
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency.equals(other.currency);
}
}
Aggregates
Aggregates define consistency boundaries and encapsulate business rules:
Aggregate Root Pattern:
class Order {
private readonly id: OrderId;
private readonly customerId: CustomerId;
private status: OrderStatus;
private items: OrderItem[];
private totalAmount: Money;
constructor(id: OrderId, customerId: CustomerId) {
this.id = id;
this.customerId = customerId;
this.status = OrderStatus.DRAFT;
this.items = [];
this.totalAmount = Money.zero(Currency.USD);
}
addItem(productId: ProductId, quantity: number, unitPrice: Money): void {
if (this.status !== OrderStatus.DRAFT) {
throw new DomainError('Cannot modify confirmed order');
}
const existingItem = this.items.find(item => item.productId.equals(productId));
if (existingItem) {
existingItem.increaseQuantity(quantity);
} else {
this.items.push(new OrderItem(productId, quantity, unitPrice));
}
this.recalculateTotal();
}
confirm(): void {
if (this.items.length === 0) {
throw new DomainError('Cannot confirm empty order');
}
this.status = OrderStatus.CONFIRMED;
this.addDomainEvent(new OrderConfirmed(this.id, this.totalAmount));
}
}
Domain Services
Services contain business logic that doesn't naturally fit in entities or value objects:
Domain Service Example:
class PricingService {
constructor(
private readonly discountRepository: DiscountRepository,
private readonly taxCalculator: TaxCalculator
) {}
calculateTotal(order: Order, customer: Customer): Money {
let subtotal = order.getSubtotal();
// Apply customer discounts
const customerDiscounts = this.discountRepository.findByCustomer(customer.id);
for (const discount of customerDiscounts) {
if (discount.appliesTo(subtotal)) {
subtotal = discount.applyTo(subtotal);
}
}
// Calculate taxes
const taxAmount = this.taxCalculator.calculateTax(subtotal, customer.address);
return subtotal.add(taxAmount);
}
}
Domain Events
Domain events represent significant business occurrences:
Domain Event Pattern:
abstract class DomainEvent {
public readonly eventId: string;
public readonly aggregateId: string;
public readonly eventVersion: number;
public readonly occurredOn: Date;
constructor(aggregateId: string) {
this.eventId = uuidv4();
this.aggregateId = aggregateId;
this.eventVersion = 1;
this.occurredOn = new Date();
}
}
class OrderPlaced extends DomainEvent {
constructor(
public readonly orderId: OrderId,
public readonly customerId: CustomerId,
public readonly totalAmount: Money
) {
super(orderId.toString());
}
}
Application Layer
Command and Query Patterns
Command Pattern:
abstract class Command {
public readonly commandId: string;
public readonly timestamp: Date;
constructor() {
this.commandId = uuidv4();
this.timestamp = new Date();
}
}
class PlaceOrderCommand extends Command {
constructor(
public readonly customerId: CustomerId,
public readonly items: OrderItem[]
) {
super();
}
}
class CommandHandler {
constructor(
private readonly orderRepository: OrderRepository,
private readonly domainEventPublisher: DomainEventPublisher
) {}
async handle(command: PlaceOrderCommand): Promise<OrderId> {
const order = Order.create(command.customerId);
for (const item of command.items) {
order.addItem(item.productId, item.quantity, item.unitPrice);
}
await this.orderRepository.save(order);
await this.domainEventPublisher.publish(order.getDomainEvents());
return order.id;
}
}
Application Services
Application Service Layer:
class OrderApplicationService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus
) {}
async placeOrder(request: PlaceOrderRequest): Promise<PlaceOrderResponse> {
const command = new PlaceOrderCommand(
new CustomerId(request.customerId),
request.items.map(item => new OrderItem(
new ProductId(item.productId),
item.quantity,
new Money(item.unitPrice, Currency.USD)
))
);
const orderId = await this.commandBus.send(command);
return { orderId: orderId.toString() };
}
async getOrderDetails(orderId: string): Promise<OrderDetailsDto> {
const query = new GetOrderDetailsQuery(new OrderId(orderId));
return await this.queryBus.send(query);
}
}
Infrastructure Layer
Repository Pattern
Repository Implementation:
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: OrderId): Promise<Order | null>;
findByCustomerId(customerId: CustomerId): Promise<Order[]>;
nextIdentity(): OrderId;
}
class SqlOrderRepository implements OrderRepository {
constructor(private readonly db: Database) {}
async save(order: Order): Promise<void> {
const data = this.mapToData(order);
await this.db.orders.upsert({
where: { id: order.id.toString() },
update: data,
create: data
});
}
async findById(id: OrderId): Promise<Order | null> {
const data = await this.db.orders.findUnique({
where: { id: id.toString() }
});
return data ? this.mapToDomain(data) : null;
}
}
Context Mapping Implementation
Anti-Corruption Layer
ACL Implementation:
class LegacySystemAdapter {
constructor(private readonly legacyApi: LegacyApiClient) {}
async getCustomer(customerId: string): Promise<Customer> {
const legacyCustomer = await this.legacyApi.getCustomer(customerId);
return new Customer(
new CustomerId(legacyCustomer.id),
new PersonName(legacyCustomer.firstName, legacyCustomer.lastName),
new EmailAddress(legacyCustomer.email)
);
}
async updateCustomer(customer: Customer): Promise<void> {
await this.legacyApi.updateCustomer({
id: customer.id.toString(),
firstName: customer.name.firstName,
lastName: customer.name.lastName,
email: customer.email.toString()
});
}
}
Testing Domain Models
Unit Testing Domain Logic
Entity Testing:
describe('Customer', () => {
it('should change name when active', () => {
const customer = new Customer(
new CustomerId('123'),
new PersonName('John', 'Doe'),
new EmailAddress('john@example.com')
);
customer.changeName(new PersonName('Jane', 'Doe'));
expect(customer.name.firstName).toBe('Jane');
});
it('should not change name when inactive', () => {
const customer = new Customer(/* ... */);
customer.deactivate();
expect(() => {
customer.changeName(new PersonName('Jane', 'Doe'));
}).toThrow(DomainError);
});
});
Integration Testing
Repository Testing:
describe('OrderRepository', () => {
let repository: OrderRepository;
let db: TestDatabase;
beforeEach(async () => {
db = await createTestDatabase();
repository = new SqlOrderRepository(db);
});
it('should save and retrieve order', async () => {
const order = Order.create(new CustomerId('123'));
order.addItem(new ProductId('456'), 2, new Money(10, Currency.USD));
await repository.save(order);
const retrieved = await repository.findById(order.id);
expect(retrieved?.id).toEqual(order.id);
expect(retrieved?.getTotalAmount()).toEqual(new Money(20, Currency.USD));
});
});
Common DDD Anti-Patterns
Anemic Domain Model
Anti-Pattern:
// ❌ Anemic - no business logic
class Order {
id: string;
items: OrderItem[];
status: string;
// Just getters and setters
getTotal(): number {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
}
Correct Approach:
// ✅ Rich domain model
class Order {
// Business logic encapsulated
calculateTotal(): Money {
return this.items.reduce(
(total, item) => total.add(item.getLineTotal()),
Money.zero(this.currency)
);
}
}
God Classes
Anti-Pattern:
// ❌ God class - too many responsibilities
class OrderManager {
createOrder() { /* ... */ }
calculateTax() { /* ... */ }
sendEmail() { /* ... */ }
updateInventory() { /* ... */ }
}
Correct Approach:
// ✅ Single responsibility classes
class OrderFactory { /* ... */ }
class TaxCalculator { /* ... */ }
class EmailService { /* ... */ }
class InventoryService { /* ... */ }
GitScrum Integration
Domain Modeling in Task Management
DDD for Project Management:
- Model projects as bounded contexts
- Use domain events for task state changes
- Implement aggregates for sprint and project boundaries
Ubiquitous Language in Team Communication
Shared Understanding:
- Define clear domain terms for tasks and workflows
- Use consistent terminology across team members
- Reflect domain concepts in GitScrum board configurations