Ideas Engineered for Tomorrow
We Engineer Services & Solutions for Your Business Needs
Home About
Products
Services
Hire
Industries
Consulting
Partners
Articles Careers Contact
Software Development

Domain-Driven Design: A Practical Guide for Developers

Most DDD tutorials drown you in theory. This one starts with the code you'll actually write — bounded contexts, aggregates, and domain events in a real e-commerce system.

📖 Architecture January 21, 2026 15 min read

In This Guide

Domain-Driven Design is a way of structuring code around business concepts instead of technical layers. Instead of organizing by Controllers, Services, Repositories, you organize by Order, Inventory, Shipping — the things your business actually cares about. We adopted DDD for a logistics client whose codebase had become a tangle of 200+ services with unclear boundaries. Six months later, their deploy frequency went from weekly (with fear) to daily (with confidence). The secret wasn't the tactical patterns — it was getting developers and domain experts speaking the same language.

1. Why DDD? The Complexity Problem

Most software projects don't fail because of technology. They fail because developers build the wrong thing, or build the right thing with the wrong structure. A payment system where "Order" means one thing to the sales team and another thing to the warehouse team will have bugs baked into its architecture.

DDD solves this by making the domain model — the mental model of how your business works — the central artifact of your software. Not the database schema. Not the API contract. The domain.

Traditional Approach DDD Approach
Start with database tablesStart with domain concepts and behaviors
One big model for the whole appMultiple models, each optimized for its context
Organize by technical layer (controllers, services)Organize by business capability (orders, shipping)
Business logic scattered across servicesBusiness logic concentrated in domain objects
Devs and business speak different languagesShared ubiquitous language across code and conversation

DDD has two layers: strategic design (how you divide the system into bounded contexts) and tactical design (how you structure code within each context). Most teams jump to tactical patterns and skip strategic design. That's backwards — getting the boundaries right matters more than getting the aggregate pattern perfect.

2. Ubiquitous Language — The Foundation

Ubiquitous language means everyone — developers, product managers, domain experts — uses the same terms to describe the same concepts. And those terms appear in the code. If the business calls it a "shipment," the code has a Shipment class, not a DeliveryPackage or TransportUnit.

This sounds trivial until you discover that:

How we build the language: We run Event Storming sessions with sticky notes on a wall. Domain experts and developers map out business processes as events: "Order Placed," "Payment Received," "Shipment Dispatched." The language that emerges from these sessions becomes the class names and method names in the code. If the sticky note says "Order Placed," the code has OrderPlaced as a domain event. No translation layer.

3. Bounded Contexts — Drawing the Lines

A bounded context is a boundary within which a particular model is defined and consistent. "Product" means one thing inside the Catalog context and another inside the Pricing context. Both are valid — they're just different perspectives on the same real-world thing.

E-commerce system — Bounded Contexts:

┌─────────────────────┐  ┌─────────────────────┐
Catalog Context │  │ Pricing Context
│ │  │ │
│ Product: │  │ Product: │
│ - name │  │ - sku │
│ - description │  │ - basePrice │
│ - images[] │  │ - discountRules[] │
│ - categories[] │  │ - taxCategory │
│ - specifications │  │ - priceHistory[] │
└─────────┬───────────┘  └─────────┬───────────┘
          │                       │
          │ ProductId            │ SKU
          ▼                       ▼
┌─────────────────────┐  ┌─────────────────────┐
Inventory Context │  │ Order Context
│ │  │ │
│ StockItem: │  │ OrderItem: │
│ - sku │  │ - productId │
│ - warehouseId │  │ - quantity │
│ - quantity │  │ - unitPrice │
│ - reservations[] │  │ - lineTotal │
│ - reorderPoint │  │ - status │
└─────────────────────┘  └─────────────────────┘

Notice: "Product" appears in three contexts with completely different shapes. In the Catalog context, it has images and descriptions. In Pricing, it has discount rules and tax categories. In Inventory, it's not even called "Product" — it's a "StockItem." Each context has the exact data it needs, nothing more.

How to find your bounded contexts:

4. Aggregates — Consistency Boundaries

An aggregate is a cluster of domain objects that are treated as a single unit for data changes. The aggregate root is the entry point — all modifications go through it. You never reach inside an aggregate to modify a child entity directly.

Think of an Order aggregate: it contains OrderItems, a ShippingAddress, and PaymentDetails. You don't modify an OrderItem directly — you call order.addItem() or order.removeItem(). The Order aggregate enforces business rules: "can't add items to a shipped order," "minimum order total is $10."

// Order Aggregate (TypeScript)

class Order {
  private id: OrderId;
  private items: OrderItem[] = [];
  private status: OrderStatus = 'draft';
  private events: DomainEvent[] = [];

  // Business rule: can't add items to confirmed orders
  addItem(product: ProductRef, quantity: number, unitPrice: Money): void {
    if (this.status !== 'draft') {
      throw new OrderAlreadyConfirmedError(this.id);
    }
    if (quantity < 1) {
      throw new InvalidQuantityError(quantity);
    }

    const existing = this.items.find(i => i.productId.equals(product.id));
    if (existing) {
      existing.increaseQuantity(quantity);
    } else {
      this.items.push(new OrderItem(product.id, quantity, unitPrice));
    }

    this.events.push(new ItemAddedToOrder(this.id, product.id, quantity));
  }

  // Business rule: minimum order total + all items must have prices
  confirm(): void {
    if (this.items.length === 0) {
      throw new EmptyOrderError();
    }
    const total = this.calculateTotal();
    if (total.isLessThan(Money.of(10, 'USD'))) {
      throw new MinimumOrderNotMetError(total);
    }

    this.status = 'confirmed';
    this.events.push(new OrderConfirmed(this.id, total));
  }

  private calculateTotal(): Money {
    return this.items.reduce(
      (sum, item) => sum.add(item.lineTotal()),
      Money.zero('USD')
    );
  }

  pullDomainEvents(): DomainEvent[] {
    const events = [...this.events];
    this.events = [];
    return events;
  }
}

Aggregate Design Rules

Rule Why Example
Keep aggregates smallLarge aggregates cause lock contentionOrder has items, but Customer is a separate aggregate
Reference other aggregates by IDPrevents loading the entire object graphcustomerId: CustomerId not customer: Customer
One transaction per aggregateGuarantees consistency within the boundaryDon't update Order and Inventory in the same transaction
Use domain events for cross-aggregateMaintains decoupling between aggregatesOrderConfirmed triggers inventory reservation
The biggest aggregate mistake: Making everything one giant aggregate. We once reviewed a codebase where the "Customer" aggregate included orders, addresses, payment methods, preferences, wishlist, reviews, and support tickets. Loading a customer pulled 15 tables. Saving a customer created lock contention across the whole system. The fix: Customer, Order, Wishlist, and SupportTicket became separate aggregates linked by customerId.

5. Entities vs Value Objects

These are the building blocks inside aggregates. The distinction is simple but important:

Entity Value Object
IdentityHas a unique ID that persistsDefined by its attributes, no ID
EqualityEqual if same ID (even if attributes differ)Equal if all attributes are the same
MutabilityCan change over timeImmutable — create a new one instead
ExamplesCustomer, Order, ProductMoney, Address, EmailAddress, DateRange
// Value Object: Money (immutable, equality by value)
class Money {
  private constructor(
    private readonly amount: number,
    private readonly currency: string
  ) {
    if (amount < 0) throw new NegativeAmountError(amount);
    if (!['USD', 'EUR', 'INR', 'GBP'].includes(currency)) {
      throw new InvalidCurrencyError(currency);
    }
  }

  static of(amount: number, currency: string): Money {
    return new Money(Math.round(amount * 100) / 100, currency);
  }

  static zero(currency: string): Money {
    return new Money(0, currency);
  }

  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new CurrencyMismatchError(this.currency, other.currency);
    }
    return Money.of(this.amount + other.amount, this.currency);
  }

  multiply(factor: number): Money {
    return Money.of(this.amount * factor, this.currency);
  }

  isLessThan(other: Money): boolean {
    return this.amount < other.amount;
  }

  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }
}

Value objects are one of DDD's most underused patterns. We see codebases where money is a raw number, emails are string, and coordinates are {lat: number, lng: number}. Wrapping these in value objects catches bugs at construction time (negative money, invalid email format) instead of at runtime in production.

6. Domain Events — Making Things Happen

Domain events represent something that happened in the domain that other parts of the system might care about. "OrderConfirmed," "PaymentReceived," "InventoryDepleted." They're the glue between aggregates and between bounded contexts.

