- Mar 22, 2024
- 1 min read
Domain-Driven Design: A Practical Intro for Backend Engineers
DDD restructures your entire backend approach by organizing code around business domains instead of technical layers. This guide walks you through ubiquitous language, bounded contexts, tactical patterns, and real implementations in Spring Boot and Node.js TypeScript — with folder structures, case studies, and migration strategies.
The Problem: Why Your Backend is Hard to Scale
How to Fix Messy Backend Architecture
Symptoms of Poor Domain Modeling:
- ❌ Business logic scattered across services
- ❌ Tightly coupled domain logic issues
- ❌ Same concept named differently everywhere
- ❌ Controllers containing business rules
- ❌ Services doing persistence work
- ❌ Hard to understand the domain
- ❌ Feature changes affect entire codebase
- ❌ Poor domain modeling consequences
Why This Happens:
- No clear language between business and engineering
- Architecture mirrors database schema, not business
- Technical layers dominate (controllers → services → repos)
- Domain knowledge lives in developers’ heads only
How Domain-Driven Design Solves This:
DDD restructures your entire approach by:
- Creating ubiquitous language - everyone speaks the same language
- Identifying bounded contexts - clear business domains
- Encapsulating business logic - in domain models, not services
- Using tactical patterns - repository, factory, value objects, etc.
- Managing domain complexity - through strategic design
Domain-Driven Design Step-by-Step Guide
Phase 1: Understanding Your Domain (The Critical First Step)
Domain-Driven Design Step-by-Step Overview:
This is where 80% of teams fail. They skip discovery.
Listen for:
- Exact business terminology
- Business rules and constraints
- Domain expert language
- What problems you’re solving
- How customers think about the domain
Building Ubiquitous Language:
Example: E-commerce Domain
Business says: Code should use:
"Process an order" → Order.process()
"Reserve inventory for order" → inventory.reserve(order)
"Calculate tax by location" → taxCalculator.calculateForLocation()
"Refund is a reversal" → Payment.refund()
Phase 2: Identify Bounded Contexts
Your system naturally breaks into domains where language changes:
E-commerce Example:
- Catalog Context: Product, Price, Review, Category
- Order Context: Order, OrderItem, ShippingAddress, OrderStatus
- Payment Context: Transaction, PaymentMethod, RefundPolicy
- Inventory Context: Stock, Reservation, Warehouse
- Shipping Context: Shipment, TrackingNumber, Carrier
Phase 3: Domain-Driven Design Folder Structure Best Practices
Recommended folder structure for scalable backend system design:
src/
├── shared/
│ ├── domain/
│ │ ├── ValueObjects.ts
│ │ └── DomainEvent.ts
│ └── exceptions/
│
├── orders/ # Orders Bounded Context
│ ├── domain/
│ │ ├── Order.ts # Aggregate Root
│ │ ├── OrderItem.ts # Entity
│ │ ├── OrderStatus.ts # Value Object
│ │ ├── OrderCreatedEvent.ts# Domain Event
│ │ └── OrderRepository.ts # Interface
│ │
│ ├── application/ # Use Cases
│ │ ├── CreateOrderService.ts
│ │ ├── ProcessOrderService.ts
│ │ └── ShipOrderService.ts
│ │
│ ├── infrastructure/ # Implementation
│ │ ├── OrderRepositoryImpl.ts
│ │ ├── OrderEventBus.ts
│ │ └── ExternalPaymentAdapter.ts
│ │
│ ├── api/
│ │ └── OrderController.ts
│ │
│ └── __tests__/
│ ├── Order.test.ts
│ └── CreateOrderService.test.ts
│
├── payments/ # Payment Bounded Context
│ ├── domain/
│ ├── application/
│ ├── infrastructure/
│ ├── api/
│ └── __tests__/
│
└── inventory/ # Inventory Bounded Context
└── (same structure)
Why This Structure Matters:
- ✅ DDD folder structure best practices
- ✅ Clear team ownership (one team = one context)
- ✅ Easy to extract to microservice
- ✅ Self-documenting code
DDD Architecture for Large-Scale Systems
How to Structure Backend Using DDD
For large-scale systems, DDD provides clear architecture:
Three Architectural Layers:
1. Domain Layer
├─ Entities (with identity)
├─ Value Objects (immutable)
├─ Aggregates (consistency boundary)
├─ Domain Services (cross-aggregate logic)
└─ Domain Events (what happened)
2. Application Layer
├─ Use Cases (application services)
├─ DTOs (data transfer objects)
├─ Event Handlers
└─ Orchestration
3. Infrastructure Layer
├─ Repository Implementation
├─ External Services
├─ Persistence
└─ Anti-Corruption Layers
Domain Modeling for Large-Scale Systems
// Example: Large-Scale Order System
// Domain: Clear business logic
export class Order {
private orderId: OrderId;
private customerId: CustomerId;
private items: OrderItem[];
private status: OrderStatus;
private paidAmount: Money | null;
// Business rule: Can only ship if paid
canBeShipped(): boolean {
return this.status === OrderStatus.PAID &&
this.items.length > 0;
}
// Business rule: Can only refund if within window
canBeRefunded(): boolean {
const refundDeadline = addDays(this.createdAt, 30);
return new Date() < refundDeadline &&
this.status === OrderStatus.PAID;
}
markAsPaid(amount: Money): void {
if (this.status === OrderStatus.PAID) {
throw new Error("Order already paid");
}
this.status = OrderStatus.PAID;
this.paidAmount = amount;
this.addDomainEvent(new OrderPaidEvent(this.orderId, amount));
}
}
Tactical Patterns in DDD
1. Anti-Corruption Layer in DDD
When integrating with legacy systems or third parties:
// Legacy external API is terrible
class LegacyPaymentAPI {
async processPayment(p_amt: number, p_meth: string) {
return { txn_ref: '123', stat: 'approved' };
}
}
// Anti-Corruption Layer (Adapter)
export class PaymentServiceAdapter {
async charge(transaction: PaymentTransaction): Promise<PaymentResult> {
// Translate TO legacy format
const response = await this.api.processPayment(
transaction.getAmount().toSubunits(),
transaction.getMethod()
);
// Translate FROM legacy format
return new PaymentResult(
transactionId: response.txn_ref,
status: this.mapStatus(response.stat)
);
}
private mapStatus(legacyStatus: string): PaymentStatus {
const mapping = {
'approved': PaymentStatus.APPROVED,
'declined': PaymentStatus.DECLINED
};
return mapping[legacyStatus];
}
}
2. Repository Pattern in DDD
Abstracts persistence from domain:
// Domain doesn't know this is SQL
export interface OrderRepository {
save(order: Order): Promise<void>;
getById(orderId: OrderId): Promise<Order | null>;
}
// Could be PostgreSQL
export class PostgresOrderRepository implements OrderRepository {
async save(order: Order): Promise<void> {
await this.db.query(
'INSERT INTO orders VALUES ($1, $2)',
[order.getId(), order.getCustomerId()]
);
}
}
// Could be MongoDB
export class MongoOrderRepository implements OrderRepository {
async save(order: Order): Promise<void> {
await this.collection.insertOne(order.toPersistence());
}
}
3. Specification Pattern in DDD
Encapsulate query logic:
export abstract class Specification<T> {
abstract isSatisfiedBy(candidate: T): boolean;
}
export class PaidOrdersSpecification extends Specification<Order> {
isSatisfiedBy(order: Order): boolean {
return order.getStatus() === OrderStatus.PAID;
}
}
export class RecentOrdersSpecification extends Specification<Order> {
constructor(private days: number = 30) { super(); }
isSatisfiedBy(order: Order): boolean {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - this.days);
return order.getCreatedAt() > cutoff;
}
}
// Usage
const spec = new CompositeSpecification([
new PaidOrdersSpecification(),
new RecentOrdersSpecification(7)
]);
const filteredOrders = orders.filter(o => spec.isSatisfiedBy(o));
4. Strategic Design: Context Mapping Patterns
How bounded contexts communicate:
// Order Context publishes event
export class OrderPaidEvent {
constructor(
public orderId: OrderId,
public amount: Money,
public timestamp: Date
) {}
}
// Inventory Context subscribes (anti-corruption)
export class InventoryOrderPaidHandler {
async handle(event: OrderPaidEvent): Promise<void> {
// Translate to inventory language
const order = await this.orderRepo.getById(event.orderId);
const reservation = new Reservation(
order.getItems(),
event.timestamp
);
await this.reservationRepo.save(reservation);
}
}
How to Apply DDD in Node.js and Java
DDD with Spring Boot Example
// domain/Order.java
@Entity
@Table(name = "orders")
public class Order {
@Id
@Column(name = "order_id")
private OrderId id;
@Embedded
private CustomerId customerId;
@ElementCollection
private Set<OrderItem> items;
@Enumerated(STRING)
private OrderStatus status;
@Transient
private List<DomainEvent> events = new ArrayList<>();
public void markAsPaid(Money amount) {
if (this.status == OrderStatus.PAID) {
throw new BusinessRuleException("Already paid");
}
this.status = OrderStatus.PAID;
this.addEvent(new OrderPaidEvent(this.id, amount));
}
public void addEvent(DomainEvent event) {
this.events.add(event);
}
}
// application/CreateOrderService.java
@Service
public class CreateOrderApplicationService {
private final OrderRepository orderRepository;
private final InventoryService inventoryService;
private final EventPublisher eventPublisher;
@Transactional
public String execute(CreateOrderCommand command) {
// Create domain object
Order order = Order.create(
new CustomerId(command.getCustomerId()),
command.getItems()
);
// Check inventory
if (!inventoryService.hasStock(order.getItems())) {
throw new InsufficientStockException();
}
// Save aggregate
orderRepository.save(order);
// Publish domain events
order.getEvents().forEach(eventPublisher::publish);
return order.getId().toString();
}
}
// infrastructure/OrderRepositoryImpl.java
@Component
public class OrderRepositoryImpl implements OrderRepository {
@PersistenceContext
private EntityManager em;
public void save(Order order) {
em.persist(order);
}
public Order getById(OrderId id) {
return em.find(Order.class, id);
}
}
DDD with Node.js TypeScript
// domain/Order.ts
export class Order {
private orderId: OrderId;
private customerId: CustomerId;
private items: OrderItem[];
private status: OrderStatus;
private domainEvents: DomainEvent[] = [];
static create(customerId: CustomerId, items: OrderItem[]): Order {
if (!items.length) {
throw new Error('Order must have items');
}
return new Order(generateOrderId(), customerId, items);
}
markAsPaid(amount: Money): void {
if (this.status === OrderStatus.PAID) {
throw new Error('Already paid');
}
this.status = OrderStatus.PAID;
this.addEvent(new OrderPaidEvent(this.orderId, amount));
}
private addEvent(event: DomainEvent): void {
this.domainEvents.push(event);
}
getEvents(): DomainEvent[] {
return [...this.domainEvents];
}
clearEvents(): void {
this.domainEvents = [];
}
}
// application/CreateOrderService.ts
export class CreateOrderService {
constructor(
private orderRepository: OrderRepository,
private inventoryService: InventoryService,
private eventBus: EventBus
) {}
async execute(command: CreateOrderCommand): Promise<string> {
// Create domain object
const order = Order.create(
new CustomerId(command.customerId),
command.items.map(i => new OrderItem(i.sku, i.qty, i.price))
);
// Check inventory
const stock = await this.inventoryService.checkStock(order.getItems());
if (!stock.hasAll) throw new InsufficientStockError();
// Save
await this.orderRepository.save(order);
// Publish events
const events = order.getEvents();
for (const event of events) {
await this.eventBus.publish(event);
}
order.clearEvents();
return order.getOrderId().toString();
}
}
// infrastructure/OrderRepository.ts
export class TypeOrmOrderRepository implements OrderRepository {
async save(order: Order): Promise<void> {
const raw = order.toPersistence();
await this.db.getRepository(OrderEntity).save(raw);
}
async getById(orderId: OrderId): Promise<Order | null> {
const raw = await this.db.getRepository(OrderEntity).findOne({
where: { orderId: orderId.toString() }
});
return raw ? Order.fromPersistence(raw) : null;
}
}
DDD API Design with HTTP
// api/OrderController.ts
@Controller('/orders')
export class OrderController {
constructor(private createOrderService: CreateOrderService) {}
@Post()
@HttpCode(201)
async create(@Body() dto: CreateOrderDto): Promise<{ orderId: string }> {
const command = new CreateOrderCommand(
dto.customerId,
dto.items
);
const orderId = await this.createOrderService.execute(command);
return { orderId };
}
}
DDD vs Microservices vs Monolith
How Domain-Driven Design Compares to Alternatives
DDD vs Microservices:
DDD is for modeling, microservices is for deployment.
Best approach: Use DDD to design bounded contexts, then deploy contexts as microservices if needed.
| Aspect | DDD Only | Microservices (No DDD) | DDD + Microservices |
|---|---|---|---|
| Clarity | Clear domains | Unclear boundaries | Crystal clear |
| Complexity | Manageable | Distributed complexity | Well-managed |
| Cost | Low ops | High ops | Medium ops |
| Scalability | Good | Great | Excellent |
| Team autonomy | Medium | High | High |
Monolith vs Microservices vs Modular Monolith:
- Monolith (no structure): Everything together, messy
- Monolith with DDD: Clear bounded contexts within one process
- Modular monolith: DDD + internal modularity
- Microservices with DDD: Clear contexts deployed separately
- Microservices without DDD: Chaos and tight coupling
Best transition path:
Simple CRUD API
↓
DDD Monolith (clear bounded contexts)
↓
Modular Monolith (same process, better structure)
↓
Microservices with DDD (split by context)
Real-World Domain-Driven Design Case Studies
Case Study 1: How to Evolve Legacy Systems
Before (Monolithic Chaos):
One big codebase:
- 100,000+ lines of code
- Mixed concerns everywhere
- Slow deployments
- Hard to understand
- Team frustrated
Strangler Pattern Migration (6 months with DDD):
Month 1: Extract order handling
- Identify Order bounded context
- Create new Order model
- Anti-corruption layer to legacy
- 10% traffic to new Orders service
Month 2: Extract inventory
- Same pattern
- 25% traffic
Month 3-4: Extract payments
- 50% traffic
Month 5-6: Extract shipping, reporting
- 100% traffic to DDD-based services
- Delete legacy monolith pieces
Result:
- Clear bounded contexts
- Independent deployments
- Team velocity +200%
- Bugs -50%
- Now scalable!
Case Study 2: Building SaaS from Scratch with DDD
Design:
Bounded Contexts:
- Workspace: Workspace, Member, Role, Permission
- Billing: Subscription, Invoice, PaymentMethod
- Core Product: Document, Editor, Collaboration
- Reporting: Analytics, Dashboard, Metrics
Advantages:
- Clear team ownership
- Independent scaling
- Each team understands their domain
- Easy to add features
- Easy to test
- Easy to deploy
FAQ: Domain-Driven Design Best Practices
Q: When should we use DDD?
A: Use DDD when:
- ✅ Multiple teams work on system
- ✅ Business logic is complex
- ✅ Domain expected to evolve
- ✅ Several subdomains exist
Don’t use DDD when:
- ❌ Simple CRUD app
- ❌ Solo developer project
- ❌ Stable, unchanging requirements
Q: How long does DDD learning take?
A: Basic concepts: 2-4 weeks
Practical implementation: 3-6 months
Mastery: 12+ months
Q: Can we migrate to DDD from current architecture?
A: Absolutely. Use strangler pattern to gradually extract bounded contexts while old code still runs.
Q: What’s the relationship between DDD and microservices?
A: DDD = domain modeling (how to think about your domain)
Microservices = deployment strategy (how to scale)
Use DDD first to get boundaries right, then decide on deployment.
External Learning Resources
Master Domain-Driven Design with these authoritative sources:
- Eric Evans’ Domain-Driven Design Book - The original blue book
- Vaughn Vernon’s Implementing DDD - Practical guide with code examples
- Martin Fowler’s DDD Articles - Clear explanations
- DDD Community - Official community and resources
- Event Sourcing Pattern - History tracking
- CQRS Pattern - Separating read and write
- Aggregate Pattern - Consistency boundaries
- Value Object Pattern - Immutable objects
- Bounded Context - Model boundaries
- Anti-Corruption Layer - Legacy integration
Key Takeaways
- DDD fixes messy architecture by organizing around business domains
- Ubiquitous language ensures everyone understands the domain
- Bounded contexts create ownership and clear boundaries
- Tactical patterns provide concrete implementation patterns
- Strategic design handles domain complexity at scale
- Domain models encapsulate business rules
- Strangler pattern enables gradual migration
- Anti-corruption layers protect your domain from chaos
- Repository pattern abstracts persistence
- DDD scales both in code complexity and team organization
Architecture decisions compound — get your domain boundaries right first, and the rest follows. DDD gives you the language and patterns to build backends that scale with both code complexity and team size.