What bounded contexts, context maps, and ubiquitous language actually felt like inside a portfolio-wide DDD migration at a London product agency, and where I'd do it differently.
At the London product agency I led engineering at, we had a portfolio of older client projects nobody really owned. A couple of engineers floated between them depending on whose deadline was on fire that week. The word “order” meant something different in every codebase, and every codebase paid for it. I made the call to move the whole portfolio toward Domain-Driven Design, and we spent the next several months figuring out what that actually meant in practice.
This is the strategic side, not the tactical one. No aggregate roots, no repository patterns, no value-object boilerplate. Just bounded contexts, context maps, and ubiquitous language. The stuff that decides whether the rest of it has anywhere to live.
Honestly the trigger wasn’t a clean architectural impulse. It was the flagship SaaS we’d built end to end. We’d shipped that one and the company closed a funding round shortly after, and suddenly we had more engineers across more projects than the original conventions could carry. The contrast got embarrassing. On the flagship, “subscription” had one meaning, the one we’d argued about on a whiteboard. On a four-year-old PHP project we’d inherited from before my time, it had four. Support meant one thing by it. Billing meant another. The Laravel models meant a third.
That’s not a code problem. It’s a language problem. Code just makes the language permanent.
So I’ll get this out of the way. Bounded contexts are not about microservices. They’re about agreeing what words mean inside one team’s territory, drawing a line around it, and writing down how that line gets crossed. You can do all of this in a monolith, and in most of the portfolio that’s exactly what we did.
A bounded context is a slice of the system where one definition of each domain word holds. Inside, “Product” means one thing. Outside, “Product” can mean something else, and that’s fine, because the boundary is the contract.
The boutique fitness product we built is the example I keep coming back to. Studios, classes, members, the works. “Class” meant three completely different things depending on who you asked. Scheduling people: a 7pm slot on Tuesday at the Shoreditch studio. Membership people: a count against your monthly allowance. Reporting people: a row in a revenue table. First month, we tried to unify all three into one Class model. Got exactly the kind of god-object we’d promised the team we’d stop building.
So we stopped.
// scheduling/domain/class-session.ts
import { Duration } from "../../shared/duration";
import { StudioId } from "./studio-id";
export class ClassSession {
private constructor(
readonly id: string,
readonly studioId: StudioId,
readonly startsAt: Date,
readonly duration: Duration,
private capacity: number,
private bookedCount: number,
) {}
book(): void {
if (this.bookedCount >= this.capacity) {
throw new ClassFullError(this.id);
}
this.bookedCount += 1;
}
hasFreeSlot(): boolean {
return this.bookedCount < this.capacity;
}
}
// membership/domain/class-credit.ts
export class ClassCredit {
private constructor(
readonly memberId: string,
private remaining: number,
readonly periodEndsAt: Date,
) {}
consumeOne(): void {
if (this.remaining <= 0) {
throw new NoCreditsLeftError(this.memberId);
}
if (this.periodEndsAt < new Date()) {
throw new MembershipExpiredError(this.memberId);
}
this.remaining -= 1;
}
}
Two contexts, two models, same real-world thing from two angles. Scheduling has no idea what a “credit” is. Membership has no idea about studios. They each own their slice of what “booking a class” means.
The day this clicked for the team was the day someone shipped a Membership feature without touching a single Scheduling file. Boring, in the best way.
The context map is the boring deliverable that pays off for years. Literally a piece of paper, ideally a real one, that says which contexts exist and what shape the line between any two of them takes. Customer/Supplier. Conformist. Anti-Corruption Layer. Published Language. Shared Kernel, only when you can’t avoid it.
We standardized on three patterns and forbade the rest by default. ACL, Customer/Supplier, Published Language. That’s it. Anything else needed a conversation.
// ordering/infra/payment-gateway.acl.ts
import { Stripe } from "stripe";
import { Money } from "../../shared/money";
export class StripePaymentGatewayACL implements PaymentGateway {
constructor(private readonly stripe: Stripe) {}
async capture(orderId: string, amount: Money, token: string) {
try {
const charge = await this.stripe.charges.create({
amount: Math.round(amount.value * 100),
currency: amount.currency.toLowerCase(),
source: token,
metadata: { order_id: orderId },
});
return {
status: charge.status === "succeeded" ? "captured" : "pending",
gatewayReference: charge.id,
} as const;
} catch (err) {
if (err instanceof Stripe.errors.StripeCardError) {
return { status: "declined", reason: err.decline_code } as const;
}
throw new PaymentGatewayUnavailable(err);
}
}
}
Ordering speaks captured, declined, Money. Stripe speaks cents, decline codes, Connect tokens. The ACL is the only place those two languages meet. The payoff showed up two years later on a healthcare portal sub-account, when we had to swap in a Turkish provider for regulatory reasons. The rest of Ordering didn’t move. The ACL did. That’s the whole pitch.
The other two patterns. Customer/Supplier inside the same repo (Ordering pulls availability from Inventory, Inventory owns the schema) and Published Language for anything crossing a service boundary. Producer owns the versioned event schema, consumer reads it and never mutates it. Add fields, fine. Remove or rename, that’s a new version.
A context boundary violation does not always announce itself as a modeling mistake. Sometimes it shows up as a slow synchronous call from a hot consumer into a service that belongs to a different context with different latency characteristics. Published Language guards against this: emit an event, project locally, never make a synchronous call across that line. Lazy boundary drawing has a runtime cost.
People treat ubiquitous language like a glossary you put on Notion and never look at again. Honestly that’s not what it is. It’s the words your code uses, the words your support triage uses, the words your product manager uses on a kickoff call. When those drift apart, you ship the drift.
We had one rule on every project across the agency. The variable name in code matches the noun the domain expert uses on the call. If the gym manager calls it a “class pass”, the column is class_pass, not subscription, not voucher. On the worst-affected projects we put a linter on it. promoId got rejected in review. promotionId required.
// ordering/domain/order.ts
export class Order {
cancel(reason: CancellationReason): void {
if (this.status === "shipped" || this.status === "delivered") {
throw new OrderCannotBeCancelled(
`Order ${this.id} is ${this.status}. Use initiateReturn instead.`,
);
}
if (this.status === "cancelled") return;
this.status = "cancelled";
this.cancellationReason = reason;
this.raise(new OrderCancelled(this.id, reason, this.lineItems));
}
initiateReturn(items: ReturnItem[], reason: ReturnReason): Return {
if (this.status !== "shipped" && this.status !== "delivered") {
throw new ReturnNotAllowed(
`Order ${this.id} hasn't shipped. Use cancel instead.`,
);
}
return Return.create({ orderId: this.id, items, reason });
}
}
Two methods, two words, two rules. A support rep cannot “cancel” a shipped order through this domain model. The compiler is the dictionary.
This kind of thing sounds pedantic right up until you sit in a triage call where the customer says “I want to cancel my order” and the rep has to know whether they mean cancel-as-in-cancel or cancel-as-in-return. Different process, different team, different refund window. The vocabulary mismatch costs you in a way that’s hard to measure but easy to feel.
A few honest things from the portfolio-wide migration that I still think about.
I underestimated how many projects didn’t actually need full DDD. A real chunk of them were small enough that a clean module split inside a Laravel monolith plus disciplined naming was most of the win. We over-applied on a few of those early on and the engineers maintaining them rightly pushed back. I’d be quicker now to say “this one is small, just rename things, move on”.
I should have shipped the language linter on day one instead of waiting until we were knee-deep. Drift is cheap to prevent at write time and weirdly expensive to clean up later. Every rename PR became a coordination thing across teams.
And the boundary calibration. We split contexts too finely on at least four projects. Rate of change is a useful heuristic for splitting. Access pattern is just as useful, maybe more. If two things are always queried together, they probably belong in the same context, even when their roadmaps look different on paper. You can always split later. Merging is the harder direction.
Thanks for reading. If you’ve got thoughts, send them my way.