DDDDomain-Driven DesignArchitectureSoftware Design

Revisiting Domain-Driven Design (DDD)

Sloth255
Sloth255
ยท20 min readยท4,383 words

Introduction

"I kind of know what DDD is, but what do I actually need to do for something to count as DDD?"
"What is the real difference between an Entity and a Value Object?"
"If I create a Repository, does that make it DDD?"

Many people probably have questions like these. In this article, drawing on Eric Evans's Domain-Driven Design and Vaughn Vernon's Implementing Domain-Driven Design, I will reorganize the essence of DDD into a form you can actually use in practice.

The explanations of the main terms are aligned as closely as possible with Eric Evans's DDD Reference and Vaughn Vernon's official overview.

What Is DDD?

Domain-Driven Design (DDD) is a design approach based on the idea that, when developing software with complex business logic,

the domain should sit at the center of the software, and the code should be written in the language of that domain

The key point is that DDD is neither a framework nor an architecture. It is a collection of ideas and techniques. That is why simply adding a DDD library does not make your system "DDD."

DDD Is Broadly Split into Two Layers

DDD can be divided into the following two parts.

Category Also called What it does
Strategic Design Strategic Design Splits the domain appropriately and draws clear boundaries
Tactical Design Tactical Design Expresses the domain at the code level

It is easy to think "DDD means creating Entities and Value Objects," but that is only the tactical side. What really matters even more is strategic design.

Strategic Design

Ubiquitous Language

This is the starting point of DDD and its most important concept.

Domain experts, developers, and stakeholders use the same words

For example, when people on an e-commerce site say "order":

  • Marketing: "The moment an item is put in the cart is an order"
  • Accounting: "The moment payment is completed is an order"
  • Logistics: "The moment a shipping instruction is issued is an order"

It is very common for the meaning to vary from person to person. If you carry that ambiguity straight into the code, nobody will know what the Order class is supposed to mean.

Make the model the backbone of the language, and keep using that language in both conversation and code. That is the foundation of DDD.

Bounded Context

A ubiquitous language does not need to be unified across the entire organization, and in fact it usually cannot be.

So you draw a boundary that says, "Within this scope, this word means this." That is a Bounded Context. The boundary is not just an agreement in conversation. It also shows up in team structure, the scope where the model applies inside the application, and the separation of codebases and data stores.

flowchart LR
  subgraph Sales["Sales Context"]
    S["Order =<br/>Purchase intent after checkout"]
  end
  subgraph Logistics["Logistics Context"]
    L["Order =<br/>Shippable package with ship order"]
  end
  N["The same Order may mean different things"]

  S -.-> N
  L -.-> N

  classDef context fill:#eef2ff,stroke:#6366f1,color:#312e81
  classDef note fill:#ffffff,stroke:#94a3b8,color:#334155,stroke-dasharray: 5 5
  class S,L context
  class N note

The map of these boundaries is the context map. It is also a powerful guide when deciding microservice boundaries.

How to Extract a Domain from Requirements

Everyone eventually asks, "So how do I actually find the domain?" Here are a few approaches that are genuinely useful in real projects.

1. Treat interviews as a chance to collect verbs and nouns

When talking with customers or business stakeholders, consciously separate the nouns = candidate models from the verbs = candidate behaviors and write them down that way.

"A sales representative creates a quotation, sends it to the customer, and when it is approved, turns it into an order."

From that one sentence alone, you can identify:

  • Nouns: sales representative, quotation, customer, order -> candidates for Entities / Value Objects
  • Verbs: create, send, approve, turn into an order -> candidates for behaviors / domain events

The words that business people use unconsciously are the raw ore of the ubiquitous language.

2. Use EventStorming

For complex domains, EventStorming, where you lay out events on sticky notes on a whiteboard or in tools like Miro or FigJam, is extremely effective.

flowchart LR
  E1["๐ŸŸง Quote requested"] --> E2["๐ŸŸง Quote created"] --> E3["๐ŸŸง Quote sent"]
  E3 --> C2["๐ŸŸฆ Command<br/>Approve quote"] --> E4["๐ŸŸง Quote approved"] --> E5["๐ŸŸง Order accepted"]
  E3 --> C1["๐ŸŸฆ Command<br/>Cancel quote"] --> E6["๐ŸŸง Quote cancelled"]
  A1["๐ŸŸช Aggregate<br/>Quote"] -.thing that triggers the event.-> E2
  R1["๐ŸŸจ Rule<br/>Only approved quotes can be converted into orders"] -.constraint.-> E5

  classDef event fill:#fdba74,stroke:#ea580c,color:#7c2d12
  classDef command fill:#bfdbfe,stroke:#2563eb,color:#1e3a8a
  classDef aggregate fill:#ddd6fe,stroke:#7c3aed,color:#4c1d95
  classDef rule fill:#fde68a,stroke:#ca8a04,color:#713f12
  class E1,E2,E3,E4,E5,E6 event
  class C1,C2 command
  class A1 aggregate
  class R1 rule
  • ๐ŸŸง Orange sticky notes: domain events, written in the past tense
  • ๐ŸŸฆ Blue sticky notes: commands, meaning someone's action
  • ๐ŸŸช Purple sticky notes: aggregates, the things that trigger the events. Example: Quote
  • ๐ŸŸจ Yellow sticky notes: business rules / constraints. Example: "Only approved quotes can be converted into orders"

For example, "Approve quote" is a command because it is an instruction someone performs. By contrast, "Quote approved" is a domain event because it is a fact that has already happened.

