• 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:

  1. Creating ubiquitous language - everyone speaks the same language
  2. Identifying bounded contexts - clear business domains
  3. Encapsulating business logic - in domain models, not services
  4. Using tactical patterns - repository, factory, value objects, etc.
  5. 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.

AspectDDD OnlyMicroservices (No DDD)DDD + Microservices
ClarityClear domainsUnclear boundariesCrystal clear
ComplexityManageableDistributed complexityWell-managed
CostLow opsHigh opsMedium ops
ScalabilityGoodGreatExcellent
Team autonomyMediumHighHigh

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:


Key Takeaways

  1. DDD fixes messy architecture by organizing around business domains
  2. Ubiquitous language ensures everyone understands the domain
  3. Bounded contexts create ownership and clear boundaries
  4. Tactical patterns provide concrete implementation patterns
  5. Strategic design handles domain complexity at scale
  6. Domain models encapsulate business rules
  7. Strangler pattern enables gradual migration
  8. Anti-corruption layers protect your domain from chaos
  9. Repository pattern abstracts persistence
  10. 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.