Domain-Driven Design in Practice: Lessons from Scaling a B2B Platform
When our B2B platform at Tajir hit 30,000 daily active users, the codebase started showing cracks. Features that should have taken days were taking weeks. A change in the order module would mysteriously break inventory sync. The classic signs of a monolith in distress.
We decided to restructure around Domain-Driven Design — not because it was trendy, but because our domain was genuinely complex. Distribution logistics across Pakistan, with thousands of retailers, multiple warehouses, and a fleet of delivery vehicles, demanded clear boundaries.
Bounded Contexts: Drawing the Lines
The first and most impactful decision was identifying our bounded contexts. We landed on four:
- Catalog — product definitions, pricing tiers, regional availability
- Orders — cart, checkout, payment status
- Fulfillment — warehouse picking, route assignment, delivery tracking
- Identity — retailers, sales agents, roles and permissions
Each context owns its data and exposes a clear API boundary. No shared database tables. This felt painful at first — we had to duplicate some product data in the fulfillment context — but it eliminated an entire class of coupling bugs.
// Each context defines its own product representation
// Catalog's view: full product detail
interface CatalogProduct {
id: string;
name: string;
description: string;
priceTiers: PriceTier[];
categories: string[];
availability: RegionalAvailability[];
}
// Fulfillment's view: only what's needed for picking/delivery
interface FulfillmentItem {
productId: string;
name: string;
sku: string;
weight: number;
dimensions: Dimensions;
warehouseLocation: string;
}Aggregates: Protecting Invariants
Within each context, we defined aggregates — clusters of entities that change together and enforce business rules. The Order aggregate was our most instructive example.
An order isn't just a list of line items. It has invariants: minimum order value per region, maximum weight per delivery vehicle, credit limit checks. By modeling Order as an aggregate root, all mutations go through methods that enforce these rules:
class Order {
private items: OrderItem[] = [];
private status: OrderStatus = "draft";
addItem(product: OrderableProduct, quantity: number): void {
if (this.status !== "draft") {
throw new OrderModificationError("Cannot modify a submitted order");
}
const existing = this.items.find((i) => i.productId === product.id);
if (existing) {
existing.quantity += quantity;
} else {
this.items.push(new OrderItem(product, quantity));
}
this.enforceMinimumOrderValue();
this.enforceWeightLimit();
}
submit(creditCheck: CreditCheckResult): void {
this.enforceMinimumOrderValue();
this.enforceWeightLimit();
if (!creditCheck.approved) {
throw new CreditLimitExceededError(creditCheck.availableCredit);
}
this.status = "submitted";
this.addDomainEvent(new OrderSubmittedEvent(this.id, this.items));
}
}Event-Driven Communication
Contexts communicate through domain events, not direct calls. When an order is submitted, the fulfillment context reacts to OrderSubmittedEvent by creating a picking task. The catalog context listens to update demand forecasting.
We used a simple in-process event bus initially, then migrated to Kafka as we split into services:
// Domain event published by Orders context
interface OrderSubmittedEvent {
type: "order.submitted";
orderId: string;
items: Array<{ productId: string; quantity: number }>;
retailerId: string;
deliveryAddress: Address;
timestamp: Date;
}
// Fulfillment context subscribes and reacts
class FulfillmentEventHandler {
async handle(event: OrderSubmittedEvent): Promise<void> {
const pickingTask = PickingTask.createFrom(event);
await this.pickingTaskRepository.save(pickingTask);
await this.routePlanner.assignToNextAvailableRoute(pickingTask);
}
}The key insight: events should carry enough data for the consumer to act without calling back to the producer. This keeps contexts decoupled even under load.
What We Got Wrong
Not everything was smooth. A few lessons from our mistakes:
-
Over-granular contexts early on. We initially split "Catalog" into "Products" and "Pricing" — two contexts that changed together constantly. We merged them back within a month.
-
Anemic domain models. Our first pass had entities that were just data bags with getters/setters. The real value came when we moved business logic into the domain objects.
-
Ignoring the ubiquitous language. Engineers called it a "shipment," operations called it a "dispatch," and the database called it a "delivery_batch." We burned a week on a bug that was really just a naming confusion.
The Payoff
Six months after the restructuring:
- Deployment frequency went from weekly to multiple times per day
- Incident rate for cross-module bugs dropped by roughly 70%
- Onboarding time for new engineers halved — each context is understandable in isolation
DDD isn't a silver bullet. It adds upfront modeling cost and introduces data duplication. But for a domain as complex as B2B distribution logistics, having explicit boundaries and a shared language between engineers and domain experts was transformative.
The codebase went from "don't touch that, it might break something" to "I own this context and I know exactly what changes are safe." That confidence is worth the investment.