Doing this together with business stakeholders makes tacit knowledge suddenly visible. Its biggest value is that conversations arise naturally: "Oh, a decision happens at this point?" "Actually, in this case there is an exception..."

3. Always ask about exceptional cases

The richest source of information in requirements interviews is the exception flow.

Question What it reveals
"In what situations can this not be done?" Invariants and business rules
"What kinds of cases caused disputes in the past?" Hidden constraints
"What are people working around manually?" Business rules that have never been implemented
"What has changed in this operation over the last five years?" Volatile parts vs. stable parts

If you only ask about the happy path, the shape of the domain never really comes into view. The essence of the domain hides in the painful parts.

4. Classify them into core, supporting, and generic

Not every extracted domain needs to be implemented with the same weight.

Category Description Strategy
Core Domain The source of the business's competitive advantage Build it carefully in-house with DDD
Supporting Subdomain Business capabilities that support the core Implement in-house in a lightweight way or outsource
Generic Subdomain Common to every company, such as authentication or payments Use SaaS / OSS

Taking an e-commerce site as an example:

  • Core: product recommendations, pricing strategy, inventory allocation
  • Supporting: product catalog management, delivery coordination
  • Generic: authentication, payments, email sending

If you try to go all out on everything, the project will reliably collapse. Making it explicit where to invest effort is the first step in boundary design.

5. Criteria for drawing context boundaries

It takes experience to decide where to draw boundaries, but the following signs make it easier.

  • ๐Ÿšฉ The same word changes meaning (the Order example at the beginning)
  • ๐Ÿšฉ The responsible organization or department changes (Conway's Law)
  • ๐Ÿšฉ The lifecycle differs (orders and customers do not live on the same timeline)
  • ๐Ÿšฉ The rate of change differs (pricing strategy changes often, address management not so much)
  • ๐Ÿšฉ The required level of consistency differs (inventory needs strong consistency, recommendations can tolerate eventual consistency)

When you feel one of these misalignments, you have probably found a candidate boundary.

Tactical Design

From here on, we move into the code.

Value Object

A Value Object represents "the value itself" and has the following characteristics:

  • Immutable
  • Compared by value equality rather than identity
  • Has no side effects
// โŒ Just a string
const email: string = "user@example.com";

// โœ… Value Object
class Email {
  constructor(private readonly value: string) {
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
      throw new Error("Invalid email address");
    }
  }
  equals(other: Email): boolean {
    return this.value === other.value;
  }
  toString(): string {
    return this.value;
  }
}

The point is to encapsulate the constraint in the type that says, "this is a valid email address." Validation stops being scattered all over the codebase.

Entity

An Entity is an object whose identity is determined by an identifier (ID).

class User {
  constructor(
    public readonly id: UserId,
    private name: UserName,
    private email: Email,
  ) {}

  changeEmail(newEmail: Email): void {
    this.email = newEmail;
  }

  equals(other: User): boolean {
    return this.id.equals(other.id);
  }
}

Even if the name changes or the email changes, if the id is the same, it is the same User. That is the essence of an Entity.

Aggregate

An Aggregate is a consistency boundary that groups multiple Entities and Value Objects into one unit.

The entity that serves as the entry point is called the Aggregate Root, and external code must always access the aggregate through that root.

// Aggregate root
class Order {
  private items: OrderItem[] = [];

  constructor(
    public readonly id: OrderId,
    private readonly userId: UserId,
  ) {}

  // The aggregate root protects consistency inside the aggregate.
  addItem(product: Product, quantity: number): void {
    if (this.items.length >= 100) {
      throw new Error("The item limit per order has been exceeded");
    }
    this.items.push(new OrderItem(product.id, quantity, product.price));
  }

  totalPrice(): Money {
    return this.items.reduce(
      (sum, item) => sum.add(item.subtotal()),
      Money.zero(),
    );
  }
}

A fundamental rule is: keep aggregates as small as possible. If they grow too large, you will suffer from update conflicts and load costs.

Repository

A Repository is an abstraction responsible for persisting aggregates. It exists so that the domain layer does not depend on infrastructure.

// Domain layer: define only the interface.
interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

// Infrastructure layer: place the implementation outside.
class PostgresOrderRepository implements OrderRepository {
  async findById(id: OrderId): Promise<Order | null> { /* ... */ }
  async save(order: Order): Promise<void> { /* ... */ }
}

What matters here is that a Repository is an abstraction that provides global access to aggregate roots and lets callers treat them like a collection. Think of it not as "something that saves to the database," but as "the gateway for storing and retrieving aggregates."

Domain Service

A Domain Service is where you place important domain processes or transformations that do not fit naturally as responsibilities of an Entity or a Value Object.

// A duplicate email-address check cannot be decided by User alone.
class UserDuplicationChecker {
  constructor(private readonly userRepository: UserRepository) {}

  async exists(email: Email): Promise<boolean> {
    return (await this.userRepository.findByEmail(email)) !== null;
  }
}

A decision flow for when you are unsure: Entity or Service?

During implementation, one of the most common questions is, "Should this logic live in the Entity or in a Service?" The following flow handles most cases.

flowchart TB
  Start["Does the logic..."] --> Q1{"only change the state of a single object?"}
  Q1 -- YES --> E["Entity method"]
  Q1 -- NO --> Q2{"make a decision across multiple aggregates?"}
  Q2 -- YES --> D["Domain Service"]
  Q2 -- NO --> Q3{"require a repository or external service?"}
  Q3 -- YES --> D
  Q3 -- NO --> Q4{"read more naturally as a verb than a noun?"}
  Q4 -- YES --> D
  Q4 -- NO --> V["Entity / Value Object"]

  classDef question fill:#eef2ff,stroke:#6366f1,color:#312e81
  classDef answer fill:#ede9fe,stroke:#7c3aed,color:#4c1d95
  class Q1,Q2,Q3,Q4 question
  class E,D,V answer
