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 noteThe 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
Orderexample 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 domainDependencies 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.
- Load aggregates by calling Repositories
- Delegate to the domain by letting Entities or Domain Services execute the rules
- Manage transaction boundaries
- Instruct persistence by saving through Repositories
- 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 CreatedAs 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.
Recommended implementation order
Finally, here is a recommended order of work when implementing a new feature with DDD.
- Write the use case in one sentence: "A user orders a product"
- Extract the terms that appear: user / product / order / inventory
- Decide the aggregates and their boundaries: what belongs inside the
Orderaggregate - Start from the Domain layer: Entity, Value Object, Domain Service
- Write Domain-layer tests (they should run without a database)
- Write the Application Service: keep using Repositories as interfaces
- Implement the Infrastructure layer: real Repository implementations, ORM configuration
- Implement the Presentation layer: controller, request types, response types
- 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.
- Context boundaries become physically visible. A design that stays self-contained within
ordering/arises naturally. - Microservice extraction becomes easier. You can pull out
contexts/ordering/into another repository as-is. - 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 domainThe 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 outputA 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.
- Draw context boundaries properly. Even in non-DDD areas, keep the concept of boundaries.
- Do not skimp on the core domain. If you cut corners there, you will regret it later.
- Write CRUD areas plainly. Do not force DDD where it is unnecessary.
- Allow a separate route for reads. Use read models or lightweight CQRS.
- 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.
- Collect the language of the business by building a glossary together with domain experts
- Be conscious of boundaries and draw lines around what belongs to the same context
- Start with Value Objects and replace primitive types with meaningful types
- Identify aggregates carefully and think in terms of transaction boundaries
- 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"
