The Word 'Account' Is Breaking Your Schema: One Model Cannot Serve Two Languages
A retail platform has a table called accounts. The identity team reads it as login credentials: email, password hash, MFA devices — an account is created at signup and deleted when the user closes it. The billing team reads the same table as a contract: credit limit, payment terms, tax status — an account is opened after a credit check and can never be deleted, only settled. Every sprint they negotiate patches: identity wants status to mean ACTIVE/DELETED, billing wants it to mean PENDING/OPEN/CHARGEDOFF. Both are right inside their own language, and both are wrong about the other's.
This is the failure bounded context names. A context defines where one model of a ubiquitous language applies consistently — a language that domain experts and developers use identically, with each term carrying exactly one meaning and one set of invariants inside that boundary. The bounding is the point: the language does not have to be globally unique, it has to be locally unambiguous. Account in identity and Account in billing are homonyms — same spelling, different words — and forcing them into one shared table makes every rule conditional on "which team is asking?"
The practical test for whether you are looking at two contexts rather than one context with two views: do the invariants conflict?
| Concept | Identity's meaning | Billing's meaning |
|---|---|---|
| Account created | Signup completes (no credentials → no account) | Credit approved (limits set before any use) |
| Account deleted | User invokes right-to-erasure | Impossible — invoices must keep referencing it |
| Account status | ACTIVE, SUSPENDED, DELETED | PENDING, OPEN, IN_ARREARS, SETTLED |
| Key invariants | Unique email, verified factor | Tax jurisdiction, credit limit ≥ exposure |
When the same field name must hold two different legal values, you do not have one entity with two modules — you have two models pretending to be one. The fix is to let each context own its own Account, connected by explicit translation: identity's AccountId and billing's AccountId are different types that happen to share a suffix, correlated through a mapping the teams agree on (identity_user_42 ↔ billing_account_7), not through a shared primary key everyone treats as sacred.
Why not just add more columns and flags? Because a shared model accumulates every team's exceptions until no one can state the invariants plainly — "is a DELETED account billable?" becomes a three-team thread instead of a compile error. Each context's model stays small enough that a new engineer can recite its rules, and cross-context questions are answered at the boundary where the translation lives, with an owner.
Interviewers use the homonym example constantly: "how do you model Account used by both auth and billing?" The strong answer: two contexts, two models, an explicit relationship on the context map — and then, importantly, the concession that within a single context you still want one model, not one class per use case. Bounded contexts are not an argument for fragmentation; they are an argument against a lowest-common-denominator model. The next step is learning where to draw the lines and how teams agree once they exist, which is exactly the job of the Boundaries and Module Coupling discussion — but the map of relationships comes first.
Drawing the Line: Event Storming, Team Shape, and the Three Signals of a Context Boundary
Knowing that contexts exist is not knowing where yours are. Drawing them is detective work, and three signals give you the map.
Signal one: language breaks. Wherever two people must translate when they talk — "by customer do you mean the person who ordered or the company that pays?" — you are standing on a boundary. Signal two: data changes for reasons owned by different people. If the same record is updated by signup flows and by dunning workflows on different schedules with different approval rules, two lifecycles are sharing a row. Signal three: the team graph. Conway's law is not destiny, but it is evidence: if two groups never share a standup and never co-own a deploy, the model they maintain together is already drifting into two.
A lightweight way to collect the signals is an event-storming session: write domain events on sticky notes in temporal order (OrderPlaced, PaymentAuthorized, ShipmentDispatched, InvoiceIssued), group them by the language that describes them, and mark where the vocabulary changes. The orange events about commerce and the events about invoicing rarely share a noun; the grouping is the candidate boundary. Validate each candidate against the invariant test from the previous chapter: inside one group, can every rule be stated without asking who is asking?
Once candidates exist, decide the relationships. The context map is the catalog of these relationships, and four patterns cover most real pairs:
| Relationship | Shape | Use when | Cost |
|---|---|---|---|
| Customer–Supplier | Upstream exposes what downstream needs; downstream's needs shape the API | Downstream has leverage or real requirements | Negotiation overhead both ways |
| Shared Kernel | Two contexts share a small, jointly owned model subset with change protocol | Deeply intertwined, same team, tiny surface | Any change needs both owners' sign-off |
| Conformist | Downstream adopts the upstream's model as-is | Upstream is dominant or indifferent; your translation cost would exceed adoption cost | Their quirks become your quirks |
| Anti-Corruption Layer | Downstream translates upstream's model at the boundary into its own language | Upstream's language would pollute your invariants (legacy or other company's API) | Translation code to write and keep current |
Choosing is a power-and-coupling question, not a purity question. When your fraud team consumes the payments provider's webhooks, conformist acceptance is often correct — you do not get to redesign Stripe. When your clean billing context reads a messy legacy accounts table, an anti-corruption layer is mandatory, because otherwise the legacy model's conditionals reappear in your code with new names. And a shared kernel between two contexts of the same team — say a Money value object and a ProductRef — is healthy; between two teams it becomes a joint-change tax that should be demoted to translation the moment a second team appears.
The discipline that makes any of this durable: name the contexts on the diagram, name the relationships, and revisit both when the team changes. Contexts drawn for the original org chart rot silently when the org restructures — the language follows the people. In an interview, walking through this sequence — detect language breaks, validate with the invariant test, pick a relationship by power and coupling — demonstrates the method rather than the vocabulary, which is the difference interviewers are screening for.
Keeping Foreign Models Out: The Anti-Corruption Layer in Practice
The billing context must consume identity's user data. The lazy version imports identity's UserDto everywhere: user.getAuthStatus().equals("ACTIVE") appears in invoicing logic, a password-lockout field shows up in a billing report, and when identity renames authStatus to lifecycleState, forty billing files change. Six months later billing cannot be tested without identity's test fixtures, and a proposed identity refactor is blocked by a team that does not own it. That is not integration; that is colonization.
An anti-corruption layer is the explicit translation boundary that prevents it. Concretely, it is a small set of types and functions owned by the consumer that converts the foreign model into the local language on entry:
// billing's boundary package — the ONLY file that knows identity's payload
class IdentityDirectory(private val http: IdentityClient) {
suspend fun billableParty(userId: UserId): BillableParty {
val u = http.getUser(userId.value()) // identity's shape
return BillableParty(
id = BillablePartyId(u.id),
displayName = u.profile.displayName,
isReachable = u.lifecycle.state == "ACTIVE" && !u.lifecycle.lockedOut,
// unknown states fail loudly — never default to billable
)
}
}
Three properties make this more than a wrapper class. The translation is one-directional and total: it maps every state identity can emit to a billing concept, or throws on an unknown one — a new upstream state becomes a visible error in your tests instead of silently wrong invoices. The foreign vocabulary stops at the package boundary: grep "lifecycle.state" inside billing returns one file. And the local type has fewer fields than the foreign one, by design — billing asked for what billing needs, so identity's future experiments do not ripple inward.
The cost is honest and should be stated in review: you write and maintain a mapping, and upstream schema changes require editing it. That is precisely the point — the change lands in one translation file with a failing test, rather than across your domain. The calculus favors the ACL whenever the upstream model is large, unstable, or owned by someone else, and disfavors it for a stable sibling context with a tiny surface (there, a conformist read or a shared kernel subset may be cheaper).
In practice the ACL gets one more teammate: a contract test against the upstream's published schema (a recorded payload fixture, or a Pact file) that runs on every merge. When identity deprecates lifecycle.state, that test breaks in billing's CI before any runtime traffic does, and the fix is confined to IdentityDirectory. Without it, the ACL works in staging and fails in production on the first upstream deploy — translation code you do not test is just a new place for the foreign model to leak through.
The map makes the whole system legible. A one-page diagram — boxes for contexts, labeled arrows for relationships (billing —conformist→ payments provider, billing —ACL← identity) — answers questions that used to require archaeology: which contexts may this module import from? (none; only its own), who translates? (the consumer, in its boundary package), what happens when upstream changes a field? (the ACL test fails first). When an interviewer asks "how do you integrate with a legacy system without inheriting its mess?", this is the answer: not by wrapping every call, but by making the translation a first-class, owned, tested component — the same instinct that later shows up as a strangler facade during migration. Finish by naming the tool that validates boundaries in code: the ACL package is importable only from the boundary, and a dependency rule enforces it.
Quiz
1. Identity wants Account.status = ACTIVE/DELETED; billing wants PENDING/OPEN/IN_ARREARS on the same table. What does this conflict indicate?
2. Your fraud team must consume a third-party payments provider's webhook payload. Which context-map relationship fits best?
3. What is the correct scope of a ubiquitous language?
4. Which observation best signals that you are standing on a bounded-context boundary?
Flashcards
Question
What does a bounded context bound, and what is the invariant test for splitting one?
Click to reveal answer
Answer
It bounds where one model of the ubiquitous language applies — one meaning per term, one set of invariants. Split when the invariants conflict: if the same field must hold two different legal values depending on which team asks (status = ACTIVE/DELETED vs PENDING/OPEN), you have two contexts sharing a row, not one entity with two views.
Question
Name the three signals that reveal a context boundary in an existing system.
Click to reveal answer
Answer
Language breaks (people must translate: 'customer = buyer or payer?'), data with different lifecycles (one row updated by signup flows and dunning workflows under different rules), and team shape (groups that never co-own a deploy letting one shared model drift). Event storming maps these by grouping domain events where the vocabulary changes.
Question
Compare Shared Kernel and Anti-Corruption Layer in one line each.
Click to reveal answer
Answer
Shared Kernel: a small jointly owned model subset with a change protocol — healthy only while one team owns both sides; every change needs dual sign-off. Anti-Corruption Layer: the consumer translates the foreign model into its own language at the boundary — costs mapping code but confines upstream churn and quirks to one package.
Question
What three properties make an anti-corruption layer effective?
Click to reveal answer
Answer
One-directional and total translation (every upstream state maps to a local concept or throws — unknown states fail loudly instead of defaulting); foreign vocabulary stops at one boundary package (grep shows a single file); the local type is deliberately smaller than the foreign one — you expose your consumer's needs, not the provider's full shape.
Question
When is Conformist the right relationship rather than an ACL?
Click to reveal answer
Answer
When the upstream is dominant or indifferent (you cannot shape its API), its model is stable enough for your needs, and the cost of writing and maintaining translation would exceed the cost of adopting their quirks — e.g. consuming Stripe's webhook fields as-is. The moment their quirks start violating your invariants, promote to ACL.
Revision Notes
Key Takeaways
- 1. A bounded context is where one language is unambiguous; the homonym 'Account' in identity and billing proves one shared table cannot carry two sets of invariants
- 2. The invariant test — do legal values conflict? — separates a real boundary from a module split, and it is the fastest diagnostic in a modeling review
- 3. Context-map relationships are chosen by power and coupling: conformist when you have no leverage, ACL when their language would pollute your rules, shared kernel only under single-team ownership
- 4. An ACL is effective when it is total (unknown upstream state throws), singular (one boundary file), and reductive (local type exposes fewer fields)
- 5. Context maps rot when the org changes; label the diagram and revisit boundaries at reorg time, because the language follows the people
Interview Tips
- • 'How do you model an entity used by two teams?' — two contexts, two models, explicit mapping; explain why a mega-entity makes every rule conditional, then concede that within one context you still keep one model
- • 'What is a ubiquitous language?' — shared terms between experts and code that hold one meaning inside a context; give the customer example (buyer vs paying company) to show you have seen the failure
- • 'Legacy system integration — how do you avoid inheriting the mess?' — anti-corruption layer: translation owned by the consumer, unknown states fail loudly, foreign vocabulary confined to one package
- • 'Where do you draw context boundaries?' — walk the method: language breaks, lifecycle conflicts, team graph, validate with the invariant test; mention event storming as the collection technique
Cheat Sheet
Bounded Contexts Cheat Sheet
Definition: a boundary within which one model of the ubiquitous language applies — every term has exactly one meaning and one set of invariants. Contexts may reuse words with different meanings; inside a context they may not.
Invariant test (split or not?): same field, conflicting legal values across teams → two contexts. One model must not need 'which team is asking?' conditionals.
Detection signals: language breaks in conversation · one record, two lifecycles (signup vs dunning) · team graph with no shared ownership · event storming groups where vocabulary changes.
Context map relationships:
| Pattern | One-liner |
|---|---|
| Customer–Supplier | downstream's needs shape upstream's API (negotiate) |
| Shared Kernel | tiny jointly owned subset, dual sign-off — one team only |
| Conformist | adopt upstream's model as-is (no leverage, stable upstream) |
| Anti-Corruption Layer | consumer-side translation package; foreign terms stop at the boundary |
| Separate Ways | no integration at all — legitimate outcome |
ACL rules: total mapping (unknown state → throw, never default), one boundary file knows the foreign shape, local type has fewer fields than foreign.
Hygiene: diagram contexts + label arrows; revisit when the org restructures — language follows people.