โœ… What should live in an Entity

Logic that protects its own state and invariants belongs to the Entity.

class Order {
  private status: OrderStatus;
  private items: OrderItem[];

  // โœ… Protect its own state transitions.
  cancel(): void {
    if (this.status === OrderStatus.SHIPPED) {
      throw new Error("A shipped order cannot be cancelled");
    }
    this.status = OrderStatus.CANCELLED;
  }

  // โœ… Logic that only calculates from its own data.
  totalPrice(): Money {
    return this.items.reduce(
      (sum, item) => sum.add(item.subtotal()),
      Money.zero(),
    );
  }

  // โœ… Check its own invariant.
  addItem(item: OrderItem): void {
    if (this.items.length >= 100) throw new Error("The limit has been exceeded");
    this.items.push(item);
  }
}

A useful criterion is this: If it sounds natural to personify it and ask, "Order, can you be cancelled?" then it is probably the Entity's responsibility.

โœ… What should live in a Domain Service

Logic that cannot be decided by a single Entity alone, or requires interaction with the outside world, belongs in a Domain Service.

// โœ… Transfer logic that spans two aggregates.
class TransferService {
  transfer(from: Account, to: Account, amount: Money): void {
    from.withdraw(amount);
    to.deposit(amount);
    // The concept of transfer belongs to neither account alone.
  }
}

// โœ… A duplicate check that requires a repository.
class UserDuplicationChecker {
  constructor(private readonly userRepository: UserRepository) {}
  async exists(email: Email): Promise<boolean> {
    return (await this.userRepository.findByEmail(email)) !== null;
  }
}

A useful criterion here is: If you ask, "User, do you know whether this email address is already registered by someone else?" and the natural answer is "How would I know?" then it belongs in a Service.

โŒ Anti-pattern: putting everything in a Service
// โŒ Do not do this.
class OrderService {
  cancel(order: Order): void {
    if (order.status === "SHIPPED") throw new Error(/* ... */);
    order.status = "CANCELLED";  // rewrite through a setter
  }
  totalPrice(order: Order): Money { /* iterate items and sum them */ }
  addItem(order: Order, item: OrderItem): void { /* ... */ }
}
class Order {
  status: string;
  items: OrderItem[];
  // getters / setters only โ† an anemic domain model
}

This is a typical transaction script. The Entity has become nothing more than a data structure. The principle is: if a behavior can be written with Order as the subject, write it in Order.

To summarize

Perspective Put it in Entity Put it in Domain Service
Subject The object itself A verb or business action
State Changes its own state Stateless
Dependencies Does not depend on a Repository May depend on Repositories / external systems
Scope Within a single aggregate Across multiple aggregates
Example order.cancel() transferService.transfer(a, b, money)

Domain Event

A Domain Event is an object that represents "an important thing that happened in the domain." It fits especially well with microservices and asynchronous processing, which is why it has drawn a lot of attention in recent years.

class OrderPlaced {
  constructor(
    public readonly orderId: OrderId,
    public readonly userId: UserId,
    public readonly occurredAt: Date,
  ) {}
}

It lets you separate side effects loosely, for example: "order placed -> send email" or "order placed -> grant points."

Layered Architecture

DDD is often implemented with a layer structure like the following.

flowchart TB
  P["Presentation Layer<br/>UI / API"]
  A["Application Layer<br/>Use Cases"]
  D["Domain Layer<br/>Model / Rules<br/>Main actor"]
  I["Infrastructure Layer<br/>DB / APIs"]

  P --> A --> D
  I --> D

  classDef layer fill:#eef2ff,stroke:#6366f1,color:#312e81
  classDef domain fill:#c7d2fe,stroke:#4338ca,color:#1e1b4b
  class P,A,I layer
  class D domain

Dependencies always point inward. The Domain layer depends on none of the other layers. To enforce this strictly, people often use Onion Architecture or Hexagonal Architecture (Ports and Adapters).

Make the responsibility of each layer explicit

In real development, one of the easiest ways to create a mess is the problem of logic leaking into every layer. It helps to remember each layer's role in one sentence.

Layer Responsibility What it must not do
Presentation Accept requests and present results Write business rules
Application Coordinate use cases Write business rules
Domain Contain the business rules themselves Depend on frameworks or databases
Infrastructure Implement persistence and external communication Make business decisions

The key constraint is strong and simple: business rules belong only in the Domain layer. If you keep that rule, your business logic survives even when the framework changes.

The role of the Presentation layer

It should do nothing more than accept input from users or other systems, pass it to the Application layer, and return the result. The rule is: keep it as thin as possible.

// โœ… Good controller: it just passes things through.
@Controller("orders")
class OrderController {
  constructor(private readonly placeOrder: PlaceOrderUseCase) {}

  @Post()
  async create(@Body() body: PlaceOrderRequest): Promise<OrderResponse> {
    // 1. Shape the request (convert it into a DTO).
    const command = new PlaceOrderCommand(
      body.userId,
      body.items.map(i => ({ productId: i.productId, quantity: i.quantity })),
    );

    // 2. Delegate to the use case.
    const result = await this.placeOrder.execute(command);

    // 3. Shape and return the response.
    return OrderResponse.from(result);
  }
}

