← Architecture
Decompose a system Where a word stops

Bounded contexts

One word, one meaning, per context.

Somewhere in your codebase there is a Customer, or a User, that every part of the app imports, and half of its fields are optional because somebody needed them once. Let’s build a small insurer, file one claim, and see who that record lands on.

TypeScriptGoOne insurer, two models of its people, a scan, and six recorded builds.

01 / The prompt

“Build me a backend for a small car insurer.”

You ask for quotes, policies, claims, and monthly billing. What comes back works. When we asked, the plain request returned one server.ts with a Quote, a Policy, and a Claim, and a script found every month-end letter where it should be.

Many codebases take the other road. The first feature creates a customers table, and every feature after it adds the columns it needs, because a customer is a customer. This lesson’s story starts there, with an insurer written that way and a second version of the same insurer that gives each team its own record. Both run, and both are tested.

Then Claims needs to record the other driver in an accident. He is a person, and the only record for a person is Customer. The question the prompt never answered is what does “customer” mean here, and to whom? Bounded contexts answer it once per part of the business.

02 / Name the shape

One word, one meaning, per context.

A bounded context is the part of a system inside which a model, and the words it uses, mean one thing. Eric Evans defines it as “a description of a boundary (typically a subsystem, or the work of a particular team) within which a particular model is defined and applicable,” and his advice is short: “Explicitly define the context within which a model applies.”

Eric Evans, Domain-Driven Design Reference, 2015, definitions and “Bounded Context”.

In the insurer’s terms, the rule is:

Give each part of the business its own model of the people it deals with. Where two parts meet, translate. Never make one record carry every meaning.

The test is practical. If a part of the code has to invent values to build a record, the record is not its word.

What “customer” means in each context
ContextIts wordWhat it holds about the person
quoting/Prospectid, name, email, dateOfBirth, licenseNumber, address, premiumCents, quotedOn, accepted
policies/PolicyholderpolicyNumber, name, email, address, licenseNumber, status
claims/Claimantname, email, role, plate
billing/PayerpolicyNumber, name, email, paymentMethod, monthlyCents

Words to put in a prompt or a review

Bounded context
A part of the system inside which one model, and its words, apply.
Ubiquitous language
The words a team and its code share inside one context.
Context map
Where contexts meet, and what crosses between them.
Translation
Turning one context’s record into what the next one needs, at the boundary.
Placeholder field
A value written only because the type demands it: a sign the type is not yours.
Shared kernel
A small model two contexts agree to own together, on purpose.
When one model is rightSmall teams, one meaning, and shared kernels

Plenty of apps have one person model and are right to. A note-taking app’s user is the person who signs in, pays, and owns the notes; there is one meaning, so there is one type. Contexts earn their cost when the same word starts meaning different people, or the same person in different roles, with fields that are empty for most of them.

Sometimes two contexts do share a small piece on purpose, such as the policy number that Claims and Billing both use to refer to a policy. That is a shared kernel: kept small, owned by both teams, and changed only when both agree. A policy number is an identifier, not a person.

03 / Follow one claim

Watch one claim put the wrong person on the mailing list.

Ana buys a policy on 1 September. On 10 September Ben rear-ends her, and Claims records him. On 30 September the month-end jobs run. First with one shared Customer type, then with a model per context. In Try it, you choose what Claims writes.

Decompose a system

Who gets the month-end mail?

One Customer type

Customer fields by context
Customer fieldquotingpoliciesclaimsbilling
iduses
nameusesusesuses
emailusesusesuses
dateOfBirthfills
licenseNumberfills
addressfills
statususesusesuses
policyNumberblankusesuses
premiumCentsusesuses
paymentMethodblankuses
createdOnuses

“customer” in (root), billing, claims, customers, policies, quoting

01/ 04
One Customer

One Customer, eleven fields.

Quoting, Policies, Claims, and Billing all build and read the same record. The word “customer” is in 6 folders.

