Skip to main content

2 - Domain-Driven Design for Web Applications

DDD applied to the web world: Bounded Contexts, Aggregates, Value Objects, Entities and Domain Events with TypeScript and Angular.

What do you think about this article?

At a Glance

DDD applied to the web world: Bounded Contexts, Aggregates, Value Objects, Entities and Domain Events with TypeScript and Angular.

Domain-Driven Design for Web Applications

Domain-Driven Design (DDD), introduced by Eric Evans in 2003, is a software design approach that places the business domain at the center of every architectural decision. In a web ecosystem dominated by frameworks and libraries, DDD provides a compass for building applications whose structure faithfully mirrors the language and processes of the business they serve.

In this article, we will explore how to apply DDD in a web context with TypeScript, covering both the strategic level (Bounded Contexts, Context Maps) and the tactical level (Entities, Value Objects, Aggregates, Domain Events). We will use an e-commerce domain as a concrete example to illustrate each concept.

What You Will Learn

  • Strategic DDD: Bounded Contexts, Context Maps, Ubiquitous Language
  • Tactical DDD: Entities, Value Objects, Aggregates, Repositories
  • Domain Events and inter-context communication
  • Practical implementation in TypeScript
  • Structuring an Angular/Node.js project with DDD
  • Collaboration with domain experts

Strategic DDD

Strategic DDD addresses the big picture: how to divide a complex system into manageable parts and how those parts communicate with each other. This is the level that has the greatest impact on the overall system architecture.

Ubiquitous Language

The Ubiquitous Language is the shared vocabulary between developers and domain experts. Every term has a precise, unambiguous meaning within a given context. The code must reflect this language: class names, method names, and variable names must correspond to the terms used by the business.

Example: E-commerce

In the Catalog context, a "Product" has a name, description, category, and images. In the Inventory context, the same physical concept becomes a "Stock Item" with quantity on hand, reorder threshold, and warehouse assignment. Using the same term for different concepts creates confusion; the Ubiquitous Language resolves this ambiguity by defining a specific vocabulary for each context.

Bounded Contexts

A Bounded Context is an explicit boundary within which a domain model has a precise meaning. Each Bounded Context has its own Ubiquitous Language, its own entities, and potentially its own database and development team.

Bounded Contexts of an E-commerce System

+------------------+     +------------------+     +------------------+
|    CATALOG       |     |     ORDERS       |     |    SHIPPING      |
|                  |     |                  |     |                  |
|  - Product       |     |  - Order         |     |  - Shipment      |
|  - Category      |     |  - OrderLine     |     |  - Package       |
|  - Price         |     |  - Customer      |     |  - Carrier       |
|  - Review        |     |  - Payment       |     |  - Tracking      |
|                  |     |                  |     |                  |
+--------+---------+     +--------+---------+     +--------+---------+
         |                         |                         |
         +-------------------------+-------------------------+
                                   |
                          Context Map (relationships)

Context Map

The Context Map describes the relationships between Bounded Contexts. It defines how contexts communicate, who depends on whom, and which integration patterns are used.

Relationship Description Example
Shared Kernel Two contexts share a subset of the model Catalog and Orders share ProductId
Customer-Supplier One context provides data, the other consumes it Catalog (supplier) provides product info to Orders (customer)
Anti-Corruption Layer A translation layer protects a context from external models Integration with external payment gateway
Published Language A standard format for communication between contexts Domain Events serialized as JSON with a defined schema
Conformist One context adopts another's model without translation Reporting context directly using the orders model

Tactical DDD

Tactical DDD provides the building blocks for implementing the domain model within a Bounded Context. These patterns define how to structure entities, values, aggregates, and services.

Entities

An Entity is an object defined by its identity, not by its attributes. Two entities with the same data but different identities are distinct objects. The identity persists over time even as attributes change.

Entity - Order

// domain/entities/order.entity.ts

export class OrderId {
  constructor(public readonly value: string) {
    if (!value || value.trim().length === 0) {
      throw new Error('OrderId cannot be empty');
    }
  }

  equals(other: OrderId): boolean {
    return this.value === other.value;
  }