What the Presentation layer should do:

  • Parse and validate the request format, such as required fields, types, and string length
  • Perform entry-point authentication and authorization processing
  • Convert results into the response shape
  • Choose HTTP status codes

What it must not do is business judgment such as "return 400 if there is no inventory." That is the Domain layer's job. Presentation merely translates the result produced by the Domain into the language of HTTP.

The role of the Application layer

This is the layer most often misunderstood in DDD. An Application Service is a coordinator for use cases. It does not own business logic.

// โœ… Good Application Service: it only coordinates the flow.
class PlaceOrderUseCase {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly productRepository: ProductRepository,
    private readonly orderRepository: OrderRepository,
    private readonly inventoryService: InventoryService,  // Domain Service
    private readonly transactionManager: TransactionManager,
    private readonly eventPublisher: EventPublisher,
  ) {}

  async execute(command: PlaceOrderCommand): Promise<OrderResult> {
    return this.transactionManager.run(async () => {
      // 1. Load the required aggregates.
      const user = await this.userRepository.findById(command.userId);
      if (!user) throw new UserNotFoundError(command.userId);

      const products = await this.productRepository.findByIds(
        command.items.map(i => i.productId),
      );

      // 2. Delegate the processing to the domain (business rules live in Domain).
      const order = Order.place(user, products, command.items);

      // 3. Call the domain service (decision spanning multiple aggregates).
      await this.inventoryService.reserve(order);

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

      // 5. Publish domain events.
      await this.eventPublisher.publishAll(order.pullEvents());

      return OrderResult.from(order);
    });
  }
}

These are the five responsibilities of an Application Service.

  1. Load aggregates by calling Repositories
  2. Delegate to the domain by letting Entities or Domain Services execute the rules
  3. Manage transaction boundaries
  4. Instruct persistence by saving through Repositories
  5. Publish domain events

By contrast, here is a typical example of code that must not live in an Application Service:

// โŒ Anti-pattern: business rules are leaking into the Application layer.
class PlaceOrderUseCase {
  async execute(command: PlaceOrderCommand) {
    // โŒ Checking stock here leaks domain knowledge.
    if (product.stock < command.quantity) {
      throw new Error("Out of stock");
    }
    // โŒ Calculating the total here should be Order's job.
    const total = command.items.reduce((s, i) => s + i.price * i.quantity, 0);
    // โŒ Deciding on state here should be Order's job.
    if (user.status === "BLOCKED") throw new Error("Account suspended");

    await this.orderRepository.save({ /* ... */ });
  }
}

A good test is: "If I show this code to a business stakeholder, can they see the business rules in it?" If business decisions are being made in if statements here, that is a sign the logic should move into the Domain layer.

The flow of one request across layers

Let us follow the flow of a user clicking the order button during actual development.

sequenceDiagram
  participant Browser
  participant Presentation as Presentation / OrderController
  participant Application as Application / PlaceOrderUseCase
  participant Domain as Domain / Order.place(...)
  participant Infrastructure as Infrastructure / Repository implementation

  Browser->>Presentation: POST /orders
  Presentation->>Application: Pass PlaceOrderCommand
  Note over Application: Start transaction
  Application->>Infrastructure: userRepository.findById()
  Application->>Infrastructure: productRepository.findByIds()
  Infrastructure-->>Application: User / Product aggregates
  Application->>Domain: Order.place(user, products, items)
  activate Domain
  Domain->>Domain: Validate business rules
  Domain->>Domain: Check invariants
  Domain->>Domain: Create OrderPlaced event
  Domain-->>Application: Order aggregate and OrderPlaced
  deactivate Domain
  Application->>Domain: inventoryService.reserve(order)
  Domain-->>Application: Inventory reservation result
  Application->>Infrastructure: orderRepository.save(order)
  Application->>Infrastructure: eventPublisher.publishAll(...)
  Infrastructure-->>Application: Persistence complete
  Note over Application: Commit transaction
  Application-->>Presentation: OrderResult
  Presentation-->>Browser: 201 Created

As this flow shows, business decisions happen only inside the Domain layer. The other layers merely do the preparation and cleanup that allow the Domain to do its job well.

The boundary between DTOs and domain objects

When crossing layers, the standard practice is not to expose domain objects directly to the outside.

// Domain layer
class Order { /* Aggregate with business logic */ }

// Application layer: input / output DTOs
class PlaceOrderCommand { /* Request DTO */ }
class OrderResult { /* Response DTO */ }

// Presentation layer: HTTP-facing types
class PlaceOrderRequest { /* JSON schema */ }
class OrderResponse { /* Response JSON */ }

It may feel tedious, but this buys you a lot:

  • The Domain stays intact even if the API shape changes
  • Serialization concerns no longer pollute the domain
  • Switching to GraphQL or gRPC becomes a localized change

In particular, never add ORM decorators such as @Column or serialization annotations such as @Expose directly to a Domain Object. The moment you do, the Domain starts depending on the framework.

Finally, here is a recommended order of work when implementing a new feature with DDD.

  1. Write the use case in one sentence: "A user orders a product"
  2. Extract the terms that appear: user / product / order / inventory
  3. Decide the aggregates and their boundaries: what belongs inside the Order aggregate
  4. Start from the Domain layer: Entity, Value Object, Domain Service
  5. Write Domain-layer tests (they should run without a database)
  6. Write the Application Service: keep using Repositories as interfaces
  7. Implement the Infrastructure layer: real Repository implementations, ORM configuration
  8. Implement the Presentation layer: controller, request types, response types
  9. E2E tests

The key is to write from the inside out. Starting from database table design runs against the spirit of DDD. The domain model comes first, the tables come after. That principle is not negotiable.

Best Practices for Folder Structure

This is the first question many people run into when organizing a DDD project.

Should we split by layer, or by domain / feature?

The short answer is: the standard approach is domain first, then layers inside that.

โŒ Layer-first (leaning toward an anti-pattern)

src/
โ”œโ”€โ”€ controllers/
โ”‚   โ”œโ”€โ”€ user.controller.ts
โ”‚   โ”œโ”€โ”€ order.controller.ts
โ”‚   โ””โ”€โ”€ product.controller.ts
โ”œโ”€โ”€ services/
โ”‚   โ”œโ”€โ”€ user.service.ts
โ”‚   โ”œโ”€โ”€ order.service.ts
โ”‚   โ””โ”€โ”€ product.service.ts
โ”œโ”€โ”€ repositories/
โ”‚   โ”œโ”€โ”€ user.repository.ts
โ”‚   โ”œโ”€โ”€ order.repository.ts
โ”‚   โ””โ”€โ”€ product.repository.ts
โ””โ”€โ”€ entities/
    โ”œโ”€โ”€ user.entity.ts
    โ”œโ”€โ”€ order.entity.ts
    โ””โ”€โ”€ product.entity.ts

It is easy to understand at first, but as the system grows:

  • Changing one use case means moving across four folders
  • The domain boundaries become invisible because everything looks flat
  • It becomes hard to extract services later if you move toward microservices

The pain grows quickly.

โœ… Domain-first (recommended)

src/
โ”œโ”€โ”€ contexts/                      โ† Organized by Bounded Context
โ”‚   โ”œโ”€โ”€ ordering/                  โ† Ordering context
โ”‚   โ”‚   โ”œโ”€โ”€ domain/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ order.ts           (Entity / Aggregate Root)
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ order-item.ts
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ order-id.ts        (Value Object)
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ order-status.ts
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ order.repository.ts(interface only)
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ events/
โ”‚   โ”‚   โ”‚       โ””โ”€โ”€ order-placed.ts
โ”‚   โ”‚   โ”œโ”€โ”€ application/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ place-order.usecase.ts
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ cancel-order.usecase.ts
โ”‚   โ”‚   โ”œโ”€โ”€ infrastructure/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ postgres-order.repository.ts
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ stripe-payment.gateway.ts
โ”‚   โ”‚   โ””โ”€โ”€ presentation/
โ”‚   โ”‚       โ”œโ”€โ”€ order.controller.ts
โ”‚   โ”‚       โ””โ”€โ”€ dto/
โ”‚   โ”‚           โ”œโ”€โ”€ place-order.request.ts
โ”‚   โ”‚           โ””โ”€โ”€ order.response.ts
โ”‚   โ”œโ”€โ”€ catalog/                   โ† Product catalog context
โ”‚   โ”‚   โ””โ”€โ”€ (same structure)
โ”‚   โ””โ”€โ”€ shipping/                  โ† Shipping context
โ”‚       โ””โ”€โ”€ (same structure)
โ”œโ”€โ”€ shared-kernel/                 โ† Value Objects shared across all contexts
โ”‚   โ”œโ”€โ”€ money.ts
โ”‚   โ”œโ”€โ”€ email.ts
โ”‚   โ””โ”€โ”€ user-id.ts
โ””โ”€โ”€ shared/                        โ† Cross-cutting technical foundation
    โ”œโ”€โ”€ logger.ts
    โ”œโ”€โ”€ transaction-manager.ts
    โ””โ”€โ”€ event-bus.ts

This layout has three major benefits.

  1. Context boundaries become physically visible. A design that stays self-contained within ordering/ arises naturally.
  2. Microservice extraction becomes easier. You can pull out contexts/ordering/ into another repository as-is.
  3. Code review becomes more focused. You can tell at a glance whether a change stays within ordering.

Organizing inside the Domain layer

When domain/ starts getting large, a good approach is to split it by aggregate.

ordering/domain/
โ”œโ”€โ”€ order/
โ”‚   โ”œโ”€โ”€ order.ts                   (aggregate root)
โ”‚   โ”œโ”€โ”€ order-item.ts
โ”‚   โ”œโ”€โ”€ order-id.ts
โ”‚   โ”œโ”€โ”€ order-status.ts
โ”‚   โ”œโ”€โ”€ order.repository.ts
โ”‚   โ””โ”€โ”€ events/
โ”‚       โ”œโ”€โ”€ order-placed.ts
โ”‚       โ””โ”€โ”€ order-cancelled.ts
โ”œโ”€โ”€ customer/
โ”‚   โ”œโ”€โ”€ customer.ts
โ”‚   โ””โ”€โ”€ customer.repository.ts
โ””โ”€โ”€ services/
    โ””โ”€โ”€ pricing.service.ts          (Domain Service)

Once an aggregate is organized as if it owns its own little world, its dependencies also become much easier to follow.

Monorepo structure

When multiple services are managed in a single repository, the structure looks more like this.

monorepo/
โ”œโ”€โ”€ apps/
โ”‚   โ”œโ”€โ”€ web/                       (Next.js frontend)
โ”‚   โ”œโ”€โ”€ api/                       (backend API)
โ”‚   โ””โ”€โ”€ admin/                     (admin UI)
โ”œโ”€โ”€ packages/
โ”‚   โ”œโ”€โ”€ ordering-context/          โ† Bounded Context packaged as a unit
โ”‚   โ”œโ”€โ”€ catalog-context/
โ”‚   โ”œโ”€โ”€ shipping-context/
โ”‚   โ””โ”€โ”€ shared-kernel/
โ””โ”€โ”€ package.json

