Domain-Driven Design in TypeScriptPart 2
Use bounded contexts to define model ownership
Learn how bounded contexts and a context map keep onboarding, identity, entitlements, and provisioning from collapsing into one enterprise model.
The first article found a shared language for enterprise onboarding. Shared language is only the beginning of strategic DDD. We now need to decide where each part of that language is valid and who has authority to change it. That is the job of bounded contexts and a context map.
Our running SaaS example exposes the need for those boundaries when a provisioning retry creates a second entitlement. It is tempting to blame the retry loop, but the deeper problem is ownership: Onboarding writes the Entitlements context’s tables directly. That shortcut lets Onboarding create commercial state without knowing whether the contract permits a second allocation or whether the first request already succeeded.
Shared names do not solve that problem on their own. Different parts of a business need authority over different decisions. Tenant onboarding can coordinate a launch, but it should not decide seat entitlements, identity rules, or regional provisioning details.
A bounded context is the part of the system in which a set of terms and rules has one consistent meaning. The Entitlements context, for example, owns what an allocation means and whether a contract permits one. Other contexts cross that boundary through a public function, command, or API and translate the response into their own language. A context map records who owns each decision and how those translations happen. Neither concept requires microservices; the boundaries can be enforced between modules inside one TypeScript application.
Bounded Context is one of Evans’s central strategic patterns. Martin Fowler’s explanation of bounded contexts is a helpful companion because it shows why a large domain rarely supports one perfectly unified model. The same word can carry different rules for different teams. The goal is not to force one universal definition, but to make each local meaning and every translation between meanings explicit.
Onboarding may request an allocation, but only Entitlements may approve and record one.
A context map makes ownership and integration explicit
Open in StudioThe example
In the tenant onboarding workflow, that collapse looks like this:
async function onboardEnterpriseCustomer(input: OnboardingInput) {
await entitlements.requestAllocation(input.account);
await iam.configureSaml(input.identityProvider);
await provisioning.seedTenant(input.tenant);
await db.onboarding.update({ status: "complete" });
}
It ships quickly because one function can reach every dependency. The cost appears later: an entitlement-mapping bug blocks identity setup, a federation-metadata change alters Onboarding’s request and response types, and a provisioning retry creates duplicate entitlements. These failures are related only because the function has collapsed four owners into one call path. A developer can no longer change one capability safely without first understanding all of them.
This is how a modular monolith loses its ownership lines: every change now requires reading every context.
Bounded contexts
Carve the domain by business capability and ownership:
| Context | Owner | Model language |
|---|---|---|
| Tenant onboarding | Account operations | lifecycle, milestones, readiness gates, owner handoffs |
| Identity & Access | Security | federation, SAML/OIDC, role mappings, authentication |
| Entitlements | Commercial operations | subscriptions, quotas, feature grants, agreement terms |
| Provisioning | Platform | data-plane cluster, tenant seed records, background jobs |
| External identity translation | Integration | external identifiers, directory groups, identity claims |
These contexts can live in one deployable. The boundary is about model ownership, not network topology: Entitlements can expose a public module function while keeping its tables and internal objects private. Moving that module behind HTTP later would change deployment, but it would not create the ownership boundary; the code and team responsibilities must already do that.
Context map
The relationships should be explicit:
- Tenant onboarding -> Entitlements: customer-supplier. Tenant onboarding cannot mark a tenant production-ready until Entitlements approves the seat allocation against the signed order form.
- Tenant onboarding -> IAM (identity and access management): customer-supplier. Tenant onboarding waits for validated SAML metadata and required identity provider group mappings.
- Tenant onboarding -> Provisioning: customer-supplier. Tenant onboarding waits for the correct data-plane cluster, tenant seed records, and default admin role setup.
- Tenant onboarding -> External identity provider: anti-corruption layer. External identity-provider shapes must not become internal domain objects.
A context map should change imports, contracts, tests, and review ownership. Otherwise it is only a diagram.
Failure mode: boundary bypass
The dangerous shortcut is letting Onboarding write another context’s tables directly:
await db.entitlement.create({
data: {
tenant_id: tenantId,
plan: "enterprise",
seats: 500,
},
});
That code bypasses the Entitlements context’s language. Is enterprise an agreement code, a feature bundle, or a negotiated exception? Can one regional workspace draw from the parent organization’s expansion pool? Onboarding should not decide that by inserting a row.
The safer boundary is a command or public contract:
await entitlementsClient.requestSeatAllocation({
globalOrganizationId,
regionalDivisionId,
tenantId,
requestedSeats: 100,
contractId,
});
Entitlements can approve, reject, or request commercial review in its own model.
Anti-corruption layer
External enterprise systems are especially dangerous because every client has a different vocabulary.
The fictional client uses an external identity provider backed by a separate directory. Group assignments arrive during SAML/OIDC sign-in or user provisioning.
That payload may look like this:
{
"tid": "example-idp-tenant",
"oid": "user-9f14",
"groups": ["grp-us-ops-admin", "grp-us-ops-review"],
"department": "Operations",
"country": "US"
}
Onboarding should not spread identity provider object ids, directory group names, or client department strings through the domain. Translate at the edge:
type IdentityProfileMapping = {
regionalDivisionId: RegionalDivisionId;
residencyPolicy: ResidencyPolicy;
roleMapping: RoleMappingDraft;
};
function mapExternalIdentityProfile(
payload: ExternalIdentityProfilePayload,
): IdentityProfileMapping {
return {
regionalDivisionId: divisionFromGroupClaims(payload.groups),
residencyPolicy: residencyPolicyForCountry(payload.country),
roleMapping: mapGroups(payload.groups),
};
}
An anti-corruption layer keeps another system’s vocabulary from becoming your domain model.
The domain should talk about regional divisions, residency policies, and role mappings, not tid,
oid, or grp-us-ops-admin.
TypeScript enforcement
Architecture diagrams are not enough. Enforce boundaries in the monorepo:
{
"compilerOptions": {
"paths": {
"@onboarding/*": ["src/onboarding/*"],
"@entitlements/public": ["src/entitlements/public-api.ts"],
"@iam/public": ["src/iam/public-api.ts"]
}
}
}
Then ban deep imports:
{
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": ["@entitlements/domain/*", "@iam/domain/*"]
}
]
}
}
These fragments show the rule, not a complete lint configuration. In a real repository, add a failing architecture test or lint fixture so CI proves that a forbidden import is rejected.
Onboarding can depend on the Entitlements context’s public contract. It cannot reach into that context’s aggregate and mutate entitlement state.
The takeaway
I use clear boundaries to prevent one tenant’s onboarding error from spilling into entitlements, IAM, provisioning, and the rest of the application ecosystem.
Strategic DDD earns its keep by stopping departmental models from corrupting each other.
Principles to apply in your own work
- Draw bounded contexts around coherent business models, not around deployment units, database schemas, or team names by default.
- Give each business decision and invariant one clear owning context.
- Make every relationship between contexts explicit: record its direction, integration contract, and translation point.
- Integrate through published contracts rather than importing another context’s domain objects or persistence model.
- Use an anti-corruption layer when an upstream or external model would distort the receiving context’s language.
- Enforce intended boundaries with dependency rules and architecture tests so accidental coupling fails in CI.
A context map describes how models relate; it does not require every context to be a separate service. Choose deployment boundaries independently, based on operational needs.