Reduced motion: choose a scene to see its completed state.

Read this scene

Quoting, Policies, Claims, and Billing all build and read the same record. The word “customer” is in 6 folders.

One Customer type. “customer” in (root), billing, claims, customers, policies, quoting.

Watch restarts the story when you come back. Step through keeps your step. Try it runs a fresh month each time you press the button.

04 / Read the shape

A shared word shows up as placeholders.

Basic form finds where the word is used at all. In the wild finds what it means in each place: the fields each context uses, and the fields it fills with nothing. At the call site the table becomes a check a review can run.

Like any text scan, it follows names. A context that calls its record c hides its uses from the field count, though not from the placeholder report, which reads what is passed to createCustomer.

Where a word lives: every identifier that contains the term, per top-level folder. The scan reads code with comments and string contents blanked, so “customer” in a comment or a message does not count, but one inside a ${…} expression does.

TypeScriptReading
bounded.ts
type Mode =
	{ mode: 'code'; depth: number } | { mode: 'string'; quote: string } | { mode: 'template' };

/**
 * The same text, the same length, with comments turned to spaces. With `blankStrings`,
 * string and template contents become spaces too, but `${…}` expressions stay code.
 */
export function blank(source: string, blankStrings = true): string {
	const out: string[] = [];
	const stack: Mode[] = [{ mode: 'code', depth: 0 }];
	const keep = (ch: string) => (blankStrings && ch !== '\n' ? ' ' : ch);
	let i = 0;
	while (i < source.length) {
		const top = stack[stack.length - 1];
		const ch = source[i];
		const two = source.slice(i, i + 2);
		if (top.mode === 'code') {
			if (two === '//') {
				while (i < source.length && source[i] !== '\n') {
					out.push(' ');
					i++;
				}
				continue;
			}
			if (two === '/*') {
				const end = source.indexOf('*/', i + 2);
				const stop = end < 0 ? source.length : end + 2;
				for (; i < stop; i++) out.push(source[i] === '\n' ? '\n' : ' ');
				continue;
			}
			if (ch === "'" || ch === '"') stack.push({ mode: 'string', quote: ch });
			else if (ch === '`') stack.push({ mode: 'template' });
			else if (ch === '{') top.depth++;
			else if (ch === '}') {
				if (top.depth === 0 && stack.length > 1) stack.pop();
				else top.depth--;
			}
			out.push(ch);
			i++;
		} else if (top.mode === 'string') {
			if (ch === '\\') {
				out.push(keep(ch), keep(source[i + 1] ?? ''));
				i += 2;
				continue;
			}
			if (ch === top.quote || ch === '\n') {
				stack.pop();
				out.push(ch);
			} else out.push(keep(ch));
			i++;
		} else {
			if (ch === '\\') {
				out.push(keep(ch), keep(source[i + 1] ?? ''));
				i += 2;
			} else if (ch === '`') {
				stack.pop();
				out.push(ch);
				i++;
			} else if (two === '${') {
				stack.push({ mode: 'code', depth: 0 });
				out.push('$', '{');
				i += 2;
			} else {
				out.push(keep(ch));
				i++;
			}
		}
	}
	return out.join('').slice(0, source.length);
}

const IDENT = /[A-Za-z_$][A-Za-z0-9_$]*/g;

/** The top-level folder a file lives in; files at the root belong to `(root)`. */
export function contextOf(path: string): string {
	return path.includes('/') ? path.slice(0, path.indexOf('/')) : '(root)';
}

/** For each context, the distinct identifiers that contain the term, ignoring case. */
export function termUses(files: SourceFile[], term: string): Record<string, string[]> {
	const out: Record<string, Set<string>> = {};
	for (const file of files)
		for (const [name] of blank(file.source).matchAll(IDENT))
			if (name.toLowerCase().includes(term.toLowerCase()))
				(out[contextOf(file.path)] ??= new Set()).add(name);
	return sortedRecord(out);
}

