Domain-Driven Design: Structuring Large Codebases

Architecture By TryzTech Team
Domain-Driven DesignDDDSoftware ArchitectureSoftware EngineeringClean Code

Table of Contents

What Is Domain-Driven Design?

Domain-Driven Design (DDD) is an approach to software design that puts the business domain at the center of technical decisions. It is not only about splitting folders into controllers, services, and repositories. It is about understanding business rules, the language users actually use, and the boundaries between different parts of the system.

In a small codebase, a simple structure is often enough. As a product grows, business logic starts spreading everywhere: validation in controllers, pricing rules in services, status changes in models, and inconsistent terminology across teams. DDD helps bring the code structure back toward clearer business language and boundaries.

DDD does not replace clean code or SOLID principles. It works best when those basics are already reasonably healthy: names are clear, dependencies are controlled, and module responsibilities do not leak everywhere.

The Problems DDD Helps Solve

DDD is useful when the hardest part of the application is no longer the framework, database, or deployment pipeline, but the business rules.

Common signs that a codebase may benefit from DDD:

  • business terms differ between product, support, and engineering
  • a small feature change touches too many files
  • important business rules are scattered across layers
  • the database model dictates too much of the code design
  • the team cannot clearly explain who owns each part of the system
  • business rule changes frequently create regressions

If the application is still simple CRUD, full DDD may be too heavy. But some ideas, especially ubiquitous language and bounded contexts, can still help without changing the entire architecture.

DDD also helps with technical debt management because it gives teams a way to map code areas by business domain, not only by technical folders. When domain boundaries are clearer, refactoring priorities and ownership become easier to discuss.

Ubiquitous Language

The most important concept in DDD is ubiquitous language: a shared language used by domain experts, product, design, support, and engineering.

For example, in an e-commerce system, terms like “cart”, “order”, “invoice”, “shipment”, and “refund” should not be used casually. If product uses “order” to mean a paid transaction, but engineering uses “order” for an unpaid checkout draft, communication bugs are almost guaranteed.

Ubiquitous language should appear in:

  • class and function names
  • event names
  • command names
  • API documentation
  • test cases
  • team conversations

Poor naming:

function processData(input) {
  // ...
}

Better naming:

function approveRefundRequest(request: RefundRequest) {
  // ...
}

The second name carries business context immediately. This matches the naming habits described in clean code: names are not decoration, they are communication tools.

Important DDD Building Blocks

DDD has many concepts, but these building blocks are the ones most often used in application codebases.

Entity

An entity is an object whose identity matters and whose state may change over time.

Example:

class Customer {
  constructor(
    public readonly id: CustomerId,
    public email: EmailAddress,
    public status: CustomerStatus
  ) {}

  suspend(reason: string) {
    if (this.status === 'closed') {
      throw new Error('Closed customer cannot be suspended');
    }

    this.status = 'suspended';
  }
}

The Customer remains the same Customer even if the email or status changes, because identity comes from CustomerId.

Value Object

A value object is defined by its value, not by an ID.

Example:

class Money {
  constructor(
    public readonly amount: number,
    public readonly currency: string
  ) {
    if (amount < 0) {
      throw new Error('Amount cannot be negative');
    }
  }
}

Money(100000, 'IDR') equals another Money(100000, 'IDR') because the values are the same. Value objects are useful for small rules that otherwise get duplicated across the codebase.

Aggregate

An aggregate is a group of entities and value objects treated as one consistency boundary.

For example, Order can be an aggregate root that controls OrderItem, total price, payment status, and cancellation rules. External code should not mutate OrderItem directly without going through Order.

class Order {
  private items: OrderItem[] = [];

  addItem(productId: ProductId, quantity: number, price: Money) {
    if (this.status !== 'draft') {
      throw new Error('Only draft orders can be changed');
    }

    this.items.push(new OrderItem(productId, quantity, price));
  }
}

Aggregates keep business rules in a focused place. They also make reviews easier because reviewers can focus on domain boundaries, not only syntax. For healthier review habits, see code review feedback.

Repository

A repository is an abstraction for loading and saving aggregates.

interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

A repository is not a place for every query in the system. It should represent domain needs. For complex read queries, patterns like CQRS may help keep write models and read models from forcing each other into awkward shapes.

Domain Service

A domain service is used when a business rule does not naturally belong to one entity or value object.

For example, checking whether a customer is eligible for a promotion may require Customer, Order History, and Campaign Rule together. If that logic is forced into one entity, its responsibility may grow too wide.