// Domain events are facts — past tense, immutable

interface DomainEvent {
  readonly eventId: string;
  readonly occurredAt: Date;
  readonly aggregateId: string;
}

class OrderConfirmed implements DomainEvent {
  readonly eventId = crypto.randomUUID();
  readonly occurredAt = new Date();

  constructor(
    readonly aggregateId: string,
    readonly orderTotal: Money,
    readonly items: ReadonlyArray<{ productId: string; quantity: number }>
  ) {}
}

class PaymentReceived implements DomainEvent {
  readonly eventId = crypto.randomUUID();
  readonly occurredAt = new Date();

  constructor(
    readonly aggregateId: string,
    readonly amount: Money,
    readonly paymentMethod: string
  ) {}
}

// Handlers react to events — in-process or via message broker
class WhenOrderConfirmed {
  async handle(event: OrderConfirmed): Promise<void> {
    // Reserve inventory
    await this.inventoryService.reserve(event.items);
    // Send confirmation email
    await this.emailService.sendOrderConfirmation(event.aggregateId);
    // Update analytics
    await this.analytics.trackOrder(event.orderTotal);
  }
}

Notice the naming: always past tense ("OrderConfirmed," not "ConfirmOrder"). Events are facts about what already happened. Commands are requests that might be rejected. This naming convention prevents confusion about whether something has happened or is being asked for.

7. Repositories and Domain Services

Repositories — Persistence Abstraction

A repository gives you the illusion that aggregates live in an in-memory collection. You save an Order, you find an Order by ID — the repository hides whether that's PostgreSQL, MongoDB, or a file. One repository per aggregate root.

// Repository interface (domain layer — no database specifics)
interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
  nextId(): OrderId;
}

// Implementation (infrastructure layer — PostgreSQL)
class PostgresOrderRepository implements OrderRepository {
  async findById(id: OrderId): Promise<Order | null> {
    const row = await this.db.query(
      'SELECT * FROM orders WHERE id = $1', [id.value]
    );
    if (!row) return null;
    return this.toDomain(row); // hydrate the aggregate
  }

  async save(order: Order): Promise<void> {
    const data = this.toPersistence(order);
    await this.db.query(
      `INSERT INTO orders (id, customer_id, status, total)
       VALUES ($1, $2, $3, $4)
       ON CONFLICT (id) DO UPDATE SET status = $3, total = $4`,
      [data.id, data.customerId, data.status, data.total]
    );

    // Publish domain events after successful persistence
    const events = order.pullDomainEvents();
    for (const event of events) {
      await this.eventBus.publish(event);
    }
  }
}

Domain Services — Logic That Doesn't Fit in Entities

Sometimes business logic doesn't naturally belong to any single entity. Calculating shipping cost depends on the order weight, the destination address, and the carrier rates. None of those entities "own" that logic. That's a domain service.

// Domain service — stateless, orchestrates multiple aggregates
class ShippingCostCalculator {
  calculate(order: Order, destination: Address, carrier: Carrier): Money {
    const weight = order.totalWeight();
    const zone = carrier.getZone(destination);
    const baseRate = carrier.rateForZone(zone, weight);

    // Business rule: free shipping over $100 for domestic
    if (zone.isDomestic() && order.subtotal().isGreaterThan(Money.of(100, 'USD'))) {
      return Money.zero('USD');
    }

    return baseRate;
  }
}

8. Strategic Design — Context Mapping

Context mapping describes how bounded contexts relate to each other. This is where most of the political and architectural decisions happen.

Pattern Relationship Real-World Example
Shared KernelTwo teams share a small model (both must agree on changes)Orders and Billing share the Money value object
Customer-SupplierUpstream provides, downstream consumesOrder context (upstream) feeds Shipping context (downstream)
ConformistDownstream accepts upstream's model as-isYour app conforms to Stripe's payment model — you don't negotiate
Anti-Corruption LayerTranslation layer protects your model from external modelsLegacy ERP uses different terms — ACL translates to your domain language
Open Host ServicePublished API with documented protocolYour public API that multiple external consumers integrate with
Separate WaysNo integration — contexts are independentMarketing site and core product have no shared model

The Anti-Corruption Layer is the pattern we use most. When integrating with legacy systems or third-party APIs, we build a translation layer that converts their model to our domain language. This means our domain model stays clean even if the external system is messy. When Stripe changes their API, only the ACL changes — our payment domain stays the same.

