Domain-Driven Design in TypeScriptPart 1

Learn DDD by modeling enterprise SaaS onboarding

Learn how Domain-Driven Design turns a complicated SaaS onboarding workflow into shared language, explicit business rules, and a model the team can trust.

Domain-driven design, or DDD, helps a team turn hard-won knowledge about a business into a model that both the software and the people around it can use. Instead of starting with database tables or framework patterns, the team first asks what the business is trying to accomplish, which rules must hold, and where the same word means different things to different people.

This series teaches that way of thinking through one fictional but realistic case: onboarding a global company into a multi-tenant SaaS product. The customer has one commercial agreement, several regional legal entities, strict data-residency requirements, and multiple workspaces. Sales, Security, Legal, Support, and Platform Engineering all participate, but each sees a different part of the problem. That makes the case complicated enough to show why DDD is useful without requiring any private knowledge of an existing product. The scenario was created for teaching.

You do not need previous DDD experience. We will start with the language people use, find the boundaries between business capabilities, and then introduce TypeScript patterns only when they protect a rule we can name. By the final article, one seat-allocation request will pass through the whole design so you can see how the ideas fit together.

Here is the first problem we will untangle. An API accepts accountId, tenantId, and region. Those names look reasonable until two teams use them to describe different things. Sales may use accountId for the company that signed the contract, while Legal uses it for the regional entity responsible for the data. The type checker sees a string either way, so it cannot warn us when we use the right value with the wrong meaning. A developer can then provision a valid tenant into a valid region that violates the customer’s data-residency agreement.

The vocabulary in this series comes from Eric Evans’s 2004 book Domain-Driven Design: Tackling Complexity in the Heart of Software. Evans gave the software community a way to connect careful domain learning with concrete design patterns. His freely available DDD Reference is a useful map of those ideas, though it is intentionally a reference rather than a replacement for the book. Later writers and practitioners, especially Martin Fowler and Vaughn Vernon, helped make the strategic and tactical parts of DDD easier to apply in modern systems. I will link to their work where it adds depth to a specific decision.

Discover the onboarding domain before designing the code

Open in Studio
This lightweight event-storm view reads left to right. Commands express intent, aggregates make decisions, policies enforce rules, and past-tense events record what happened.

The diagram is not an implementation plan. It is a discovery view, similar to a small event-storming session. Reading it from left to right forces us to name the decisions and business facts before we argue about services or tables. The command Register organization is something a person wants the system to do. OrganizationRegistered is the fact we can discuss after the system accepts it. The residency policy explains why tenant placement needs a separate decision instead of a generic region string.

Strategic and tactical DDD solve different problems

DDD is often introduced through code patterns such as entities, value objects, aggregates, and repositories. Those are the tactical tools. They help a team express business identity, rules, consistency boundaries, and persistence in code. They are useful, but they are not the starting point and they are not the whole method.

The strategic work asks larger questions first: Which part of the business deserves the most care? Where is a model valid? Who owns a decision? Where does the same word have a different meaning? The ubiquitous language, bounded contexts, context maps, and distinction between core and supporting domains come from that work. In a later talk about what he had learned since the book, Evans put ubiquitous language, context mapping, and the core domain at the center of his own practice, with aggregates close by. That emphasis is important because a beautifully implemented aggregate cannot repair a model built around the wrong business concepts.

This series uses both sides, but not as a package deal. We will first discover the language and ownership boundaries. Then we will use tactical patterns only where they protect a specific rule or make a business decision easier to see. A straightforward create, read, update, and delete screen may need none of them. A seat allocation constrained by a contract probably does.

DDD is not a folder structure. It is a way to keep business understanding and software design in conversation with each other.

Find the disagreement hiding in the code

Ask each group to describe the customer and you will hear a different part of the relationship. Marketing talks about the account, commercial operations about the global contract, Legal about the contracting entity, Security about the data isolation region, and Customer Success about the tenant where users work. Developers often compress all of those answers into tenant_id, even though that column cannot tell a reviewer which relationship it represents.

Those words are not synonyms. In a shared-schema SaaS system, confusing them can leak data across regions, apply the wrong contract terms, or provision a tenant into the wrong residency boundary.

The domain is not “create a tenant.” The domain is:

Safely onboard a global enterprise customer into one or more isolated operational tenants while honoring contract, residency, access, quota, and compliance constraints.

That sentence is the beginning of the model.

Use one case to test the language

This series uses an unnamed, fictional multi-region software organization. It is a teaching scenario, not a disguised employer or client project.

The organization signs one global contract, but it cannot run as one flat tenant:

  • The parent organization owns the legal and commercial relationship.
  • One regional workspace must keep regulated data in a US region.
  • A second regional workspace must keep EU personal data in an EU region.
  • Each regional division needs one or more operational tenants for business units like regional operations and partner support.
  • The contract buys 100 seats for the first rollout, but the commercial team wants to reserve expansion capacity.
  • Security requires SAML federation before any tenant is marked production-ready.
  • Platform must create the tenant’s shared-schema database records in the correct residency region and seed default roles before users can sign in.

The rest of the series uses this same onboarding case. First, we need names that distinguish the contract holder, its regional entities, and the workspaces people actually use. If a pattern does not make this capability safer or easier to understand, it does not belong in the model.

The core language

For this series, the domain model starts with three structural concepts:

TermMeaning
Global OrganizationParent corporate entity that owns the master commercial relationship
Regional DivisionLegal/geographic subdivision that satisfies residency or compliance law
Business Unit / TenantOperational workspace where users actually work
Residency PolicyAllowed data region and compliance rule set for a regional division
Enterprise ContractCommercial agreement that defines quotas, entitlements, and obligations

That vocabulary is not documentation garnish. It determines which relationships the code can express and which invalid combinations it can reject. If a RegionalDivisionId and a TenantId are distinct types, a function signature can require the division that owns a tenant instead of accepting an ambiguous string and relying on the caller to remember the relationship.

At this point, do not start with TypeScript tricks. Start by checking whether people in product, support, and engineering use these words the same way.

Before: one id to rule them all

This is how enterprise SaaS products drift into danger:

async function createTenant(input: { accountId: string; tenantId: string; region: string }) {
  await db.tenant.create({
    data: {
      account_id: input.accountId,
      tenant_id: input.tenantId,
      region: input.region,
    },
  });
}

Neither parameter tells the caller which concept it expects. accountId could identify a CRM account, subscription, global organization, or regional legal entity. region could describe a UI preference, legal residency boundary, or deployment target. Because the signature cannot distinguish those choices, reviewers have to reconstruct the intended meaning from surrounding code.

The code accepts the wrong questions.

After: model the business structure

Before reaching for an aggregate, model the business structure:

  1. A Global Organization owns the commercial relationship.
  2. A Regional Division belongs to that organization and carries residency obligations.
  3. A Business Unit / Tenant belongs to a regional division and is where users work.
  4. The tenant must satisfy a Residency Policy.
  5. The tenant must fit inside a Contract entitlement.
  6. The tenant must reach Identity readiness: SAML metadata accepted, required identity-provider groups mapped, and first admin access tested.

Now a code review can ask domain questions:

  • Does this tenant belong to this regional division?
  • Is the division allowed to use this residency policy?
  • Does the contract cover this tenant’s quota request?
  • Which legal entity owns the data?

Those are better questions than “did we pass the right string?”

The TypeScript representation matters, but it should follow the language. In later posts, the code examples use typed identities and value objects. They are useful only because the business concepts have already been separated.

Even a small type model gives code review better questions:

type TenantPlacement = {
  organizationId: GlobalOrganizationId;
  divisionId: RegionalDivisionId;
  tenantId: TenantId;
  residencyPolicy: ResidencyPolicy;
  contractId: EnterpriseContractId;
};

function placeTenant(input: TenantPlacement) {
  if (!input.residencyPolicy.allows(input.divisionId)) {
    return { ok: false, reason: "division violates residency policy" } as const;
  }

  return { ok: true, placement: input } as const;
}

The branded ids alone do not prove the relationship is valid. The policy check still matters. The improvement is that the code now names the relationship it is checking instead of accepting three unrelated strings.

Core domain versus supporting domains

Not every concept deserves the same modeling pressure. Modeling pressure means the time spent on precise language, dedicated types, protected invariants, and tests. That effort is valuable where a mistake would violate a contract or expose customer data, but it would be wasteful for a generic email template editor with ordinary create, read, update, and delete behavior.

In this series, tenant onboarding is the core domain because mistakes here create customer-visible failures and compliance exposure. Entitlements, identity and access management (IAM), and security compliance are important supporting domains with their own models. Email delivery and template editing are generic capabilities.

That classification matters because DDD does not mean giving every module an aggregate and a custom type system. It means spending the strongest modeling effort on the rules that differentiate the business or carry the greatest risk. Supporting and generic capabilities still need clean code, but they should not receive ceremony that does not protect a concrete promise.

The takeaway

A shared vocabulary in code is the first line of defense against data-isolation failures. If two stakeholders use one word for different residency or contract concepts, fix the language before designing workflows, events, repositories, or aggregates.

Isolation bugs often start as naming bugs.

Principles to apply in your own work

  1. Build the ubiquitous language with domain experts and developers; neither group can define it alone.
  2. Give different business concepts different names, even when the existing system represents them with the same primitive value.
  3. Define important terms in plain business language, including examples and counterexamples that reveal where each term stops applying.
  4. Let the language be local to a bounded context. A word may have another meaning elsewhere as long as the boundary and translation are explicit.
  5. Encode agreed distinctions in types, commands, events, and module APIs so the model reinforces the language during implementation.
  6. Treat recurring misunderstandings and defects as evidence that the language or model needs to change.

Ubiquitous language is a living part of the model, not a glossary completed before coding begins. Keep refining it as the team learns more about the domain.