top of page

SNIPPETS LTD.

Domain Driven Design

  • Writer: Pavol Megela
    Pavol Megela
  • Sep 22, 2022
  • 7 min read

Updated: Mar 9

As applications grow, managing business logic and maintaining scalability become difficult. Domain-Driven Design (DDD) helps solve this by organizing code around business concepts.


We will look at:

  • Structuring the project using Entities, Aggregates, and Repositories

  • Implementing a Product & Stock aggregate with domain rules

  • Using Domain Events to maintain consistency across aggregates

  • Exposing the logic via NestJS services and controllers


DDD concepts we will talk about:

  • Entities - Objects with an unique identity

  • Aggregates - Groups of related entities with strict business rules. Read more about aggregates in Invariants and Aggregates post

  • Repositories - Abstractions for database access

  • Domain Events - Events triggered when important domain actions occur

  • Orchestrating Aggregates - Multiple aggregates grouped together to orchestrate atomic updates

  • Read Models - Not really a DDD pattern but good to know when learning about DDD principles


Setting Up a DDD Project in NestJS

Before diving into implementation, let’s look at our project. If you want to learn how to structure your NestJS project effectively you can refer to Clean Architecture Application Structure In NestJS Framework.

src/
 ├── modules/
 │   ├── product/         
 │   │   ├── domain/
 │   │   │   ├── product.entity.ts
 │   │   ├── application/
 │   │   │   ├── product.service.ts
 │   │   ├── infrastructure/
 │   │   │   ├── product.repository.ts
 │   │   ├── product.controller.ts
 │   │
 │   ├── stock/
 │   │   ├── domain/
 │   │   │   ├── stock.entity.ts
 │   │   │   ├── events/
 │   │   │   │   ├── stock-updated.event.ts
 │   │   ├── application/
 │   │   │   ├── stock.service.ts
 │   │   ├── infrastructure/
 │   │   │   ├── stock.repository.ts
 │   │   ├── stock.controller.ts

Now, let’s implement a Product and Stock aggregate.


Entity

An entity is an object that has a unique identifier and exists independently. Even if its properties change it will remain the same entity. For example, in an e-commerce system, a Product is an entity because:

  • It has an ID ("product-123"), which uniquely identifies it

  • Its name, price or description can change, but it’s still the same product


Key takeaways for creating entities

  • Always give entities a unique ID usually from the database

  • Business logic should be inside the entity, not in the service layer

  • Use methods like updatePrice() to enforce rules within the entity


Invariants & Aggregate

If you want a deeper dive into aggregates and invariants and understand how they are created and grouped together, check out this article Invariants and Aggregates.


Aggregate is a group of related entities that must be kept consistent according to business rules (invariants). The aggregate root is the main object responsible for enforcing these invariants.


For example, in an Order aggregate, you can’t add or remove items after checkout because that would break the business rule. In a Stock aggregate, inventory should never be below zero, because its impossible to have negative amount of items in stock. Aggregates help enforce these rules without requiring every part of the system to check them manually.


Product

The Product entity is our Aggregate Root (modules/product in the folder structure showed above). It contains business logic and enforces invariants.


Let's create Product and make sure that our business rules are enforced:

  • Product price cannot be negative or equal to 0

// src/modules/product/domain/product.entity.ts

export class Product {
  public constructor(
    public readonly id: string,
    public name: string,
    public price: number,
    public category: string,
    public description: string
  ) {
    this.validatePrice(this.price);
  }

  public updatePrice(newPrice: number): void {
    this.validatePrice(newPrice);

    this.price = newPrice;
  }

  public validatePrice(newPrice: number): void {
    if (newPrice <= 0) {
      throw new Error('Price must be valid.');
    }
  }
}

Here, Product ensures that a valid price is always enforced, in both cases, when a new Product is created and when the price is updated. This is done by calling validatePrice() in both the constructor and the updatePrice() method. If an invalid price (zero or negative) is provided, an error is thrown, preventing the system from entering an invalid state. This way, the business rule is always enforced at the domain level, ensuring consistency across the application.


Stock

Since Stock has a different lifecycle and doesn’t need atomic updates with Product, it should be a separate aggregate. (read about how we got to this decision here Invariants and Aggregates).


Let's look create Stock and make sure that our business rules are enforced:

  • Stock cannot be negative

// src/modules/product/domain/stock.entity.ts

export class Stock {
  public constructor(
    public readonly id: string,
    public readonly productId: string,
    public readonly warehouseId: string,
    private quantity: number
  ) {
    this.validateQuantity(quantity);
  }

  public decreaseStock(amount: number) {
    const newQuantity = this.quantity - amount;

    this.validateQuantity(newQuantity);

    this.quantity = newQuantity;
  }