// Anti-Corruption Layer for Stripe integration
class StripePaymentAdapter implements PaymentGateway {
  async charge(payment: Payment): Promise<PaymentResult> {
    // Translate OUR domain model → Stripe's model
    const stripeCharge = await stripe.charges.create({
      amount: payment.amount.toCents(),  // Money → integer cents
      currency: payment.amount.currency.toLowerCase(),
      source: payment.tokenId,
      metadata: { orderId: payment.orderId.value }
    });

    // Translate Stripe's response → OUR domain model
    return new PaymentResult(
      stripeCharge.status === 'succeeded' ? 'completed' : 'failed',
      PaymentId.from(stripeCharge.id),
      Money.of(stripeCharge.amount / 100, stripeCharge.currency.toUpperCase())
    );
  }
}

9. When NOT to Use DDD

DDD adds overhead. It's justified for complex business domains where getting the model wrong costs real money. It's overkill for simple CRUD applications.

Our rule of thumb: If the domain expert says "it's complicated" and the developers say "it's complicated," you need DDD. If the domain expert says "it's straightforward" and the developers are making it complicated — simplify the code, don't add DDD patterns. DDD tames inherent complexity. It shouldn't create accidental complexity.

Project Structure We Use

src/
├── order/                     ← Bounded Context
│  ├── domain/
│  │  ├── Order.ts            ← Aggregate Root
│  │  ├── OrderItem.ts        ← Entity
│  │  ├── Money.ts            ← Value Object
│  │  ├── OrderStatus.ts      ← Value Object (enum)
│  │  ├── OrderConfirmed.ts   ← Domain Event
│  │  └── OrderRepository.ts  ← Repository Interface
│  ├── application/
│  │  ├── PlaceOrder.ts       ← Use Case / Command Handler
│  │  ├── GetOrderDetails.ts  ← Query Handler
│  │  └── OrderService.ts     ← Application Service
│  └── infrastructure/
│     ├── PostgresOrderRepo.ts← Repository Implementation
│     └── OrderController.ts  ← HTTP Adapter
├── inventory/                 ← Another Bounded Context
│  ├── domain/
│  ├── application/
│  └── infrastructure/
└── shared/                    ← Shared Kernel
   ├── Money.ts
   └── DomainEvent.ts

10. Frequently Asked Questions

Can I use DDD with a relational database, or does it require NoSQL?

DDD works perfectly with relational databases. PostgreSQL is our default choice. The repository pattern hides persistence details, so your domain model doesn't care whether data is in PostgreSQL, MongoDB, or DynamoDB. The mapping layer (repository implementation) handles the translation between domain objects and database rows. ORMs like Prisma or TypeORM can help but aren't required.

How do bounded contexts map to microservices?

A bounded context is a logical boundary; a microservice is a deployment boundary. They often align, but they don't have to. You can have multiple bounded contexts inside a monolith (modular monolith) or a single bounded context split across multiple microservices. We recommend starting with a modular monolith where each module is a bounded context, then extracting to microservices only when you need independent deployment or scaling.

What's the difference between a domain event and an integration event?

Domain events are internal to a bounded context — they represent what happened within that context's model. Integration events cross context boundaries and are part of your public contract. Domain events can change freely (internal refactoring). Integration events need versioning and backward compatibility because other teams depend on them. We publish domain events on an in-process bus and integration events to Kafka.

How do I introduce DDD to an existing codebase?

Don't rewrite. Start by identifying your most complex domain area — the part of the code that's hardest to change and causes the most bugs. Apply DDD tactically there: extract a domain model, introduce value objects for primitive obsession, define aggregate boundaries. Keep the rest of the codebase as-is. The anti-corruption layer pattern lets your new DDD module coexist with the legacy code. Migrate incrementally.

PI
Pillai Infotech Engineering Team

We've applied Domain-Driven Design to complex business domains in logistics, fintech, and healthcare — systems where getting the model wrong means lost revenue and compliance failures. Our approach is pragmatic: use DDD where it earns its complexity, and simpler patterns everywhere else.

Related Articles

Event-Driven Architecture: Design Patterns and Implementation → Microservices vs Monolith: When to Make the Switch → Clean Architecture: Building Maintainable Software →