Skip to content

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.

KeywordShapeWhat it admits to
partnershipmutual, written with <->Partnership — Mutual dependence, and a real coordination cost.
shared-kernelmutual, written with <->Shared kernel — That neither side can be made downstream of the other without lying.
customer-supplierdirected, written with ->Customer/supplier — That the downstream team has real negotiating power.
conformistdirected, written with ->Conformist — Powerlessness, honestly. That is the value of the name.
anticorruption-layerdirected, written with ->Anticorruption layer — That the upstream model is unsuitable and the downstream one is worth protecting.
open-host-servicedirected, written with ->Open host service — That there are enough consumers to make one interface cheaper than N.
published-languagedirected, written with ->Published language — That the interchange format is an asset with more than two readers.
separate-waysmutual, written with <->Separate ways — That integration costs more than duplication.
big-ball-of-mudeitherBig 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

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
    }
  }
}

Back to the mapper·the model editor