  toString(): string {
    return this.value;
  }
}

export class Order {
  private _items: OrderLine[] = [];
  private _domainEvents: DomainEvent[] = [];

  constructor(
    public readonly id: OrderId,
    public readonly customerId: CustomerId,
    private _status: OrderStatus = OrderStatus.CREATED,
    private _createdAt: Date = new Date()
  ) {}

  get status(): OrderStatus {
    return this._status;
  }

  get items(): ReadonlyArray<OrderLine> {
    return [...this._items];
  }

  get totalAmount(): Money {
    return this._items.reduce(
      (total, line) => total.add(line.subtotal),
      Money.zero('EUR')
    );
  }

  addLine(productId: ProductId, quantity: Quantity, unitPrice: Money): void {
    if (this._status !== OrderStatus.CREATED) {
      throw new DomainError('Cannot add lines to a confirmed order');
    }

    const existingLine = this._items.find(
      line => line.productId.equals(productId)
    );
    if (existingLine) {
      throw new DomainError('Product already exists in the order');
    }

    this._items.push(new OrderLine(productId, quantity, unitPrice));
  }

  confirm(): void {
    if (this._items.length === 0) {
      throw new DomainError('Cannot confirm an empty order');
    }
    if (this._status !== OrderStatus.CREATED) {
      throw new DomainError('Order has already been confirmed');
    }

    this._status = OrderStatus.CONFIRMED;
    this._domainEvents.push(
      new OrderConfirmedEvent(this.id, this.customerId, this.totalAmount)
    );
  }

  pullDomainEvents(): DomainEvent[] {
    const events = [...this._domainEvents];
    this._domainEvents = [];
    return events;
  }
}

Value Objects

Value Objects are immutable objects defined entirely by their value. They have no identity of their own: two Value Objects with the same attributes are identical. They encapsulate validation rules and behavior tied to the value they represent.

Value Objects - Money and Quantity

// domain/value-objects/money.ts

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

  static of(amount: number, currency: string): Money {
    return new Money(Math.round(amount * 100) / 100, currency);
  }

  static zero(currency: string): Money {
    return new Money(0, currency);
  }

  add(other: Money): Money {
    this.ensureSameCurrency(other);
    return Money.of(this.amount + other.amount, this.currency);
  }

  multiply(factor: number): Money {
    return Money.of(this.amount * factor, this.currency);
  }

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

  private ensureSameCurrency(other: Money): void {
    if (this.currency !== other.currency) {
      throw new DomainError(
        `Currency mismatch: ${this.currency} vs ${other.currency}`
      );
    }
  }

  toString(): string {
    return `${this.amount.toFixed(2)} ${this.currency}`;
  }
}

// domain/value-objects/quantity.ts

export class Quantity {
  constructor(public readonly value: number) {
    if (!Number.isInteger(value) || value <= 0) {
      throw new DomainError('Quantity must be a positive integer');
    }
  }

  add(other: Quantity): Quantity {
    return new Quantity(this.value + other.value);
  }

  equals(other: Quantity): boolean {
    return this.value === other.value;
  }
}

Aggregates

An Aggregate is a cluster of entities and value objects treated as a single unit for data modification operations. Each aggregate has an Aggregate Root: the only entity through which the outside world can interact with the aggregate. Business rules involving multiple objects within the aggregate are enforced by the root.

Aggregate Rules

  • External access always goes through the Aggregate Root
  • Business invariants are maintained within the aggregate boundary
  • A persistence operation saves the entire aggregate atomically
  • References between aggregates use identity (ID), not direct references
  • Aggregates should be as small as possible
Aggregate Root - Cart

// domain/aggregates/cart.aggregate.ts

export class Cart {
  private _items: Map<string, CartItem> = new Map();
  private _domainEvents: DomainEvent[] = [];

  constructor(
    public readonly id: CartId,
    public readonly customerId: CustomerId
  ) {}

  get items(): CartItem[] {
    return Array.from(this._items.values());
  }

  get itemCount(): number {
    return this._items.size;
  }

