How Bounded Contexts Interact in Domain-Driven Design: Solving the Order and Product Dilemma
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
ProductPriceChangedorInventoryReservedtravel 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
Orderboundary. - 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.