  public getQuantity() {
    return this.quantity;
  }

  public validateQuantity(newQuantity: number): void {
    if (newQuantity < 0) {
      throw new Error("Stock cannot be negative.");
    }
  }
}

Stock ensures that quantity can never be negative, again in both cases, when a new Stock instance is created and when stock is decreased. The validateQuantity() method is called in the constructor to enforce this rule at creation and again inside decreaseStock() before applying the change. If an invalid quantity is detected, an error is thrown, preventing the system from processing an invalid operation. This guarantees that stock levels are always valid and that no part of the system can reduce stock below zero.


Repositories

A repository is a class that provides methods for retrieving and saving aggregates, without exposing database logic to the domain layer.


Instead of querying the database directly inside your service, you call a repository method like orderRepository.findById(orderId).


Key takeaways for creating repositories

  • Repositories fetch and save entire aggregates, not just entities

  • Keep repositories separate from business logic, they should only handle data persistence

  • Your domain layer (entities) should never interact with the database directly, only through repositories


I did mention Domain layer, if you want to learn about Layers in Clean Architecture, what I really recommend, you can quickly read this short article about them Clean Architecture Layers.


Domain Events

A domain event is a message that is triggered when an important change happens in the system. Instead of directly modifying another aggregate an event is published, and any part of the system that needs to react can listen for that event.


For example, when stock is decreased for a product, the system might need to:

  • Notify the warehouse to reorder stock

  • Update the analytics service

  • Log the change


Instead of Stock calling multiple services directly it emits an event and other parts of the system handle it asynchronously.


Key takeaways for creating domain events:

  • Domain events decouple aggregates, making the system more flexible, scalable and easier to change

  • Events are stored inside the entity first, then published by the application service

  • Handlers react to events asynchronously, so multiple systems can respond without tight coupling


You can see simple example here Handling Domain Events in NestJS Server.


Domain Events Complexity

While domain events are a powerful way to decouple parts of your system you have to keep in mind that they also introduce complexity. Managing event consistency, ensuring proper event handling and dealing with eventual consistency are challenges you’ll need to consider.


Orchestrating Aggregate

In simpler applications where keeping complexity low is a priority you might not want to implement Domain Events. Instead, you can take a more straightforward approach by introducing an Orchestrating Aggregate. This is a single aggregate that encapsulates and manages operations across multiple aggregates.


This approach allows a higher-level aggregate to own and coordinate business rules between related aggregates while maintaining strong consistency. It works well in cases where aggregates frequently interact and need to be updated together in a single transactional operation.


When to use a Orchestrating Aggregate instead of domain events

  • When two aggregates must be updated together in a single transaction

  • When introducing eventual consistency (via domain events) would complicate business logic or increase application complexity more than is necessary

  • When there is a clear business concept that justifies combining the two


Example: Order and stock should be updated together

If we need to ensure Stock is decreased immediately when an Order is placed, we can introduce an OrderProcessing aggregate that owns the logic of both Order and Stock. That means that instead of having Order modify Stock directly or relying on Domain Events, this aggregate would:

  • Import both the Stock and Order modules

  • Access their services directly (OrderService and StockService)

  • Perform operations on both aggregates within a single method call, possibly updated together as a single transaction (atomicity) utilizing Unit-Of-Work Pattern

  • Ensure business rules are applied correctly before persisting changes.


Read Models

While this is not a formal DDD pattern, it aligns well with DDD principles. I want to mention it here so you have an idea that this is possible to do.


Read Model is a read-only model that combines data from multiple aggregates into a single structure optimized for queries. Instead of fetching and joining multiple aggregates at runtime, you:

  • Create a SQL VIEW that merges relevant fields from different tables

  • Precompute and store a model (Materialized View) in a separate read database (Elasticsearch, Redis, a reporting database)

  • Query the View Aggregate instead of querying multiple aggregates separately to improve performance


When to Use View Aggregates:

  • When read performance is more important than write performance

  • When you want to combine data from multiple aggregates without breaking aggregate boundaries

  • When reporting or analytics require pre-joined data for fast queries



Keep reading

For cases where aggregates need to communicate asynchronously, Domain Events are a powerful solution. To see a simple implementation of Domain Events in NestJS, check out this article.


And that's all for now

We explored key Domain-Driven Design (DDD) concepts like Entities, Aggregates, Repositories, and Orchestrating Aggregates. We also discussed different approaches to handling operations across multiple aggregates, including using an Orchestrating Aggregate or Read Models for optimized queries.


By applying these DDD principles correctly, you can build scalable, maintainable, and well-structured applications. Keep on learning, you're doing great!



Commenti


bottom of page