  get totalAmount(): Money {
    return this.items.reduce(
      (total, item) => total.add(item.subtotal),
      Money.zero('EUR')
    );
  }

  addProduct(productId: ProductId, quantity: Quantity, unitPrice: Money): void {
    const key = productId.value;

    if (this._items.has(key)) {
      const existing = this._items.get(key)!;
      this._items.set(key, existing.increaseQuantity(quantity));
    } else {
      this._items.set(key, new CartItem(productId, quantity, unitPrice));
    }

    this._domainEvents.push(
      new ProductAddedToCartEvent(this.id, productId, quantity)
    );
  }

  removeProduct(productId: ProductId): void {
    const key = productId.value;
    if (!this._items.has(key)) {
      throw new DomainError('Product not found in cart');
    }
    this._items.delete(key);

    this._domainEvents.push(
      new ProductRemovedFromCartEvent(this.id, productId)
    );
  }

  checkout(): Order {
    if (this._items.size === 0) {
      throw new DomainError('Cart is empty');
    }

    const orderId = new OrderId(crypto.randomUUID());
    const order = new Order(orderId, this.customerId);

    for (const item of this.items) {
      order.addLine(item.productId, item.quantity, item.unitPrice);
    }

    this._items.clear();
    this._domainEvents.push(new CartCheckedOutEvent(this.id, orderId));

    return order;
  }

  pullDomainEvents(): DomainEvent[] {
    const events = [...this._domainEvents];
    this._domainEvents = [];
    return events;
  }
}

Domain Events

Domain Events represent significant facts that have occurred in the domain. They are immutable, timestamped, and carry the information needed to communicate the change to other contexts or system components.

Domain Events

// domain/events/domain-event.ts

export interface DomainEvent {
  readonly eventType: string;
  readonly occurredOn: Date;
  readonly aggregateId: string;
}

// domain/events/order-confirmed.event.ts

export class OrderConfirmedEvent implements DomainEvent {
  readonly eventType = 'OrderConfirmed';
  readonly occurredOn = new Date();

  constructor(
    public readonly aggregateId: string,
    public readonly orderId: OrderId,
    public readonly customerId: CustomerId,
    public readonly totalAmount: Money
  ) {
    this.aggregateId = orderId.value;
  }
}

// domain/events/product-added-to-cart.event.ts

export class ProductAddedToCartEvent implements DomainEvent {
  readonly eventType = 'ProductAddedToCart';
  readonly occurredOn = new Date();

  constructor(
    public readonly cartId: CartId,
    public readonly productId: ProductId,
    public readonly quantity: Quantity
  ) {}

  get aggregateId(): string {
    return this.cartId.value;
  }
}

Repository

The Repository provides the abstraction for aggregate persistence. It always operates on the entire aggregate, not on individual internal entities.

Repository - Interface and Implementation

// domain/repositories/order.repository.ts

export interface OrderRepository {
  nextId(): OrderId;
  findById(id: OrderId): Promise<Order | null>;
  findByCustomer(customerId: CustomerId): Promise<Order[]>;
  save(order: Order): Promise<void>;
}

// infrastructure/repositories/order-api.repository.ts

export class OrderApiRepository implements OrderRepository {
  constructor(private readonly http: HttpClient) {}

  nextId(): OrderId {
    return new OrderId(crypto.randomUUID());
  }

  async findById(id: OrderId): Promise<Order | null> {
    const response = await firstValueFrom(
      this.http.get<OrderApiDto>(`/api/orders/${id.value}`)
    );
    return response ? OrderApiMapper.toDomain(response) : null;
  }

  async findByCustomer(customerId: CustomerId): Promise<Order[]> {
    const response = await firstValueFrom(
      this.http.get<OrderApiDto[]>(
        `/api/orders?customer=${customerId.value}`
      )
    );
    return response.map(dto => OrderApiMapper.toDomain(dto));
  }

  async save(order: Order): Promise<void> {
    const dto = OrderApiMapper.toApi(order);
    await firstValueFrom(
      this.http.put(`/api/orders/${order.id.value}`, dto)
    );
  }
}

