Domain-Driven Design: Tactical Patterns
DDD tactical patterns help implement domain models. After applying these patterns in production, here’s how to use them effectively.
Tactical Patterns Overview
Tactical patterns include:
- Entities - Objects with identity
- Value Objects - Immutable objects
- Aggregates - Consistency boundaries
- Domain Services - Domain logic
- Repositories - Data access abstraction
Entities
Definition
Entities:
- Have unique identity
- Can change over time
- Identified by ID, not attributes
Implementation
class User {
constructor(id, name, email) {
if (!id) {
throw new Error('User must have an ID');
}
this._id = id;
this._name = name;
this._email = email;
this._createdAt = new Date();
}
get id() {
return this._id;
}
get name() {
return this._name;
}
updateName(newName) {
if (!newName || newName.trim().length === 0) {
throw new Error('Name cannot be empty');
}
this._name = newName;
}
updateEmail(newEmail) {
if (!this.isValidEmail(newEmail)) {
throw new Error('Invalid email address');
}
this._email = newEmail;
}
isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
equals(other) {
return other instanceof User && this._id === other._id;
}
}
Value Objects
Definition
Value Objects:
- Defined by attributes, not identity
- Immutable
- No identity
Implementation
class Money {
constructor(amount, currency) {
if (amount < 0) {
throw new Error('Amount cannot be negative');
}
if (!currency) {
throw new Error('Currency is required');
}
this._amount = amount;
this._currency = currency;
Object.freeze(this); // Make immutable
}
get amount() {
return this._amount;
}
get currency() {
return this._currency;
}
add(other) {
if (this._currency !== other._currency) {
throw new Error('Cannot add different currencies');
}
return new Money(this._amount + other._amount, this._currency);
}
subtract(other) {
if (this._currency !== other._currency) {
throw new Error('Cannot subtract different currencies');
}
if (this._amount < other._amount) {
throw new Error('Insufficient funds');
}
return new Money(this._amount - other._amount, this._currency);
}
equals(other) {
return other instanceof Money &&
this._amount === other._amount &&
this._currency === other._currency;
}
}
class Address {
constructor(street, city, state, zipCode) {
this._street = street;
this._city = city;
this._state = state;
this._zipCode = zipCode;
Object.freeze(this);
}
get street() { return this._street; }
get city() { return this._city; }
get state() { return this._state; }
get zipCode() { return this._zipCode; }
equals(other) {
return other instanceof Address &&
this._street === other._street &&
this._city === other._city &&
this._state === other._state &&
this._zipCode === other._zipCode;
}
}
Aggregates
Definition
Aggregates:
- Consistency boundary
- Root entity
- Enforces invariants
- Accessed through root
Implementation
class Order {
constructor(id, customerId) {
this._id = id;
this._customerId = customerId;
this._items = [];
this._status = 'pending';
this._total = new Money(0, 'USD');
}
get id() {
return this._id;
}
get items() {
return [...this._items]; // Return copy
}
get total() {
return this._total;
}
addItem(productId, quantity, price) {
if (this._status !== 'pending') {
throw new Error('Cannot modify completed order');
}
if (quantity <= 0) {
throw new Error('Quantity must be positive');
}
const itemTotal = price.multiply(quantity);
this._items.push({
productId,
quantity,
price,
total: itemTotal
});
this._total = this._total.add(itemTotal);
}
removeItem(productId) {
if (this._status !== 'pending') {
throw new Error('Cannot modify completed order');
}
const index = this._items.findIndex(item => item.productId === productId);
if (index === -1) {
throw new Error('Item not found');
}
const item = this._items[index];
this._total = this._total.subtract(item.total);
this._items.splice(index, 1);
}
confirm() {
if (this._items.length === 0) {
throw new Error('Cannot confirm empty order');
}
this._status = 'confirmed';
}
cancel() {
if (this._status === 'shipped') {
throw new Error('Cannot cancel shipped order');
}
this._status = 'cancelled';
}
}
Domain Services
Definition
Domain Services:
- Operations that don’t belong to entities
- Stateless
- Domain logic
Implementation
class OrderPricingService {
calculateDiscount(order, customer) {
let discount = new Money(0, 'USD');
// VIP customers get 10% discount
if (customer.isVIP()) {
discount = order.total.multiply(0.1);
}
// Orders over $100 get $10 off
if (order.total.amount > 100) {
discount = discount.add(new Money(10, 'USD'));
}
return discount;
}
}
class OrderValidationService {
validateOrder(order, inventory) {
const errors = [];
for (const item of order.items) {
const stock = inventory.getStock(item.productId);
if (stock < item.quantity) {
errors.push(`Insufficient stock for product ${item.productId}`);
}
}
return errors;
}
}
Repositories
Definition
Repositories:
- Abstract data access
- Collection-like interface
- Domain-focused
Implementation
class OrderRepository {
constructor(db) {
this.db = db;
}
async findById(id) {
const data = await this.db.orders.findOne({ id });
if (!data) {
return null;
}
return this.toDomain(data);
}
async findByCustomerId(customerId) {
const data = await this.db.orders.find({ customerId });
return data.map(this.toDomain);
}
async save(order) {
const data = this.toPersistence(order);
await this.db.orders.save(data);
}
async delete(id) {
await this.db.orders.delete({ id });
}
toDomain(data) {
const order = new Order(data.id, data.customerId);
// Restore state
data.items.forEach(item => {
order.addItem(item.productId, item.quantity, new Money(item.price, 'USD'));
});
if (data.status === 'confirmed') {
order.confirm();
}
return order;
}
toPersistence(order) {
return {
id: order.id,
customerId: order.customerId,
items: order.items.map(item => ({
productId: item.productId,
quantity: item.quantity,
price: item.price.amount
})),
total: order.total.amount,
status: order.status
};
}
}
Application Services
Definition
Application Services:
- Orchestrate domain objects
- Transaction boundaries
- Use case implementation
Implementation
class OrderApplicationService {
constructor(
orderRepository,
customerRepository,
inventoryService,
pricingService
) {
this.orderRepository = orderRepository;
this.customerRepository = customerRepository;
this.inventoryService = inventoryService;
this.pricingService = pricingService;
}
async createOrder(customerId, items) {
// Load aggregate
const customer = await this.customerRepository.findById(customerId);
if (!customer) {
throw new Error('Customer not found');
}
// Create aggregate
const order = new Order(generateId(), customerId);
// Add items
for (const item of items) {
// Check inventory
const stock = await this.inventoryService.getStock(item.productId);
if (stock < item.quantity) {
throw new Error(`Insufficient stock for product ${item.productId}`);
}
const price = await this.inventoryService.getPrice(item.productId);
order.addItem(item.productId, item.quantity, price);
}
// Apply discount
const discount = this.pricingService.calculateDiscount(order, customer);
order.applyDiscount(discount);
// Save aggregate
await this.orderRepository.save(order);
return order.id;
}
async confirmOrder(orderId) {
const order = await this.orderRepository.findById(orderId);
if (!order) {
throw new Error('Order not found');
}
// Validate inventory
const errors = this.orderValidationService.validateOrder(
order,
await this.inventoryService.getInventory()
);
if (errors.length > 0) {
throw new Error(`Validation failed: ${errors.join(', ')}`);
}
// Confirm order
order.confirm();
// Reserve inventory
for (const item of order.items) {
await this.inventoryService.reserve(item.productId, item.quantity);
}
// Save
await this.orderRepository.save(order);
}
}
Best Practices
- Keep aggregates small - Consistency boundaries
- Use value objects - Immutable, validated
- Protect invariants - In aggregate root
- Repository per aggregate - One repository per aggregate
- Domain services - For cross-aggregate logic
- Application services - Orchestration layer
- No anemic domain - Behavior in domain objects
- Test domain logic - Unit tests for domain
Common Mistakes
- Anemic domain model - Data without behavior
- Large aggregates - Too many entities
- Leaky repositories - Exposing persistence details
- Business logic in services - Should be in domain
- Mutable value objects - Should be immutable
Conclusion
DDD tactical patterns enable:
- Rich domain models
- Clear boundaries
- Testable code
- Maintainable systems
Start with entities and value objects, then add aggregates and repositories. The patterns shown here handle complex domains.
Domain-Driven Design tactical patterns from June 2020, covering entities, value objects, aggregates, and repositories.