Understanding Bounded Contexts vs. Aggregates

One of the most common stumbling blocks when adopting Domain-Driven Design (DDD) is confusing an Aggregate with a Bounded Context. In your scenario, Order and Product are distinct aggregates, but whether they belong to separate bounded contexts or live within the same one drastically changes how they interact.

A Bounded Context represents an explicit boundary within which a domain model applies and terms have unambiguous meanings. For example, a Product Catalog Context views a product with descriptions, image galleries, and SEO metadata. Conversely, an Ordering Context cares only about SKU, quantity, unit price at purchase, and availability.

Misconception: "Order Cannot Reference ProductId"

The assumption that an Order cannot reference a ProductId is incorrect. In DDD, aggregates should never hold direct object references to other aggregates (e.g., order.products: Product[]), but they can and should reference other aggregates by their identifier (e.g., productId: ProductId).

Direct object references create tight coupling, bloat your persistence queries, and blur aggregate transactional boundaries. Referencing by ID keeps aggregates autonomous and self-contained.

Solution 1: Use Snapshot Value Objects within the Order Aggregate

When an order is created, you are not buying the live Product entity; you are buying a snapshot of what that product was at that exact moment in time. If a product's price or name changes tomorrow, historical orders must remain intact.

Instead of linking directly to the product aggregate, design an OrderItem entity or value object inside the Order aggregate:

// Value Object representing the product identity
export class ProductId {
  constructor(public readonly value: string) {}
}

// Value Object or Entity inside the Order Aggregate
export class OrderItem {
  constructor(
    public readonly productId: ProductId,
    public readonly unitPrice: number,
    public readonly productName: string,
    public readonly quantity: number
  ) {
    if (quantity <= 0) throw new Error("Quantity must be greater than zero");
  }
}

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

  constructor(public readonly id: string) {}

  public addItem(productId: ProductId, productName: string, unitPrice: number, quantity: number): void {
    // Invariant: Order cannot contain more than 4 items
    if (this.items.length >= 4) {
      throw new Error("An order cannot contain more than 4 items.");
    }

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

  public getItems(): readonly OrderItem[] {
    return [...this.items];
  }
}

With this design, checking business rules such as "an order cannot have more than 4 products" becomes trivial because that invariant belongs strictly inside the Order aggregate.

Solution 2: Cross-Context Interaction via the Application Layer

If Product and Order truly live in separate Bounded Contexts (for instance, a Catalog Context and a Sales Context), they must communicate through explicit integration mechanisms without sharing database tables or domain entities.

  • Anti-Corruption Layer (ACL): The Application Service in the Ordering context calls an external interface (e.g., IProductCatalogService). An adapter translates foreign Catalog DTOs into Ordering domain concepts.
  • Domain Events / Asynchronous Messaging: When products are updated or verified, events like ProductPriceChanged or InventoryReserved travel across message brokers to maintain eventual consistency.

Example Application Service Flow:

export class CreateOrderUseCase {
  constructor(
    private orderRepo: OrderRepository,
    private catalogGateway: ProductCatalogGateway // ACL client
  ) {}

  async execute(dto: CreateOrderDto): Promise<void> {
    const order = new Order(generateId());

    for (const item of dto.items) {
      // Retrieve external snapshot data via ACL
      const productSnapshot = await this.catalogGateway.getProduct(item.productId);
      
      order.addItem(
        new ProductId(productSnapshot.id),
        productSnapshot.name,
        productSnapshot.currentPrice,
        item.quantity
      );
    }

    await this.orderRepo.save(order);
  }
}

Key Takeaways

  • Reference by ID, Not Object: Aggregates should hold references to external aggregates only via scalar IDs (like ProductId).
  • Capture Temporal Snapshots: Line items in an order are distinct historical records, not references to mutable product entities.
  • Enforce Invariants Internally: Rules governing collections of items (e.g., maximum item limits) belong entirely within the Order boundary.
  • Decouple Persistence from Domain: Your relational tables (orders, order_items) are an infrastructure detail. Do not allow SQL foreign keys to force relational coupling into your domain aggregates.