Domain Services

Domain Services contain business logic that does not naturally belong to any entity or value object. They often coordinate operations that involve multiple aggregates.

Domain Service - Pricing

// domain/services/pricing.service.ts

export class PricingService {
  calculateDiscount(
    order: Order,
    customerTier: CustomerTier
  ): Money {
    const total = order.totalAmount;

    if (customerTier === CustomerTier.PREMIUM && total.amount > 100) {
      return total.multiply(0.15);  // 15% discount for premium customers
    }

    if (total.amount > 200) {
      return total.multiply(0.10);  // 10% for orders above 200 EUR
    }

    return Money.zero(total.currency);
  }

  applyTax(amount: Money, taxRate: number): Money {
    return amount.multiply(1 + taxRate);
  }
}

Project Structure with DDD

The folder structure reflects the Bounded Contexts and tactical building blocks. Each context is autonomous and contains everything it needs to function.

Folder Structure - DDD Project

src/
  bounded-contexts/
    catalog/
      domain/
        entities/
          product.entity.ts
        value-objects/
          product-name.ts
          price.ts
        repositories/
          product.repository.ts
        events/
          product-created.event.ts
      application/
        commands/
          create-product.command.ts
        queries/
          get-product.query.ts
      infrastructure/
        repositories/
          product-api.repository.ts
        mappers/
          product-api.mapper.ts

    orders/
      domain/
        entities/
          order.entity.ts
          order-line.entity.ts
        value-objects/
          money.ts
          quantity.ts
          order-id.ts
        aggregates/
          order.aggregate.ts
        repositories/
          order.repository.ts
        services/
          pricing.service.ts
        events/
          order-confirmed.event.ts
      application/
        commands/
          create-order.command.ts
          confirm-order.command.ts
        queries/
          get-order-by-id.query.ts
      infrastructure/
        repositories/
          order-api.repository.ts
        event-handlers/
          send-confirmation-email.handler.ts

    shipping/
      domain/
        ...
      application/
        ...
      infrastructure/
        ...

  shared-kernel/
    domain/
      value-objects/
        email-address.ts
        entity-id.ts
    infrastructure/
      event-bus/
        event-bus.ts

Anti-Corruption Layer

When integrating with external systems (third-party APIs, legacy systems), the Anti-Corruption Layer (ACL) translates external models into our domain's language, protecting the internal model from contamination.

Anti-Corruption Layer - Payment Gateway

// infrastructure/acl/payment-gateway.adapter.ts

// External payment gateway model
interface StripePaymentResponse {
  id: string;
  amount: number;          // in cents
  currency: string;
  status: 'succeeded' | 'pending' | 'failed';
  created: number;         // Unix timestamp
}

// Our domain model
export class PaymentResult {
  constructor(
    public readonly transactionId: string,
    public readonly amount: Money,
    public readonly status: PaymentStatus,
    public readonly processedAt: Date
  ) {}
}

// Anti-Corruption Layer: translates between the two models
export class PaymentGatewayAdapter {
  constructor(private readonly stripeClient: StripeClient) {}

  async processPayment(
    orderId: OrderId,
    amount: Money
  ): Promise<PaymentResult> {
    // Translate from our model to Stripe's
    const stripeResponse = await this.stripeClient.charge({
      amount: Math.round(amount.amount * 100),  // Stripe uses cents
      currency: amount.currency.toLowerCase(),
      metadata: { order_id: orderId.value },
    });

    // Translate from Stripe's response to our domain
    return this.toDomain(stripeResponse);
  }

  private toDomain(response: StripePaymentResponse): PaymentResult {
    return new PaymentResult(
      response.id,
      Money.of(response.amount / 100, response.currency.toUpperCase()),
      this.mapStatus(response.status),
      new Date(response.created * 1000)
    );
  }

  private mapStatus(stripeStatus: string): PaymentStatus {
    const statusMap: Record<string, PaymentStatus> = {
      'succeeded': PaymentStatus.COMPLETED,
      'pending': PaymentStatus.PENDING,
      'failed': PaymentStatus.FAILED,
    };
    return statusMap[stripeStatus] ?? PaymentStatus.UNKNOWN;
  }
}