function sortedRecord(record: Record<string, Set<string>>): Record<string, string[]> {
	return Object.fromEntries(
		Object.keys(record)
			.sort()
			.map((key) => [key, [...record[key]].sort()])
	);
}
GoAlongside
main.go
type mode struct {
	kind  byte // 'c' code, 's' string, 't' template
	quote byte
	depth int
}

// Blank returns the same text, the same length, with comments turned to spaces.
// With blankStrings, string and template contents become spaces too, but ${…}
// expressions stay code.
func Blank(source string, blankStrings bool) string {
	out := []byte(source)
	stack := []*mode{{kind: 'c'}}
	keep := func(i int) {
		if blankStrings && source[i] != '\n' {
			out[i] = ' '
		}
	}
	for i := 0; i < len(source); {
		top := stack[len(stack)-1]
		ch := source[i]
		two := source[i:min(i+2, len(source))]
		switch top.kind {
		case 'c':
			switch {
			case two == "//":
				for ; i < len(source) && source[i] != '\n'; i++ {
					out[i] = ' '
				}
				continue
			case two == "/*":
				stop := len(source)
				if end := strings.Index(source[i+2:], "*/"); end >= 0 {
					stop = i + 2 + end + 2
				}
				for ; i < stop; i++ {
					if source[i] != '\n' {
						out[i] = ' '
					}
				}
				continue
			case ch == '\'' || ch == '"':
				stack = append(stack, &mode{kind: 's', quote: ch})
			case ch == '`':
				stack = append(stack, &mode{kind: 't'})
			case ch == '{':
				top.depth++
			case ch == '}':
				if top.depth == 0 && len(stack) > 1 {
					stack = stack[:len(stack)-1]
				} else {
					top.depth--
				}
			}
			i++
		case 's':
			if ch == '\\' {
				keep(i)
				if i+1 < len(source) {
					keep(i + 1)
				}
				i += 2
				continue
			}
			if ch == top.quote || ch == '\n' {
				stack = stack[:len(stack)-1]
			} else {
				keep(i)
			}
			i++
		default:
			switch {
			case ch == '\\':
				keep(i)
				if i+1 < len(source) {
					keep(i + 1)
				}
				i += 2
			case ch == '`':
				stack = stack[:len(stack)-1]
				i++
			case two == "${":
				stack = append(stack, &mode{kind: 'c'})
				i += 2
			default:
				keep(i)
				i++
			}
		}
	}
	return string(out)
}

const ident = `[A-Za-z_$][A-Za-z0-9_$]*`

var identRe = regexp.MustCompile(ident)

// ContextOf is the top-level folder, or "(root)".
func ContextOf(p string) string {
	if i := strings.Index(p, "/"); i >= 0 {
		return p[:i]
	}
	return "(root)"
}

func sortedSets(sets map[string]map[string]bool) map[string][]string {
	out := map[string][]string{}
	for key, set := range sets {
		list := []string{}
		for name := range set {
			list = append(list, name)
		}
		slices.Sort(list)
		out[key] = list
	}
	return out
}

func add(sets map[string]map[string]bool, key, value string) {
	if sets[key] == nil {
		sets[key] = map[string]bool{}
	}
	sets[key][value] = true
}

// TermUses maps each context to the distinct identifiers containing the term, ignoring case.
func TermUses(files []SourceFile, term string) map[string][]string {
	sets := map[string]map[string]bool{}
	for _, f := range files {
		for _, name := range identRe.FindAllString(Blank(f.Source, true), -1) {
			if strings.Contains(strings.ToLower(name), strings.ToLower(term)) {
				add(sets, ContextOf(f.Path), name)
			}
		}
	}
	return sortedSets(sets)
}
The two ways Claims records the other drivercreateCustomer with placeholders, against a claimant of its own

With one model, the type every context builds:

one model · customers/index.ts
export type Customer = {
	id: string;
	name: string;
	email: string;
	dateOfBirth: string;
	licenseNumber: string;
	address: string;
	status: 'prospect' | 'active' | 'lapsed';
	policyNumber: string;
	premiumCents: number;
	paymentMethod: string;
	createdOn: string;
};

and what Claims has to write to fit Ben into it:

one model · claims/index.ts
if (input.otherDriver) {
	const other = createCustomer(registry, {
		id: `c${registry.all.length + 1}`,
		name: input.otherDriver.name,
		email: input.otherDriver.email,
		dateOfBirth: '',
		licenseNumber: '',
		address: '',
		status: otherDriverStatus,
		// Kept so the claim screen can find everyone involved from the policy.
		policyNumber: input.policyNumber,
		premiumCents: 0,
		paymentMethod: '',
		createdOn: today
	});
	otherDriverId = other.id;
}

With a model per context, Claims owns the only record that knows about accidents, and the context map in app.ts is the one place that translates a bought quote into a policy and a payer:

a model per context · claims/index.ts
// In Claims, a person is someone involved in an accident. The other driver is
// one of them, with a plate and a role, and no policy, birth date, or payment method.
type Claimant = {
	name: string;
	email: string;
	role: 'policyholder' | 'other driver';
	plate: string | null;
};

