In sales, a property is a prospect with an owner and a deal stage. In inspections, it is a physical structure with units, access instructions and a history. In billing, it is a line item with a rate and a contract. Three departments, one word, three genuinely different things — and a single Property model attempting to serve all three, with forty columns of which each consumer uses a third.
That shared model is the most common structural problem I find in enterprise codebases. It grows without limit, every change risks an unrelated team, and nobody can delete a column because somebody somewhere might be reading it.
Listen for where the language changes
Bounded contexts are discovered by listening, not designed on a whiteboard. When two groups use the same word for different concepts, or different words for the same one, you are standing on a boundary. When a conversation requires constantly qualifying — 'the billing property, not the inspection property' — the boundary has already asserted itself and your model is ignoring it.
Each context gets its own model, containing only what that context needs, named the way that context speaks. The models are related by identity, not by inheritance or a shared table: the same real-world property appears as three objects with a common identifier and no shared structure.
# Inspections context: what an inspector needs, in an inspector's language.
@dataclass
class Site:
id: PropertyId # shared identity across contexts
units: list[Unit]
access_notes: str
last_inspected: date | None
# Billing context: same real-world thing, entirely different model.
@dataclass
class BillableProperty:
id: PropertyId
contract_id: ContractId
rate: Money
billing_cycle: Cycle
# The translation is explicit and lives at the boundary, so neither context
# has to know the other's vocabulary. This is the anti-corruption layer.
class BillingTranslator:
def to_billing(self, site: Site, contract: Contract) -> BillableProperty:
return BillableProperty(
id=site.id,
contract_id=contract.id,
rate=contract.rate_for(unit_count=len(site.units)),
billing_cycle=contract.cycle,
)The anti-corruption layer is the part that pays
Wherever your context meets one you do not control — a legacy system, a vendor API, another team's service — put a translation layer at the boundary and let your domain speak only its own language inside it. Without it, the external model leaks inward, and within a year your code is shaped by a vendor's design decisions from 2014.
- One team should own each context. A boundary that two teams both change is a boundary in name only.
- Duplication across contexts is correct, not a smell. A property address in two contexts serves two purposes and may diverge legitimately.
- Integrate by publishing events with explicit contracts, not by sharing tables. A shared database makes the boundary decorative.
- Write the translation explicitly, in code you can read. Automatic mapping hides the moment the two models diverge.
- Map the contexts and their relationships on one page, and keep it current. It is the most useful architecture document most teams do not have.
The same word meaning two things is not a naming problem to resolve. It is a boundary the business already has, and your schema is arguing with it.
The pragmatic caution is that this is worth doing where the domain is genuinely complex and contested, and not worth it where it is not. A CRUD-shaped administrative area does not need a bounded context with a translation layer; it needs a table and a form. Reserve the modelling effort for the parts of the business where the language is rich and the rules are real, because that is where a shared model does actual damage.