Use domain services for domain rules, not for every application operation. If everything is named SomethingService, the domain model is probably not clear enough yet.

Bounded Contexts

A bounded context is the boundary where a model and its language apply.

The same term can mean different things in different parts of the system. For example:

  • In Sales, “Customer” may mean a prospect or buyer.
  • In Billing, “Customer” may mean the party with invoices and payment methods.
  • In Support, “Customer” may mean the person who owns a support ticket.

Forcing one Customer model to serve every context often creates a bloated class. With bounded contexts, each area can have its own model as long as integration between contexts is explicit.

Bounded contexts also matter when a system moves toward microservices architecture. Good microservices usually come from clear business boundaries, not from splitting services by database tables.

When DDD Is a Good Fit

DDD is a good fit when:

  • the business domain is complex and keeps changing
  • the team often misunderstands business terminology
  • important rules are scattered across application layers
  • the product has several distinct business areas
  • the codebase is hard to understand through technical structure alone
  • domain bugs are expensive

DDD may be too heavy when:

  • the application is simple CRUD
  • a small team is still validating an MVP
  • the domain is not stable yet
  • the main problem is performance, not domain design
  • there are not enough tests or review practices to support design changes

You do not need to apply DDD in an extreme way from day one. Start with domain language, value objects for small rules, and clearer folder boundaries. Then consider aggregates, repositories, and more formal bounded contexts where they provide real value.

A Simple Folder Structure Example

Here is a simple structure for an order module:

src/
  modules/
    order/
      domain/
        Order.ts
        OrderItem.ts
        Money.ts
        OrderRepository.ts
        OrderPolicy.ts
      application/
        CreateOrder.ts
        CancelOrder.ts
        PayOrder.ts
      infrastructure/
        PostgresOrderRepository.ts
        OrderHttpController.ts
      tests/
        Order.test.ts
        CreateOrder.test.ts

Short explanation:

  • domain/ contains the core business rules.
  • application/ contains use cases and application workflows.
  • infrastructure/ contains framework, database, HTTP, queue, or vendor details.
  • tests/ protects domain and use case behavior as the system changes.

This structure is not a universal rule. The important part is that dependencies point toward the domain, not away from it. The domain should not depend on a specific web framework or ORM.

Common Mistakes When Applying DDD

1. Creating abstractions too early

Many teams immediately create domain, application, and infrastructure folders for every feature, even simple ones. The result is not clean design, but extra ceremony.

Start with areas that have real business complexity.

2. Treating DDD as a folder structure

DDD is not just folders. If class names are still generic, business rules remain scattered, and product and engineering use different terms, neat folders will not help much.

3. Turning repositories into query dumps

A repository that contains every possible query becomes a new god object. Separate domain needs from reporting or read model needs.

4. Forgetting tests

DDD makes business rules explicit. Without tests, the benefit is limited because the team still feels afraid to change domain logic.

5. Ignoring communication

DDD depends heavily on conversation. Engineers need to ask domain experts better questions, not merely translate tickets into code.

FAQ

Is DDD required for every project?

No. DDD is most useful for applications with complex business domains. For simple CRUD, some concepts can be used lightly without adopting everything.

Does DDD require microservices?

No. DDD works in a monolith. In fact, many teams should start with a modular monolith before splitting the system into microservices.

How does DDD relate to Clean Architecture?

DDD focuses on modeling the business domain. Clean Architecture focuses on dependency direction and layer separation. They can complement each other, but they are not the same thing.

Is a DDD entity the same as a database table?

Not always. A DDD entity represents a domain concept with identity. A database table is a storage detail. Sometimes they look similar, sometimes they differ.

Where should a team start with DDD?

Start with one area that changes often or causes frequent bugs. Clarify terminology, write domain rules explicitly in code, add tests, and improve boundaries gradually.

Conclusion

Domain-Driven Design helps teams build codebases shaped by the business, not only by the database or framework. With ubiquitous language, bounded contexts, entities, value objects, aggregates, repositories, and domain services, teams get practical tools for managing complexity as a product grows.

But DDD is not magic. It works best when applied pragmatically: start with truly complex areas, keep language consistent, and combine it with engineering habits like clean code, SOLID, testing, and healthy code review.

If you have applied DDD in a large codebase, or found it too heavy for your team, share your experience in the comments. Real project context is usually more useful than perfectly tidy theory.

Keep reading within the same topic.

Don't Miss Out

Get the latest tech articles, tips, and insights delivered to your inbox.