type Claim = {
a model per context · app.ts
accept(id: string, paymentMethod: string): string {
	const accepted = quoting.accept(id);
	const policyNumber = policies.bind(accepted);
	billing.start({
		policyNumber,
		name: accepted.name,
		email: accepted.email,
		paymentMethod,
		monthlyCents: Math.round(accepted.premiumCents / 12)
	});
	return policyNumber;
},
fileClaim: (
The behavior these examples promiseChecked by shared cases from a separate model
  • blank keeps the source’s length and line breaks. Comments become spaces. String and template contents become spaces too, but code inside ${…} stays code. An unterminated string ends at the line break.
  • An identifier uses the term when it contains it, ignoring case. A field use is x.field or x?.field, read or assigned, where x uses the term.
  • A type’s fields are the names before : at the top level of the first type Name = { … } in path order.
  • A fill is an object literal passed to create plus the capitalized term. A definition, after the word function, is not a call. Spreads are skipped. A value of '', "", ``, 0, null, undefined, [], or {} is a placeholder.
  • The report has one line per context with at least one placeholder, in context order, fields in the order they were first filled.

Every expectation in cases.json was produced by a small Python model written from these rules, which reads the same repositories and lives beside the examples in model/cases.py.

Reading the TypeScriptA same-length blanking pass

blank keeps every character’s position, so a match in the blanked text can be read back from the original. That is how fills finds structure in code with strings hidden, then reads the value '' from the text that still has them. The stack of modes handles a template inside a ${…} inside a template.

Reading the GoBytes, and a copy you overwrite

Blank copies the source into a byte slice and overwrites positions with spaces, which keeps offsets identical by construction. Go’s regexp returns index pairs, so the same offsets slice both the blanked and the original text. Maps have no order, so every list is sorted before it is compared or printed.

Run it yourselfNo dependencies

Copy the complete TypeScript file and run node --experimental-strip-types bounded.ts with Node 22.18 or later. For Go, save main.go next to this go.mod and run go run .. Both print:

go.mod
module heyrian.dev/lessons/bounded-contexts

go 1.23
"customer" is used in claims, customers, quoting
claims fills 2 of Customer's 4 fields with placeholders: dateOfBirth, paymentMethod
quoting fills 2 of Customer's 4 fields with placeholders: policyNumber, paymentMethod

05 / Review the agent’s diff

“Third parties no longer get renewal notices.”

The bug report came from Ben. The fix is four lines, and it does stop the two letters he got. Before you decide, count how many jobs read Customer, and how many of them the flag reaches.

The agent’s pull request

“Fixed: third parties no longer get renewal notices or invoices. Added an optional flag and filtered on it. All tests pass.”

// customers/index.ts
			export type Customer = {
			  …
			(added)  isThirdParty?: boolean;
			};
			
			// claims/index.ts
			(added)  isThirdParty: true,
			
			// policies/index.ts, renewalNotices
			(removed)  .filter((customer) => customer.status === 'active')
			(added)  .filter((customer) => customer.status === 'active' && !customer.isThirdParty)
			
			// billing/index.ts, invoices
			(added)  .filter((customer) => !customer.isThirdParty)
			
You are reviewing this change. What do you do?

06 / How it fails

A shared record fails by being right for someone else.

Nothing crashes. Every job does exactly what its own team meant by customer. The failures are letters, invoices, and counts that are correct in one context and wrong in the one that wrote the record.

How one shared Customer fails the insurer
What goes wrongWhat someone seesWhere it comes from
Wrong recipientThe other driver receives renewal notice and invoice: “Ben: $0.00 due on P-1000”.Running the one-model insurer through one month. Shared test.
No right valueEvery status Claims could pick sends him something: prospect, quote reminder; active, renewal notice and invoice; lapsed, win-back offer.The same month with each status. Shared test.
Invented dataA claims record with an empty birth date and a zero premium, which a report will one day average.claims fills 5 of Customer's 11 fields with placeholders: dateOfBirth, licenseNumber, address, premiumCents, paymentMethod. Shared case.
Half-fixedThe flag stops two letters and not the other two kinds.Authored: the section 05 diff checks the flag in two of four month-end jobs.
Slow to changeA field Claims needs, such as the other driver’s plate, is added to a type four teams build.Quoting already fills policyNumber, paymentMethod with placeholders. Shared case.
Split too fineEvery screen needs three contexts and a translation to show one person.Authored. See How big should a module or service be?

The fields nobody uses after the quote, dateOfBirth, licenseNumber, address, are the quiet version of the same failure: data kept for everyone because it was needed by one. Making illegal states unrepresentable is the type-level half of the fix, and The mapping layer is the translation half.

07 / Is it worth it?

You pay in translations. Here is what they buy.

One model is less code: no mapping when a quote is bought, one type to import. A model per context adds a translation in app.ts and four types to name. Run both against the same four kinds of change.

The same four changes, made to each insurer
ChangeOne CustomerA model per context
A second entry point: a broker portal that sells quotesIt creates Customers, and has to know what status and placeholders every other team expects.It calls Quoting’s quote. Nothing else sees a prospect.
Replace a dependency: a payment provider that needs a billing addressA field on Customer, filled with a placeholder by Claims and read by Billing.A field on Payer, set in one translation.
Change a rule: record the other driver’s insurerA twelfth field every context builds.A field on Claimant.
A second team takes over ClaimsThey share ownership of Customer with three other teams.They own claims/ and its claimant.
Change the premium rule: drivers under 21 pay morepremiumFor in Quoting.premiumFor in Quoting. No difference: pricing was never shared.

Before you split a model, decide what you will measure and what result you would accept:

  • Placeholders per shared type, from the scan in section 04, run on every pull request. This is the baseline, and a split should bring it to zero for the type it splits.
  • Messages sent to the wrong kind of person, from support tickets or from a check on the outgoing mail. The Ben letter is a ticket before it is a metric.
  • Contexts touched per change, from merged pull requests. A split that works lowers it for changes that belong to one context and may raise it, a little, for changes to the translation.

This lesson measured two small codebases and six recorded builds, not a team over months, so it has no before-and-after numbers for a real one.

08 / Ask for it

Three prompts, one ticket, no Customer.

We sent agents running Claude Sonnet the insurer request three ways. The plain prompt described each month-end letter by what it is about: “every active policy”. The architecture prompt added a block naming four contexts and forbidding a shared Customer. The third was the plain prompt in a business’s words: “Customers get quotes, buy policies, file claims”, and each letter “to every customer with an active policy”. Then each build got the same ticket from a fresh agent: record the other driver, and list everyone involved in a claim. A script asked every build the same questions.

What the checker found, run 2026-09-23
QuestionPlain promptArchitecture prompt“Customer” wording
What came backone file, server.ts16 filesone file, server.ts
Types that hold a personQuote, Policy12 typesQuote, Policy
Where the code says “customer”nowherenowherenowhere
The other-driver ticketserver.ts +63 −1claims/index.ts +35 −1, claims/types.ts +11 −0, server.ts +40 −0server.ts +69 −1
Types that hold a person, after itQuote, Policy, OtherDriver, Person14 typesQuote, Policy, OtherDriver, Person
The claim, with both rolesrecorded, with both rolesrecorded, with both rolesrecorded, with both roles
Month end after the claimnothing to the other drivernothing to the other drivernothing to the other driver
Month end after Ana’s policy lapsesnothing to the other drivernothing to the other drivernothing to the other driver

No build made the mistake this lesson is about. Not one of them has a type called Customer, and none of them uses the word in code, including the build whose prompt said it five times. Each agent modeled the insurer by its records, a quote, a policy, and a claim, and the other driver became a field on the claim. After the ticket, month end sent him nothing in any build.

The plain agents got there without being asked. Their prompt described every letter by the record it concerns, and the records became the model:

server.ts · customer wording
type Quote = {
  quoteId: string;
  name: string;
  email: string;
  dateOfBirth: string;
  licenseNumber: string;
  address: string;
  quotedOn: string; // the `today` value sent when the quote was created
  premiumCents: number;
  accepted: boolean;
};

type PolicyStatus = "active" | "lapsed";

type Policy = {
  policyNumber: string;
  quoteId: string;
  name: string;
  email: string;
  premiumCents: number;
  status: PolicyStatus;
};

type Claim = {
  claimId: string;
  policyNumber: string;
  description: string;
  today: string;
};

The architecture block bought a written map of the contexts and a translation at every boundary. It also bought sixteen TypeScript files and a dozen types that hold a name and an email, where the one-file builds needed two. For an insurer this size, that is the cost of the pattern with none of its payoff yet.

CONTEXTS.md · architecture prompt
| Context | Folder | Its word for a person | Type |
|---|---|---|---|
| Quoting | `quoting/` | **Prospect** - someone who asked for a price and has not bought | `Prospect` (name, email, dateOfBirth, licenseNumber, address) |
| Policies | `policies/` | **PolicyHolder** - someone with a policy, active or lapsed | `PolicyHolder` (name, email, dateOfBirth, licenseNumber, address) |
| Claims | `claims/` | **InvolvedParty** - whoever is party to a claim | `InvolvedParty` (name, email) |
| Billing | `billing/` | **Payer** - whoever pays for a policy | `Payer` (name, email, paymentMethod) |

So the prompt line these runs point to is not “use bounded contexts”. It is the habit the plain prompt already had: describe each job by the record it works on, “every active policy”, not by the person it reaches. Ask for contexts when a second meaning of the word shows up, and use the placeholder report from section 09 to notice when it has.

How the runs were made and checkedSix runs, recorded as written
  • The plain and architecture prompts went to fresh agents at the same time; the “customer” wording was sent after the first checker run found no shared model, to test whether the word alone produces one. Each ticket went to a fresh agent working on a copy of its build.
  • The files each agent wrote are kept byte for byte, with checksums, beside this lesson’s examples. The checker restores them, scans them with the lesson’s own code, counts each ticket’s diff, and starts a fresh server for every question.
  • A “type that holds a person” is any type or interface with an email field, since every build addresses people by email.
  • Two ticket agents, working at the same time, both wrote a server log to the same file in the session’s scratch folder, outside their own, so the later one replaced the earlier. Each stopped its own server by process id, and each build’s behavior is what the checker measured afterward, not what its agent reported.
  • This is one sample of each prompt, not a measurement of a model.

09 / Hold it there

Check what the record means, not only where it is imported.

A shared model comes back one convenient import at a time: a context needs a person, and one is already defined. Three kinds of check keep the contexts apart.

  1. The language’s own door

    Go can keep a context’s types to itself: a package under claims/internal/ “can be imported only by code in the directory tree rooted at” claims/ (Go 1.4 release notes). TypeScript inside one package has no such door; any file can import any type.

  2. An import rule an agent cannot argue with

    Two rules in the shape Enforcement layer runs: no shared folder of customers, users, or people, and contexts meet at each other’s index.ts. We ran them with dependency-cruiser 18.3.0 over both insurers. The one-model insurer fails with five errors, one per importer of customers/; the insurer with a model per context passes.

    .dependency-cruiser.cjs
    // The insurer's context rules, in the shape Enforcement layer runs.
    // Run from a layout's root: repo/one-model or repo/contexts.
    module.exports = {
    	forbidden: [
    		{
    			name: 'no-shared-person-model',
    			comment:
    				'Each context models the people it deals with. A folder of shared customers, users, or people is one model for every meaning.',
    			severity: 'error',
    			from: { pathNot: '^(customers|people|persons|users)/' },
    			to: { path: '^(customers|people|persons|users)/' }
    		},
    		{
    			name: 'contexts-meet-at-index',
    			comment: "Another context's index.ts is the only way in.",
    			severity: 'error',
    			from: { path: '^([^/]+)/' },
    			to: { path: '^[^/]+/(?!index\\.ts$)', pathNot: '^$1/' }
    		}
    	],
    	options: { tsPreCompilationDeps: true }
    };
    
  3. A check on what the record means

    An import rule matches folder names. Move Customer into a folder called common/ and it passes. The placeholder report does not care where the type lives: it reads what each context has to invent to build one. Run it on every change, and a new context filling a shared type with blanks is a failing test, not a letter to Ben.

    contexts.spec.ts
    // contexts.spec.ts
    import { expect, it } from 'vitest';
    import { placeholderReport, termUses } from './bounded';
    import { readRepo } from './read-repo';
    
    it('keeps "customer" out of the contexts that do not mean it', () => {
    	const files = readRepo('src');
    	expect(Object.keys(termUses(files, 'customer'))).toEqual([]);
    	expect(placeholderReport(files, 'customer', 'Customer')).toEqual([]);
    });

This idea runs in your server and in the schemas between services. In a UI, the same question shows up as one User type imported by every screen, but the screens only read it; the harm comes from the code that writes records, so this lesson has no frontend row.

10 / Make the call

Split the model when the word splits.

Keep one model while the word means one thing: one kind of person, one team, and no fields that are empty for most of the records. Most small apps are there, and a context map for them is ceremony.

Split when a part of the code has to fill fields it does not understand, when a job needs a flag to tell kinds of records apart, or when a second team starts adding fields for its own reasons. Those are the moments the word has already split; the code is catching up.

Take it with you

Explain it without saying “bounded context”: “Quoting, Policies, Claims, and Billing each keep their own record of the people they deal with, with only what they use. When one hands off to another, it passes what the other needs.” Then open the most-imported type in your own codebase and count its optional fields. Who fills each one with nothing?

Paste into your next prompt, and fill in the blanks

Describe each job by the record it works on: <"every active policy">,
not <"every customer">.
When one word starts to mean different people, such as <customer>, model one
bounded context per <part of the business>, each with its own type holding
only the fields it uses. No shared <Customer> type or table.
Where contexts meet, translate: <accepting a quote hands Policies and Billing
what they need>. Write the contexts and what crosses between them in
CONTEXTS.md, and fail the build when a context fills a shared type with
placeholders.
Connections to follow nextRelated lessons

Take the insurer into your editor. Add a named driver, someone Ana lets drive her car, and decide which context owns them before you write a type.

Back to architecture →