Communication Between Bounded Contexts

Bounded Contexts communicate through Domain Events and an Event Bus. This asynchronous communication keeps contexts decoupled and allows each one to evolve independently.

Event Bus for Inter-Context Communication

// shared-kernel/infrastructure/event-bus.ts

type EventHandler<T extends DomainEvent> = (event: T) => Promise<void>;

export class InMemoryEventBus {
  private handlers = new Map<string, EventHandler<any>[]>();

  subscribe<T extends DomainEvent>(
    eventType: string,
    handler: EventHandler<T>
  ): void {
    const existing = this.handlers.get(eventType) || [];
    existing.push(handler);
    this.handlers.set(eventType, existing);
  }

  async publish(event: DomainEvent): Promise<void> {
    const handlers = this.handlers.get(event.eventType) || [];
    await Promise.all(
      handlers.map(handler => handler(event))
    );
  }

  async publishAll(events: DomainEvent[]): Promise<void> {
    for (const event of events) {
      await this.publish(event);
    }
  }
}

// Example: when an order is confirmed, the Shipping context reacts
// shipping/application/event-handlers/on-order-confirmed.handler.ts

export class OnOrderConfirmedHandler {
  constructor(private readonly shippingService: ShippingService) {}

  async handle(event: OrderConfirmedEvent): Promise<void> {
    await this.shippingService.createShipment(
      event.orderId,
      event.customerId
    );
  }
}

Common Mistakes in DDD Adoption

Mistake Consequence Remedy
Starting with tactical without strategic Poorly defined Bounded Contexts, confused model Always start with domain analysis alongside experts
Aggregates that are too large Concurrency conflicts, poor performance Follow the rule: keep aggregates as small as possible
Neglecting the Ubiquitous Language Misalignment between code and business, difficult communication Shared glossary, code reviews focused on naming
DDD everywhere, even for simple CRUD Over-engineering, unnecessary complexity Apply DDD only to contexts with complex logic
Direct references between aggregates Coupling, inability to scale Always use identity (ID) for references between aggregates

When to Use DDD

Decision Guide

Scenario Strategic DDD Tactical DDD
Simple domain, few business rules Useful for communication Not necessary
Moderate domain, significant logic Recommended Selective (only complex contexts)
Complex domain, many interdependent rules Essential Essential
Legacy system to modernize Essential for partitioning Progressive, context by context

Conclusion

Domain-Driven Design is neither a framework nor a library: it is a way of thinking about software starting from the business problem rather than the technology. Strategic DDD helps us define clear boundaries and relationships between parts of the system. Tactical DDD provides concrete tools for modeling domain logic in an expressive and maintainable way.

Applied to TypeScript and Angular, DDD brings clarity to the project structure, improves communication between the technical team and stakeholders, and produces code that faithfully reflects business processes. Like any architectural approach, it requires an upfront investment in analysis and design, but the benefits in terms of maintainability and system evolution are significant for medium- and high-complexity projects.

Frequently asked questions

What is Domain-Driven Design, and how does it approach software design?

Domain-Driven Design (DDD) is a software design approach that places the business domain at the center of every architectural decision. It provides a compass for building applications whose structure faithfully mirrors the language and processes of the business they serve.

What is the purpose of the Ubiquitous Language in DDD?

The Ubiquitous Language is the shared vocabulary between developers and domain experts, ensuring that every term has a precise, unambiguous meaning within a given context. This language must be reflected in the code to maintain consistency.

How do Bounded Contexts relate to each other in a system using DDD?

Bounded Contexts are explicit boundaries within which a domain model has a precise meaning. They have their own Ubiquitous Language, entities, and potentially their own database and development team. The relationships between these contexts are described by the Context Map.

What is the role of Domain Events in DDD?

Domain Events represent significant facts that have occurred in the domain. They are immutable, timestamped, and carry the information needed to communicate the change to other contexts or system components, enabling asynchronous communication between Bounded Contexts.

Sources and further reading

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects