Skip to content
Bitloops - Git captures what changed. Bitloops captures why.
HomeAbout usDocsBlog
ResourcesSoftware DesignDomain-Driven Design (DDD) Deep Dive: Building Software Around Your Business

Domain-Driven Design (DDD) Deep Dive: Building Software Around Your Business

DDD is the discipline of making your code speak the business language and reflect how your domain actually works. It's the antidote to codebases where the technology has completely obscured the business logic underneath.

13 min readUpdated March 4, 2026Software Design

What Domain-Driven Design Really Is

Domain-Driven Design (DDD) is the practice of building software that directly reflects how your business operates. It's not about technology choice, architecture patterns, or frameworks. It's about alignment: your code speaks the language of the business, your team structure reflects your domain boundaries, and complexity is managed deliberately rather than accidentally.

Eric Evans introduced DDD in 2003 to solve a specific problem: large, complex systems where the business logic is so tangled with infrastructure code that nobody understands what the system actually does. DDD provides patterns and practices to untangle that mess.

The core insight is deceptively simple: the quality of your software depends directly on your understanding of the domain. If your code doesn't match how the business actually works, it will constantly fight you.

Why This Matters

Most software projects fail not because of technical difficulty, but because they misunderstand the problem. A team spends months building something that's not what the business needed. Code is written without clear boundaries, so changes ripple everywhere. Domain experts and engineers speak different languages, creating permanent translation layers and misunderstandings.

DDD fixes this by making domain understanding explicit and central to design. This means:

Reduced rework: When your code reflects domain reality, features align with requirements from day one. You're not building the wrong thing.

Sustainable complexity management: Complex domains are manageable when you split them into clearly bounded contexts with defined interaction rules.

Better communication: Everyone—domain experts, engineers, product managers—speaks the same language. "Order" means the same thing in every conversation.

Easier evolution: As the business changes (and it always does), your code changes because it mirrors business structure, not arbitrary technical decisions.

Team scalability: Multiple teams can work independently because bounded contexts are clear ownership boundaries.

The cost is real: DDD has a steep learning curve and requires discipline. It's not worth applying to simple CRUD apps. But for complex domains with multiple integrating systems, it's the difference between sustainable code and eventual rewrite.

Strategic DDD: Understanding Your Domain

Before you write code, you need to understand the domain. Strategic DDD is how you gain and share that understanding.

Domains and Subdomains

Your domain is everything your business does. It's usually too large to model as a single unit. You need to break it into subdomains—specialized areas of knowledge.

Three types of subdomains matter:

Core Domain: What makes your business unique. This is where you invest best engineers and resources. It's why customers choose you over competitors. In a banking system, the core domain might be loan underwriting. In an e-commerce system, it's the recommendation engine or fraud detection.

Supporting Domain: Necessary for the business but not unique. You might customize it, but it's not a competitive advantage. In banking, customer onboarding is supporting. In e-commerce, inventory management is supporting.

Generic Domain: Completely standard, no customization needed. Buy or outsource these. Authentication, payment processing, email delivery are generic for almost every business.

The critical insight: the same domain can be core, supporting, or generic depending on your business. Payment processing is core for Stripe, supporting for Netflix, and generic for your SaaS application.

Identify subdomains by talking to domain experts and asking: "What do we do that's different from our competitors? What would fail our business if it stopped working?"

Ubiquitous Language

This is the secret weapon of DDD. It's the precise vocabulary shared by everyone on the team: engineers, domain experts, product managers, even documentation.

In a payroll system, terms like "employee," "salary," "deduction," "net pay" aren't random English words—they have precise meanings agreed upon by the entire team. When someone says "we need to calculate net pay," everyone understands exactly what logic that triggers.

Why does this matter?

// Bad: Unclear terminology
class Employee {
  calculateMonthlyAmount() { // What is "amount"? Salary? Net pay?
    return this.salary - this.taxes - this.benefits;
  }
}

// Good: Ubiquitous language
class Employee {
  calculateNetPayForPeriod(payPeriod) {
    return this.salary
      .minus(this.calculateDeductions(payPeriod))
      .forPeriod(payPeriod);
  }

  calculateDeductions(payPeriod) {
    return this.taxes.plus(this.benefits).forPeriod(payPeriod);
  }
}
javascript

The second version uses terms the business uses. A domain expert can read it and verify correctness. The first version forces translation.

Building ubiquitous language:

  1. Listen to domain experts. Write down exactly what they say, not what you think they mean.
  2. Use those terms consistently in code, documentation, tests, conversations.
  3. When you find yourself translating between what experts say and what code says, fix the code.
  4. When terms are ambiguous, add context. "Order" in an e-commerce context is different from "order" in a fulfillment context.

Bounded Contexts

A bounded context is a boundary around a model. Within that boundary, a set of concepts have precise meanings. Outside the boundary, those same words might mean different things.

In a large e-commerce system:

  • Catalog Context: "Product" means a catalog item with description, price, images, ratings.
  • Order Context: "Product" means something you can order, with inventory count and fulfillment rules.
  • Shipping Context: "Product" is irrelevant—it's about packages, weight, dimensions, destination.

Same word, three different meanings. Each bounded context is a mini-system with its own ubiquitous language and model.

Why boundaries matter:

Cognitive load: Your brain can only hold so much. A focused model with clear boundaries is easier to understand than one giant model.

Team autonomy: Teams own bounded contexts. They make decisions independently without coordinating with every other team.

Technology flexibility: Different contexts can use different technologies. Your order processing context might be PostgreSQL while your recommendation context is a specialized graph database. This aligns with microservices architecture principles.

Change isolation: Changes in one context don't force changes in others.

Identifying bounded contexts:

  • Where does the ubiquitous language break down? Where do experts start talking about different aspects?
  • How are teams naturally organized?
  • Where would it be safe to have different terminology for the same concept?
  • Where are integration points natural and well-defined?

A typical e-commerce bounded context map

Catalog Context

Product, Category, Price, Rating, Review

Flows to

Order Context

Order, OrderLine, Customer, Payment, Shipment

Flows to

Inventory Context

Stock, Warehouse, Replenishment, SKU

Context Maps and Relationships

Once you have bounded contexts, you need to define how they interact. Context maps describe these relationships.

Anticorruption Layer (ACL)

When one context depends on another but can't accept that system's model, build an adapter layer that translates concepts.

// External payment system (uncontrolled)
class ExternalPaymentAPI {
  submitTransaction(data) {
    // Uses: { transactionId, amount, vendor_id, merchant_code }
  }
}

// Our Order Context (controlled)
class Payment {
  constructor(orderId, amount) {
    this.orderId = orderId;
    this.amount = amount;
  }
}

// Anticorruption Layer
class PaymentGatewayAdapter {
  constructor(externalAPI) {
    this.externalAPI = externalAPI;
  }

  submitPayment(payment) {
    // Translate from Order Context to External System
    const externalData = {
      transactionId: payment.orderId,
      amount: payment.amount.inCents(),
      vendor_id: this.vendorId,
      merchant_code: this.merchantCode
    };
    return this.externalAPI.submitTransaction(externalData);
  }
}
javascript

The anticorruption layer protects your domain model from external systems. Changes to the external API only affect the adapter, not your core logic.

Shared Kernel

Sometimes two contexts genuinely need to share code. A shared kernel is a small library both contexts use. Keep it minimal and stable.

// Shared kernel - used by multiple contexts
class Money {
  constructor(amount, currency) {
    this.amount = amount;
    this.currency = currency;
  }

  plus(other) {
    // Money logic
  }

  minus(other) {
    // Money logic
  }
}

// Both Order and Inventory contexts use Money
javascript

Customer-Supplier Pattern

One context (customer) depends on another (supplier). The supplier defines a formal interface that the customer can rely on. The supplier commits to backward compatibility.

// Supplier: Catalog Context provides stable API
class CatalogAPI {
  getProductById(id) {
    return {
      id: id,
      name: string,
      basePrice: Money,
      discontinued: boolean
    };
  }
}

// Customer: Order Context consumes it
class OrderService {
  addLineItem(orderId, productId) {
    const product = this.catalog.getProductById(productId);
    // Use the well-defined interface
  }
}
javascript

Conformist Pattern

The downstream context just accepts the upstream context's model without modification. It's useful when upstream is stable and powerful.

// Upstream: Identity Context provides User
class User {
  constructor(id, email, verified) {
    this.id = id;
    this.email = email;
    this.verified = verified;
  }
}

// Downstream: Order Context uses User directly
class Order {
  constructor(user) {
    this.userId = user.id;
    this.customerEmail = user.email;
  }
}
javascript

Tactical DDD: Implementing Domain Models

Strategic DDD gets you clear boundaries and shared language. Tactical DDD is how you actually code the domain model.

Entities and Value Objects

Entities have identity. Two Order objects with the same data but different IDs are different orders.

Value Objects have no identity. They're defined entirely by their attributes. Two Money objects with the same amount and currency are equivalent—it doesn't matter which specific instance you use.

// Entity: has identity, mutable
class Order {
  constructor(id, customerId, items) {
    this.id = id; // Identity
    this.customerId = customerId;
    this.items = items;
    this.createdAt = new Date();
  }

  addItem(item) {
    // Mutating this specific order
    this.items.push(item);
  }
}

// Value Object: no identity, immutable
class Money {
  constructor(amount, currency) {
    this.amount = amount;
    this.currency = currency;
  }

  plus(other) {
    // Returns new Money, doesn't change this one
    return new Money(this.amount + other.amount, this.currency);
  }

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

// Using them together
const order = new Order(1, 123, []);
const price = new Money(100, 'USD');
order.total = price.plus(new Money(10, 'USD')); // New Money object
javascript

Value objects are easier to reason about because they're immutable. Pass them around freely; they can't be changed. Entities need careful handling because they're mutable and have identity.

Aggregates: Consistency Boundaries

An aggregate is a cluster of domain objects that should be treated as a single unit. It has a root (an entity) and a boundary. Everything outside the boundary accesses the aggregate only through the root.

Why? Because the aggregate is a consistency boundary. All invariants (business rules) within the aggregate must be satisfied together. You don't want external code touching internal pieces directly.

// Aggregate: Order and its LineItems
class Order {
  constructor(id, customer) {
    this.id = id;
    this.customer = customer;
    this.items = [];
  }

  addLineItem(product, quantity) {
    // Business logic: can't add items to completed orders
    if (this.isCompleted()) {
      throw new Error("Can't modify completed order");
    }

    const lineItem = new LineItem(product, quantity);
    this.items.push(lineItem);
  }

  isCompleted() {
    return this.status === 'completed';
  }
}

class LineItem {
  constructor(product, quantity) {
    this.product = product;
    this.quantity = quantity;
  }
}

// Good: Access through the aggregate root
order.addLineItem(product, 5);

// Bad: Reaching inside the aggregate
order.items.push(new LineItem(product, 5)); // Bypasses business rules
javascript

The aggregate protects its invariants by forcing all modifications through methods that enforce rules.

Aggregates should be:

  • Small: Don't aggregate everything. Keep aggregates focused.
  • Consistent: All invariants enforced within the boundary.
  • Independent: Aggregates shouldn't require each other to maintain consistency.

Domain Events

Domain events represent something important that happened in the domain. They're facts about state changes.

// Domain Event
class OrderPlacedEvent {
  constructor(orderId, customerId, items, timestamp) {
    this.orderId = orderId;
    this.customerId = customerId;
    this.items = items;
    this.timestamp = timestamp;
  }
}

// Aggregate raising events
class Order {
  constructor(id, customer) {
    this.id = id;
    this.customer = customer;
    this.items = [];
    this.changes = []; // Track domain events
  }

  placeOrder() {
    if (this.items.length === 0) {
      throw new Error("Order must have items");
    }

    this.status = 'placed';

    // Raise domain event
    this.changes.push(
      new OrderPlacedEvent(this.id, this.customer.id, this.items, new Date())
    );
  }

  getDomainEvents() {
    return this.changes;
  }

  clearDomainEvents() {
    this.changes = [];
  }
}

// Outside the aggregate, other parts of the system listen for events
class OrderPlacedHandler {
  handle(event) {
    // Notify customer, start fulfillment, update analytics, etc.
  }
}
javascript

Domain events decouple aggregates. When Order needs to trigger something in Inventory, it doesn't call Inventory directly. It raises an OrderPlacedEvent that Inventory listens to.

Repositories: Aggregate Persistence

Repositories make aggregates look like in-memory collections, hiding persistence details.

// Repository Interface
class OrderRepository {
  save(order) {
    // Persist aggregate and its events
  }

  getById(id) {
    // Retrieve aggregate from storage
  }

  list() {
    // List aggregates
  }
}

// Repository Implementation
class SqlOrderRepository {
  constructor(database) {
    this.db = database;
  }

  save(order) {
    this.db.execute(
      'INSERT INTO orders VALUES (?, ?, ?)',
      [order.id, order.customer.id, order.status]
    );

    // Save domain events for event sourcing
    for (let event of order.getDomainEvents()) {
      this.db.execute(
        'INSERT INTO domain_events VALUES (?, ?, ?)',
        [order.id, event.constructor.name, JSON.stringify(event)]
      );
    }

    order.clearDomainEvents();
  }

  getById(id) {
    const row = this.db.query('SELECT * FROM orders WHERE id = ?', [id]);
    const order = new Order(row.id, row.customerId);
    order.status = row.status;
    return order;
  }
}

// Usage: Repository makes persistence invisible
const orderRepo = new SqlOrderRepository(database);
const order = new Order(1, customer);
order.addLineItem(product, 5);
order.placeOrder();
orderRepo.save(order); // Persistence is hidden
javascript

The repository pattern is crucial because it lets you:

  • Change how aggregates are persisted without changing domain logic
  • Test aggregates in memory without a database
  • Use different persistence strategies for different aggregates

Domain Services

Sometimes logic doesn't belong to any single entity or value object. It belongs to a domain service.

// Domain Service
class MoneyTransferService {
  transfer(sourceAccount, destinationAccount, amount) {
    if (!sourceAccount.canTransfer(amount)) {
      throw new Error("Insufficient funds");
    }

    sourceAccount.debit(amount);
    destinationAccount.credit(amount);

    // Important: both accounts are persisted together
    // or the transfer is atomic
  }
}

// Usage
const transferService = new MoneyTransferService();
transferService.transfer(myAccount, yourAccount, money);
javascript

Domain services are rare in well-designed models. If you're writing a lot of them, it might mean your aggregates and entities are too anemic.

Layered Architecture for DDD

DDD suggests layering your application:

Presentation Layer: UI, API, commands from users.

Application Layer: Orchestration. Takes requests, loads aggregates, calls domain logic, saves results. No business logic here.

Domain Layer: The model. Entities, value objects, aggregates, repositories, domain services. Pure business logic.

Infrastructure Layer: Technical concerns. Database, message queues, external APIs, logging.

Layered architecture

Presentation Layer

Controllers, Views

Application Layer

Use Cases, Command Handlers

Domain Layer

Model, Business Logic

Infrastructure Layer

DB, Messaging, External APIs

Application Layer Example

class CreateOrderHandler {
  constructor(orderRepository, catalogService, logger) {
    this.orderRepository = orderRepository;
    this.catalogService = catalogService;
    this.logger = logger;
  }

  handle(command) {
    // Load what we need
    const customer = this.customerRepository.getById(command.customerId);

    // Create aggregate
    const order = new Order(generateId(), customer);

    // Add business logic
    for (let item of command.items) {
      const product = this.catalogService.getProduct(item.productId);
      order.addLineItem(product, item.quantity);
    }

    order.placeOrder();

    // Persist
    this.orderRepository.save(order);

    // Publish events
    for (let event of order.getDomainEvents()) {
      this.eventBus.publish(event);
    }

    this.logger.info(`Order ${order.id} created`);

    return order.id;
  }
}
javascript

The application layer is thin but crucial. It orchestrates without making decisions.

DDD in AI-Native Development

As code generation becomes more common, DDD becomes more important. AI-generated code tends toward the anemic (weak domain models with all logic in services) because it's easier to generate. Clear domain models force even AI to respect boundaries.

At Bitloops, we use DDD as the foundation for code generation. Because the model is explicit, we can generate correct, testable, and maintainable code that respects domain boundaries.

FAQ

Isn't DDD too much for small projects?

Yes. DDD has overhead. Use it when you have complex business logic or multiple teams. If you can hold the whole system in your head, simpler approaches work fine.

How do I know my bounded contexts are the right size?

A bounded context should be small enough for one team to understand and work on independently, large enough to contain a coherent domain. There's no formula; it's judgment.

Can I mix DDD with microservices?

Absolutely. Microservices often align with bounded contexts. Each service is one bounded context, with its own database.

Does DDD require event sourcing?

No. Event sourcing is optional. DDD works fine with traditional databases. Domain events are useful for integration even without event sourcing.

My domain experts don't have time for workshops. What do I do?

DDD requires domain knowledge. If you don't have access to it, DDD is harder. Focus on getting that access—it's the most important part.

When should I refactor to DDD?

When complexity becomes painful. When you're constantly fighting the model. When different teams keep stepping on each other. Don't refactor too early.

Primary Sources

  • Eric Evans' foundational text on modeling complex domains and creating shared language. Domain-Driven Design
  • Vaughn Vernon's practical guide to implementing DDD patterns in real enterprise projects. Implementing DDD
  • Martin Fowler's patterns for organizing application architecture and domains. EAA Patterns
  • Refactoring techniques for safely evolving domain models as understanding improves. Refactoring
  • Strategies for maintaining and evolving legacy systems with DDD principles. Working with Legacy Code
  • Test-driven approach for developing rich, behavior-driven domain models. TDD by Example

Get Started with Bitloops.

Apply what you learn in these hubs to real AI-assisted delivery workflows with shared context, traceable reasoning, and architecture-aware engineering practices.

curl -sSL https://bitloops.com/install.sh | bash