The key point is that each Bounded Context is an independent npm package. apps/api only imports and uses them, which lets you enforce dependencies between contexts at the package level.

Naming convention hints

It helps to keep filenames and class names consistent. Here are naming rules that tend to work well in practice.

Target Example naming
Entity / Aggregate Root Order (noun, singular)
Value Object Email, Money, OrderId
Repository (interface) OrderRepository (ends with Repository)
Repository (implementation) PostgresOrderRepository (technology name + Repository)
Domain Service PricingService, TransferService
Application Service PlaceOrderUseCase, CancelOrderUseCase
Domain Event OrderPlaced, OrderCancelled (past tense)
Command (DTO) PlaceOrderCommand

In particular, making Domain Events past tense is important. It clearly says, "This is something that happened," not "something we are about to do."

Layering in Systems with a UI

So far the discussion has leaned toward the backend. But what changes when a UI (web frontend) is involved?

If you bring the backend's four layers straight into the frontend...

The conclusion is simple: it is usually better not to force that structure in directly. The frontend has concerns of its own, and forcing the server's layer model onto it makes the design cramped.

[Frontend-specific concerns]
- Routing
- Screen state (loading, error, selected items, etc.)
- Form validation (for UX)
- Animation
- Reactive re-rendering

If you stuff all of those into the Domain layer, the Domain starts depending on the framework, such as React or Vue, which defeats the purpose.

A frontend layer model

A practical split looks like this.

flowchart TB
  R["Pages / Routes Layer<br/>Next.js app/, pages/, etc."]
  C["Components Layer<br/>UI components / visual concerns"]
  V["ViewModel / Hooks Layer<br/>state and use case calls"]
  A["Application Layer<br/>use cases / API clients"]
  D["Domain Layer<br/>minimal frontend domain model"]

  R --> C --> V --> A --> D

  classDef layer fill:#eef2ff,stroke:#6366f1,color:#312e81
  classDef domain fill:#c7d2fe,stroke:#4338ca,color:#1e1b4b
  class R,C,V,A layer
  class D domain

The responsibilities of each layer look like this.

Layer Responsibility Example
Pages / Routes Map URLs to pages /orders/new page
Components Visuals and interactions <OrderForm />, <Button />
ViewModel / Hooks Manage screen state and trigger use cases useOrderForm()
Application Call APIs and shape data placeOrderUseCase()
Domain Minimal domain rules needed on the frontend Order.canCancel()

Example implementation in React + TypeScript

// Domain layer: keep a thin version of the backend's domain rules.
class Order {
  constructor(
    public readonly id: string,
    public readonly status: OrderStatus,
    public readonly items: OrderItem[],
  ) {}

  // Put the "can the Cancel button be pressed?" decision here.
  canCancel(): boolean {
    return this.status === "PENDING" || this.status === "CONFIRMED";
  }

  totalPrice(): number {
    return this.items.reduce((s, i) => s + i.price * i.quantity, 0);
  }
}

// Application layer: a use case that calls the API.
class PlaceOrderUseCase {
  constructor(private readonly api: OrderApiClient) {}

  async execute(items: CartItem[]): Promise<Order> {
    const response = await this.api.placeOrder({ items });
    return Order.fromResponse(response);
  }
}

// ViewModel / Hooks layer: manage screen state.
function useOrderForm() {
  const [items, setItems] = useState<CartItem[]>([]);
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const placeOrder = useMemo(() => new PlaceOrderUseCase(orderApi), []);

  const submit = async () => {
    setIsSubmitting(true);
    try {
      await placeOrder.execute(items);
    } catch (e) {
      setError(formatError(e));
    } finally {
      setIsSubmitting(false);
    }
  };

  return { items, setItems, isSubmitting, error, submit };
}

// Components layer: focus only on presentation.
function OrderForm() {
  const { items, isSubmitting, error, submit } = useOrderForm();
  return (
    <form onSubmit={(e) => { e.preventDefault(); submit(); }}>
      {/* JSX */}
    </form>
  );
}

The key point is that the Component can focus only on presentation. State management lives in the Hook, business rules such as canCancel() live in the Domain, and API calls live in the Application layer.

How much Domain should the frontend have?

That depends on the nature of the project.

Nature of the project Frontend Domain layer
Mostly simple CRUD screens Almost unnecessary. You can use API responses directly
Complex calculations or decisions are needed in the UI Have one. For example, recalculating prices or determining what can be selected
Offline support / mobile Have a solid one (see later)

Another important point is that there is no need to copy the server-side domain model exactly. On the frontend, keep only the rules related to user interaction.

BFF as an option

If "the frontend data shape does not match the backend" or "one screen needs to combine multiple APIs," then placing a BFF in between is often effective.

flowchart TB
  Browser["Browser"]
  BFF["BFF<br/>Next.js API Routes / Hono, etc.<br/>Endpoint optimized for the screen"]
  Ordering["Ordering Service"]
  Catalog["Catalog Service"]
  Inventory["Inventory Service"]
  Response["Response shaped for the screen"]

  Browser --> BFF
  BFF --> Ordering
  BFF --> Catalog
  BFF --> Inventory
  Ordering --> BFF
  Catalog --> BFF
  Inventory --> BFF
  BFF --> Response --> Browser

  classDef layer fill:#eef2ff,stroke:#6366f1,color:#312e81
  classDef service fill:#e0e7ff,stroke:#4f46e5,color:#312e81
  classDef output fill:#ffffff,stroke:#94a3b8,color:#334155
  class Browser,BFF layer
  class Ordering,Catalog,Inventory service
  class Response output

A BFF is a thin layer for the screen only, with no domain of its own. It can fetch across aggregates in one shot or combine results from multiple contexts into a single JSON shaped for the screen. It is also natural to make it function as the query side of CQRS.

Can DDD Be Applied to Mobile Apps?

"Isn't DDD really a server-side thing?"

That is a common impression, but DDD can absolutely be applied to mobile apps as well. In fact, when offline support or synchronization is involved, the benefits of a domain model can be even larger.

Mobile-specific realities

Reality Impact
Offline support A local database is needed -> Repository becomes valuable
Push notifications / background work Pairs well with Domain Events
Device sensors / camera Treat them as Infrastructure
App lifecycle Makes state persistence more complex
Version compatibility Backward compatibility with old devices is required

If all of this is written directly into Activity, Fragment, or ViewController, you can end up with a system that collapses the moment the OS changes.

Example layer structure in native implementations (Swift / Kotlin)

App/
โ”œโ”€โ”€ Domain/
โ”‚   โ”œโ”€โ”€ Entities/
โ”‚   โ”‚   โ”œโ”€โ”€ Recipe.swift
โ”‚   โ”‚   โ””โ”€โ”€ Ingredient.swift
โ”‚   โ”œโ”€โ”€ ValueObjects/
โ”‚   โ”‚   โ””โ”€โ”€ CookingTime.swift
โ”‚   โ”œโ”€โ”€ Repositories/
โ”‚   โ”‚   โ””โ”€โ”€ RecipeRepository.swift   (protocol / interface)
โ”œโ”€โ”€ Application/
โ”‚   โ””โ”€โ”€ UseCases/
โ”‚       โ””โ”€โ”€ SearchRecipeUseCase.swift
โ”œโ”€โ”€ Infrastructure/
โ”‚   โ”œโ”€โ”€ Repositories/
โ”‚   โ”‚   โ”œโ”€โ”€ CoreDataRecipeRepository.swift  (local DB)
โ”‚   โ”‚   โ””โ”€โ”€ ApiRecipeRepository.swift       (remote API)
โ”‚   โ”œโ”€โ”€ Storage/
โ”‚   โ”‚   โ””โ”€โ”€ KeyValueStore.swift
โ”‚   โ””โ”€โ”€ Sync/
โ”‚       โ””โ”€โ”€ RecipeSyncService.swift
โ””โ”€โ”€ Presentation/
    โ”œโ”€โ”€ ViewModels/
    โ”‚   โ””โ”€โ”€ RecipeListViewModel.swift
    โ””โ”€โ”€ Views/
        โ””โ”€โ”€ RecipeListView.swift            (SwiftUI)

In line with the previous sections, the UseCase sits in the Application layer, while the Domain layer stays focused on the model and its rules.

An example where Repository shines for offline support

// Domain layer: abstract multiple persistence targets.
protocol RecipeRepository {
    func find(id: RecipeId) async throws -> Recipe?
    func save(_ recipe: Recipe) async throws
}

// Infrastructure layer: compose local + remote.
class HybridRecipeRepository: RecipeRepository {
    let local: CoreDataRecipeRepository
    let remote: ApiRecipeRepository

    func find(id: RecipeId) async throws -> Recipe? {
        // It can read from local storage even while offline.
        if let cached = try await local.find(id: id) {
            return cached
        }
        let remote = try await remote.find(id: id)
        if let recipe = remote {
          try await local.save(recipe)  // refresh the cache
        }
        return remote
    }
}

Because the offline logic is closed inside the Repository implementation, the ViewModel and Domain can simply say "fetch it." That is the power of DDD.

Applying it to React Native / Flutter

The same way of thinking also works for cross-platform development. In fact, a major advantage is that you can share a common Domain layer between the web app and the mobile app.

shared/
โ””โ”€โ”€ domain/                        โ† Shared by web and mobile
    โ”œโ”€โ”€ recipe.ts
    โ””โ”€โ”€ recipe.repository.ts
apps/
โ”œโ”€โ”€ web/
โ”‚   โ””โ”€โ”€ infrastructure/
โ”‚       โ””โ”€โ”€ api-recipe.repository.ts
โ””โ”€โ”€ mobile/
    โ””โ”€โ”€ infrastructure/
        โ””โ”€โ”€ sqlite-recipe.repository.ts  (offline first)

This structure is possible precisely because the Domain depends on nothing else, which is one of the strongest expressions of what DDD does well.

But do not forget to keep it lightweight

Bringing the full server-side DDD toolkit into a mobile app is too much. In mobile apps:

  • Aggregate boundaries do not need to be as strict as on the server, because the server ultimately guarantees consistency
  • Domain Events usually only need to be local event notifications
  • In many cases, going all the way to CQRS is unnecessary

A realistic balance is to keep a thin client-side version of the domain model built on the server.

CRUD, Admin Screens, and Coexisting with DDD

After reading this far, many people are probably thinking, "Most of our app is settings screens and admin screens. Is that still DDD?" That discomfort is correct.

DDD is for complex domains. If you bring it to places that are not complex, you only make the code longer for no gain.

Evaluate the "weight" of the domain

Even within a single project, complexity differs by feature.

Feature Source of complexity Implementation approach
Order processing allocation, pricing, complex state transitions Invest with DDD
Inventory management reservation, release, consistency maintenance Invest with DDD
Product master data mostly CRUD Lightweight is fine
User settings simple value storage CRUD is enough
Admin log viewer just query and display CRUD is enough

What matters here is that the earlier classification into core, supporting, and generic maps directly to implementation weight.

Domain category Implementation style Examples
Core Domain Full DDD ordering, inventory, pricing strategy
Supporting Subdomain Lightweight DDD or transaction script product master data, delivery settings
Generic Subdomain CRUD or SaaS authentication, email sending, settings screens

How to write the CRUD parts

For lighter areas, it is perfectly fine to write plain transaction scripts.

// โœ… This is enough for updating a settings screen.
class UpdateSiteSettingsHandler {
  constructor(private readonly db: Database) {}

  async execute(input: UpdateSiteSettingsInput): Promise<void> {
    await this.db.siteSettings.update({
      where: { id: 1 },
      data: input,
    });
  }
}

If you force in Entities, Repositories, and Domain Services here, you only increase the amount of code without gaining anything.

Mixing both in the same project

In practice, the realistic answer is to change the implementation style by context.

src/contexts/
โ”œโ”€โ”€ ordering/                โ† Full DDD
โ”‚   โ”œโ”€โ”€ domain/
โ”‚   โ”œโ”€โ”€ application/
โ”‚   โ”œโ”€โ”€ infrastructure/
โ”‚   โ””โ”€โ”€ presentation/
โ”œโ”€โ”€ inventory/               โ† Full DDD
โ”‚   โ””โ”€โ”€ (same as above)
โ”œโ”€โ”€ catalog/                 โ† Lightweight DDD (keep Entities, minimize Services)
โ”‚   โ””โ”€โ”€ (simplified version)
โ””โ”€โ”€ admin/                   โ† CRUD-based
  โ”œโ”€โ”€ handlers/            (thin handlers only)
    โ””โ”€โ”€ controllers/

As long as each context is internally consistent, that is enough. There is no need to impose one style on the entire project.

CQRS is useful for read-only admin APIs

A common admin-screen case is "I want to join data from many aggregates and show a list." That often means reading across aggregates, which is a poor fit for the strict aggregate rules of DDD.

This is where a lightweight use of CQRS is helpful: be strict about writes through aggregates, and allow raw SQL for reads.

// Writes go through aggregates (strict).
class CancelOrderUseCase {
  async execute(orderId: OrderId): Promise<void> {
    const order = await this.orderRepository.findById(orderId);
    order.cancel();                    // domain rule
    await this.orderRepository.save(order);
  }
}

// Read-only queries can use raw SQL (optimized for the screen).
class OrderListQuery {
  async findForAdmin(filter: AdminFilter): Promise<OrderListView[]> {
    return this.db.raw(`
      SELECT o.id, o.status, u.name AS user_name, p.name AS product_name, ...
      FROM orders o
      JOIN users u ON ...
      JOIN order_items oi ON ...
      WHERE ...
    `);
  }
}

Do not fetch aggregates one by one and join them in memory. Use read tools for reads. That is the pragmatic approach.

Principles for mixing CRUD and DDD

Finally, here are the principles for mixing the two.

  1. Draw context boundaries properly. Even in non-DDD areas, keep the concept of boundaries.
  2. Do not skimp on the core domain. If you cut corners there, you will regret it later.
  3. Write CRUD areas plainly. Do not force DDD where it is unnecessary.
  4. Allow a separate route for reads. Use read models or lightweight CQRS.
  5. Promote later if complexity grows. If something that started as CRUD becomes complex, move it to DDD at that point.

DDD is not a religion, it is a tool. Invest in proportion to the complexity of the domain.

Common Pitfalls

1. An anemic domain model

This is the state where an Entity becomes nothing more than a bag of getters and setters, and the logic leaks into Services. Keep this in mind: an object without behavior is not really an object.

2. Applying DDD to everything

Settings screens, admin screens, simple CRUD. If you bring DDD into all of these, development speed will definitely fall. Use it only where there is a complex domain. The earlier section on CRUD, admin screens, and coexisting with DDD covers this as well.

3. Cherry-picking only the tactical patterns

Creating Entities and Repositories does not make something DDD. Without the discussion of ubiquitous language and bounded contexts, tactical patterns will not deliver much value.

Steps to Start DDD

If you want to introduce DDD in practice, I recommend doing it gradually rather than jumping straight into the full package.

  1. Collect the language of the business by building a glossary together with domain experts
  2. Be conscious of boundaries and draw lines around what belongs to the same context
  3. Start with Value Objects and replace primitive types with meaningful types
  4. Identify aggregates carefully and think in terms of transaction boundaries
  5. Use events for loose coupling when needed by introducing Domain Events

Closing

Personally, I think the essence of DDD is facing the complexity of the domain head-on in code.

If memorizing patterns becomes the goal, the code often gets more complex instead of less. Growing the ubiquitous language as a team is both the starting point and, in a sense, the destination of DDD.

Why not start tomorrow's code review with one simple question: "Would this class name also make sense to the people in the business?"

References

  • Eric Evans "Domain-Driven Design: Tackling Complexity in the Heart of Software"
  • Eric Evans "DDD Reference: Definitions and Pattern Summaries" https://www.domainlanguage.com/ddd/reference/
  • Vaughn Vernon "Implementing Domain-Driven Design"
  • Masanobu Naruse "Introduction to Domain-Driven Design"
  • Toru Masuda "Principles of System Design That Work in the Field"
  • Martin Fowler "Patterns of Enterprise Application Architecture"
  • Robert C. Martin "Clean Architecture"
  • Alberto Brandolini "Introducing EventStorming"