ba-hub · ddd mapper
The .ddd format
Braces rather than indentation, so a file that has been through a chat window or a different editor still parses. // line comments, which survive every edit made in the graph — the mapper splices the text rather than re-serialising a model, which is what makes the file reviewable.
Grammar
File = Map , EOF ;
Map = 'map' , String , '{' , { Domain | Relationship } , '}' ;
Domain = 'domain' , String ,
[ '{' , { Intent | Owner | Subdomain | Context } , '}' ] ;
Subdomain = 'subdomain' , Class , String ,
[ '{' , { Intent | Owner | Context } , '}' ] ;
Context = 'context' , String ,
[ '{' , { Intent | Owner | Language | Aggregate
| Status | Serves } , '}' ] ;
Class = 'core' | 'supporting' | 'generic' ;
Intent = 'intent' , String ; (* at most one *)
Owner = 'owner' , String ; (* at most one *)
Language = 'language' , String , { String } ;
Aggregate = 'aggregate' , String , { String } ;
Status = 'status' , ( 'modelled' | 'drafted' | 'unmodelled' ) ;
Serves = 'serves' , String ; (* the straddle; repeatable *)
Relationship = String , Arrow , String , ':' , Patterns ,
[ '{' , { Exchange | Because } , '}' ] ;
Arrow = '->' | '<->' ;
Patterns = Pattern , [ '/' , Pattern ] ;
Exchange = 'exchange' , String ;
Because = 'because' , String ;Strings
Double-quoted. \" and \\ escape; nothing else does. A literal may span lines, and continuation lines are joined with a single space after leading whitespace is stripped — which is what lets intent and becausehold a paragraph without the file growing 300-column lines.
Names are identities
A context is referred to in a relationship by its name in quotes. There is no separate identifier, because the discipline rests on the claim that the words are the model, and a file where the human name and the machine name can drift teaches the wrong lesson on sight. Two nodes may not share a name.
Direction
-> runs upstream to downstream, where upstream is whoever’s model the other has to accommodate. A downstream context frequently initiates the call, and that is irrelevant here. <-> is mutual, and the parser enforces the pairing: a mutual pattern may not be written with an arrow that asserts an upstream it denies.
Two patterns separated by / put a role on each end —open-host-service / anticorruption-layer is upstream publishing and downstream defending. That is Evans’s own notation, and a single label loses it.
The nine patterns
The column that matters is the last one. Several of these describe the same arrow and differ only in a political fact — customer/supplier and conformist are the same integration, and the difference is whether the downstream team can ask for a change and get it.
| Keyword | Shape | What it admits to |
|---|---|---|
| partnership | mutual, written with <-> | Partnership — Mutual dependence, and a real coordination cost. |
| shared-kernel | mutual, written with <-> | Shared kernel — That neither side can be made downstream of the other without lying. |
| customer-supplier | directed, written with -> | Customer/supplier — That the downstream team has real negotiating power. |
| conformist | directed, written with -> | Conformist — Powerlessness, honestly. That is the value of the name. |
| anticorruption-layer | directed, written with -> | Anticorruption layer — That the upstream model is unsuitable and the downstream one is worth protecting. |
| open-host-service | directed, written with -> | Open host service — That there are enough consumers to make one interface cheaper than N. |
| published-language | directed, written with -> | Published language — That the interchange format is an asset with more than two readers. |
| separate-ways | mutual, written with <-> | Separate ways — That integration costs more than duplication. |
| big-ball-of-mud | either | Big ball of mud — Reality. Not chosen — recorded, so everything around it can be defended. |
A complete file
The sample the mapper opens with: the seed catalog from the business analysis hub, with nine subdomains, nine bounded contexts and eleven relationships.
// The seed catalog from ba-portal, in the format ba-ddd-mapper-mapper authors.
//
// This file is the same model as ba-portal's src/lib/catalog.ts and
// src/lib/landscapes.ts, written once instead of twice. That is the point of
// the component: the catalog stops being TypeScript somebody maintains by hand.
map "Personal and commercial insurance" {
domain "Personal and commercial insurance" {
intent "Underwrite risk, price it, collect premium for it, and pay what is
owed when it materialises. Everything else in the estate exists to
make one of those four things possible."
// ---- core: the reason the business wins -------------------------------
subdomain core "Underwriting" {
intent "Deciding which risks to accept and on what terms."
owner "Head of underwriting"
context "Risk appetite" {
intent "Where a submission is judged against what the carrier is
currently willing to take on. A Risk here is a described
exposure under consideration."
language "Submission" "Risk" "Appetite rule" "Referral" "Decline reason"
aggregate "Submission" "AppetiteRuleSet" "Referral"
owner "Head of underwriting"
}
}
subdomain core "Pricing and rating" {
intent "Turning an accepted risk into a number."
owner "Chief actuary"
context "Rating" {
intent "Where a Risk becomes a premium. The same word arrives from
Risk appetite meaning a judgement; here it means a vector of
rating factors."
language "Rating factor" "Rate table" "Base premium" "Loading"
"Technical price"
aggregate "RatingRequest" "RateTableVersion"
owner "Chief actuary"
}
context "Quotation" {
intent "Where a technical price becomes an offer with a validity
period. A price is a calculation; a quote is a commitment
with an expiry."
language "Quote" "Offer" "Validity period" "Condition" "Lapse"
aggregate "Quote"
owner "Head of underwriting"
}
}
subdomain core "Claims handling" {
intent "Establishing what is owed and paying it."
owner "Claims director"
context "Claims" {
intent "From first notification to settlement or repudiation.
\"Policy\" here is a snapshot of cover as it stood on the
date of loss, not the live policy."
language "Notification" "Claim" "Reserve" "Settlement" "Repudiation"
"Cover snapshot"
aggregate "Claim" "Reserve" "Payment"
owner "Claims director"
}
}
// ---- supporting: necessary, ours, not a differentiator -----------------
subdomain supporting "Policy administration" {
intent "Keeping a policy correct through its life."
owner "Operations manager, policy services"
context "Policy lifecycle" {
intent "The policy as a thing with a history. Every change is an
event with an effective date, because \"what did the cover
say on 3 March\" gets asked in court."
language "Policy" "Inception" "Endorsement" "Effective date" "Renewal"
"Cancellation"
aggregate "Policy" "Endorsement"
owner "Operations manager, policy services"
status drafted
}
}
subdomain supporting "Distribution and party management" {
intent "Who we deal with and through whom, and the commission that follows."
owner "Head of distribution"
context "Party and relationships" {
intent "People and organisations, and the roles they play. The model
is role-based because the same organisation is a broker on
one policy and a policyholder on another."
language "Party" "Role" "Relationship" "Broker" "Policyholder" "Claimant"
aggregate "Party" "PartyRelationship"
owner "Head of distribution"
status drafted
}
}
subdomain supporting "Product definition" {
intent "What can be sold and how it may be assembled."
owner "Product owner, personal lines"
context "Product catalogue" {
intent "Covers, limits, exclusions, wordings, and the compatibility
rules between them. Upstream of nearly everything."
language "Product" "Cover" "Limit" "Exclusion" "Wording"
"Compatibility rule"
aggregate "Product" "WordingVersion"
owner "Product owner, personal lines"
}
}
// ---- generic: bought, wrapped, deliberately not modelled ---------------
subdomain generic "Billing and collections" {
intent "Invoicing, instalments, dunning, reconciliation."
owner "Finance systems manager"
context "Billing" {
intent "Bought whole and wrapped. Nothing inside this boundary is our
model."
language "Invoice" "Instalment" "Dunning" "Write-off"
owner "Finance systems manager"
status unmodelled
}
}
subdomain generic "Document generation" {
intent "Producing the schedule, the certificate and the letter."
owner "Operations manager, policy services"
context "Documents" {
intent "Rendering from a template and a payload, and keeping the
evidence of what was sent."
language "Template" "Rendition" "Dispatch record"
owner "Operations manager, policy services"
status unmodelled
}
}
subdomain generic "Notifications" {
intent "Email, SMS and postal dispatch with delivery evidence."
owner "Operations manager, policy services"
// No context: bought, wrapped, and not worth a boundary of its own yet.
// A subdomain with no context is a warning, not an error — see the
// problems panel.
}
}
// ---- the context map ----------------------------------------------------
//
// Direction is about the model, not the network: upstream is whoever's model
// the other has to accommodate.
"Product catalogue" -> "Rating" : open-host-service {
exchange "Versioned product definitions: covers, limits, exclusions and
their compatibility rules."
because "Consumed by five contexts with no two wanting the same shape. One
published interface is cheaper than five bespoke integrations."
}
"Product catalogue" -> "Quotation" : open-host-service {
exchange "The same versioned definitions, pinned to the version a quote was
made against."
because "A quote has to remain explicable after the product changes."
}
"Risk appetite" -> "Quotation" : customer-supplier {
exchange "An accepted Risk with its terms and any referral conditions."
because "Both teams sit under the same director, so quotation can ask for a
change upstream and get it. That is what makes this
customer/supplier rather than conformist."
}
"Rating" -> "Quotation" : published-language {
exchange "A technical price with its rating factor breakdown and the rate
table version used."
because "Read by quotation, by the regulator, and by the actuarial review.
A published schema is the only version that survives three
audiences."
}
"Quotation" -> "Policy lifecycle" : customer-supplier {
exchange "An accepted offer, which becomes an inception."
because "The handover where a commitment becomes an obligation."
}
"Policy lifecycle" -> "Claims" : open-host-service / anticorruption-layer {
exchange "A cover snapshot as at the date of loss — deliberately not the
live policy."
because "Claims must never see a policy change made after the loss. The
layer exists to make that impossible rather than unlikely."
}
"Party and relationships" <-> "Policy lifecycle" : shared-kernel {
exchange "Party identity and the roles a party plays on a policy."
because "Neither context can be made downstream of the other without a lie.
The most expensive pattern on this map, chosen knowingly."
}
"Policy lifecycle" -> "Billing" : conformist {
exchange "Premium due, instalment plan, and any mid-term adjustment."
because "The billing package will not change its model for us and we will
not fork it. Recorded honestly as conformist rather than dressed
up as customer/supplier."
}
"Claims" -> "Billing" : conformist {
exchange "Settlement payments and recoveries."
because "Same package, same trade-off."
}
"Policy lifecycle" -> "Documents" : anticorruption-layer {
exchange "A render request: template identifier plus a payload in our
language."
because "The vendor template vocabulary is not ours and must not become
ours."
}
"Risk appetite" <-> "Rating" : partnership {
exchange "Appetite signals that change the price, and prices that change the
appetite."
because "Neither side can succeed if the other fails, and the two models
are changed in the same conversation."
}
}
The .ddm format
One zoom level down: a .ddm file is the inside of a single bounded context named on a map. Same discipline — braces, // comments, quoted strings, and the text as the source of truth — and the same claim about names, except that here they are unique within one model rather than one map.
The file opens by naming that context — context "Rating" { — and the name is the identity in both formats: it is what lets a model be checked against the map without either document holding a pointer into the other. Files written before this statement was called context open with model instead; they are still read, and the editor writes them back under the current spelling the first time you open one.
Grammar
File = Model , EOF ;
Model = 'context' , String , '{' , { Aggregate | Value | Enum } , '}' ;
(* 'model' is the old spelling,
still read, never written *)
Aggregate = 'aggregate' , String ,
'{' , { Intent | Invariant | Entity | Value | Enum } , '}' ;
Entity = [ 'root' ] , 'entity' , String ,
[ '{' , { Id | Attribute | Link } , '}' ] ;
Value = 'value' , String , [ '{' , { Attribute | Link } , '}' ] ;
Enum = 'enum' , String , '{' , String , { String } , '}' ;
Intent = 'intent' , String ; (* at most one *)
Invariant = 'invariant' , String ; (* repeatable *)
Id = 'id' , String ; (* the identity type; entities only *)
Attribute = 'attribute' , String , ':' , String ;
Link = ( 'contains' | 'embeds' | 'references' ) , String ,
[ Multiplicity ] ;
Multiplicity = 'one' | 'optional' | 'many' | 'at-least-one' ; (* default: one *)The three links
contains is composition inside one boundary: the part is created, saved and deleted with the root, and has no life of its own. embeds is a value object or an enumeration, which has no identity and is therefore copied rather than shared.references crosses a boundary by identity — it names another aggregate, never something inside one, and holds its id rather than the thing itself. That last rule is what makes an aggregate a consistency boundary rather than a diagram, and the parser enforces it.
What the parser refuses
- A value object with an
id. Identity is the whole difference between a value and an entity: two values with the same fields are the same value. - A link naming something that is not declared, and a
referencesnaming a member instead of the aggregate that owns it. - Two declarations sharing a name. Two things called
Linein one context is precisely the ubiquitous-language failure this tool exists to surface.
It warns, rather than refuses, about the ones that are usually a smell and occasionally the truth: an aggregate with no root or no invariant, a root with noid, and a member nothing inside the aggregate reaches.
A complete file
The example the model editor loads: the inside of Risk appetite, whose aggregates are the ones the map above declares for it.
// The inside of one bounded context from the map next door.
//
// The aggregates here are the ones "Risk appetite" declares in
// samples/insurance.ddd. Keeping the names identical is the point: the name is
// the identity in both formats, and it is what lets one document be checked
// against the other.
context "Risk appetite" {
// Shared across the aggregates below, so declared at the top rather than
// inside one of them.
value "Money" {
attribute "amount" : "Decimal"
attribute "currency" : "CurrencyCode"
}
enum "SubmissionState" {
"Draft" "Submitted" "Referred" "Declined" "Withdrawn"
}
aggregate "Submission" {
intent "A request to write a risk, judged against what the carrier is
currently willing to take on."
invariant "A submission that has been withdrawn cannot be referred or
declined."
invariant "Every risk item on a submission names a cover the product
catalogue still offers."
root entity "Submission" {
id "SubmissionId"
attribute "receivedAt" : "Instant"
attribute "broker" : "PartyId"
embeds "SubmissionState" one
contains "RiskItem" at-least-one
references "AppetiteRuleSet" one
}
entity "RiskItem" {
id "RiskItemId"
attribute "cover" : "CoverCode"
embeds "Money" one
}
}
aggregate "AppetiteRuleSet" {
intent "What the carrier will and will not take on, as of a date."
invariant "Exactly one version of a rule set is effective on any given
date."
root entity "AppetiteRuleSet" {
id "AppetiteRuleSetId"
attribute "effectiveFrom" : "Date"
contains "AppetiteRule" many
}
entity "AppetiteRule" {
id "AppetiteRuleId"
attribute "cover" : "CoverCode"
attribute "decision" : "Decision"
embeds "Money" optional
}
}
aggregate "Referral" {
intent "A submission an underwriter has to look at, and why."
invariant "A referral is closed by exactly one underwriter decision."
root entity "Referral" {
id "ReferralId"
attribute "raisedAt" : "Instant"
attribute "reason" : "ReferralReason"
references "Submission" one
}
}
}