Powered by AppSignal & Oban Pro

Domain Modeling Made Functional with Choreo

domain_modeling_walkthrough.livemd

Domain Modeling Made Functional with Choreo

Mix.install([
  # {:choreo, "~> 0.11.0"},
  {:choreo, path: Path.expand("../..", __DIR__), force: true},
  {:kino_vizjs, "~> 0.9.0"}
])

Why this Livebook exists

This walkthrough shows how Choreo.Domain can be used as a compact, executable DDD notebook:

  • Strategic DDD — bounded contexts, subdomains, owners, and context-map relationships.
  • Tactical Event Storming — actors, commands, aggregates, events, policies, read models, and external systems.
  • Domain Modeling Made Functional — algebraic types and workflow pipelines inspired by Scott Wlaschin.
  • Scenario storytelling — named scenario paths rendered as normal flowcharts or Mermaid eventmodeling timelines.
  • Semantic auditing — design checks for dangling commands, missing causes, empty invariants, and broken scenarios.

Rendering diagrams: Mermaid output is shown with Choreo.Lab.Siren and Choreo.Lab.Sketch. Graphviz DOT output is shown with Kino.VizJS.

Mermaid eventmodeling requires Mermaid 11.15+. If your Livebook frontend does not support it yet, keep the generated text and paste it into a newer Mermaid renderer.

alias Choreo.Domain
alias Choreo.Domain.Analysis
alias Choreo.Sequence
mermaid_markdown = fn source ->
  fence = String.duplicate("`", 3)
  Kino.Markdown.new(fence <> "markdown\n" <> source <> "\n" <> fence)
end

render_domain = fn domain, opts ->
  mermaid = Domain.to_mermaid(domain, opts)
  dot = Domain.to_dot(domain)

  Kino.Layout.tabs(
    Siren: Choreo.Lab.Siren.new(mermaid),
    Graphviz: Kino.VizJS.render(dot, height: Keyword.get(opts, :height, "900px")),
    Sketch: Choreo.Lab.Sketch.new(mermaid),
    Source: mermaid_markdown.(mermaid)
  )
end

1. Strategic Context Map

Strategic DDD starts by asking where language, ownership, and models change. Those boundaries become Bounded Contexts.

In this order-placement example, we classify contexts by subdomain type and owner:

  • Order Taking — core subdomain, owned by the Checkout Team.
  • Billing — supporting subdomain, owned by the Finance Platform Team.
  • Inventory — supporting subdomain, owned by the Stock Team.
  • Shipping — generic subdomain, owned by the Fulfillment Team.
  • Legacy CRM — upstream legacy source protected by an ACL.
  • Online Shopping Cart — upstream supplier of customer cart intent.
context_map =
  Domain.new()
  |> Domain.add_context(:legacy_crm,
    label: "Legacy CRM System",
    subdomain: :generic,
    owner: "CRM Migration Team",
    description: "Legacy source of customer account data."
  )
  |> Domain.add_context(:shopping_cart,
    label: "Online Shopping Cart",
    subdomain: :supporting,
    owner: "Web Experience Team",
    description: "Captures customer cart intent before checkout."
  )
  |> Domain.add_context(:order_taking,
    label: "Order Taking Context",
    subdomain: :core,
    owner: "Checkout Team",
    description: "Core language for validating, pricing, and accepting orders."
  )
  |> Domain.add_context(:billing,
    label: "Billing Context",
    subdomain: :supporting,
    owner: "Finance Platform Team",
    description: "Invoices and payment authorization."
  )
  |> Domain.add_context(:inventory,
    label: "Inventory Context",
    subdomain: :supporting,
    owner: "Stock Team",
    description: "Stock reservations and availability."
  )
  |> Domain.add_context(:shipping,
    label: "Shipping Context",
    subdomain: :generic,
    owner: "Fulfillment Team",
    description: "Shipping labels, warehouse handoff, and delivery status."
  )
  |> Domain.connect_contexts(:legacy_crm, :order_taking,
    relationship: :acl,
    label: "Customer translation"
  )
  |> Domain.connect_contexts(:shopping_cart, :order_taking,
    relationship: :customer_supplier,
    label: "Submit cart details"
  )
  |> Domain.connect_contexts(:order_taking, :billing,
    relationship: :customer_supplier,
    label: "Invoice requests"
  )
  |> Domain.connect_contexts(:order_taking, :inventory,
    relationship: :shared_kernel,
    label: "SKU and stock vocabulary"
  )
  |> Domain.connect_contexts(:order_taking, :shipping,
    relationship: :published_language,
    label: "Fulfillment events"
  )

render_domain.(context_map, height: "700px")

The model is not only visual. It can also generate a glossary from labels, descriptions, and bounded-context membership.

Kino.Markdown.new(Analysis.ubiquitous_language(context_map))

2. Tactical Event Storming with Semantic Edges

A tactical board explains how a business process changes state:

  1. A Customer initiates Place Order.
  2. The Order Aggregate handles the command and emits Order Placed.
  3. The Payment Policy reacts and requests payment.
  4. The Billing Aggregate emits Payment Received.
  5. The Allocation Policy reserves inventory.
  6. The Inventory Aggregate emits Inventory Reserved.
  7. The Shipment Policy ships goods.
  8. The Shipping Aggregate emits Goods Shipped and notifies the customer.

The v0.11 Domain API lets us state those relationships directly:

  • initiates/4
  • handles/4
  • emits/4
  • triggers/4
  • projects_to/4
  • notifies/4
storming =
  Domain.new()
  # Context cluster boundaries
  |> Domain.add_context_boundary("order_taking", label: "Order Taking Context")
  |> Domain.add_context_boundary("billing", label: "Billing Context")
  |> Domain.add_context_boundary("inventory", label: "Inventory Context")
  |> Domain.add_context_boundary("shipping", label: "Shipping Context")

  # Order Taking
  |> Domain.add_actor(:customer,
    label: "Customer",
    description: "The buyer requesting an order placement."
  )
  |> Domain.add_command(:place_order,
    label: "Place Order",
    cluster: "order_taking",
    description: "Submit cart contents for validation and pricing."
  )
  |> Domain.add_aggregate(:order_agg,
    label: "Order Aggregate",
    cluster: "order_taking",
    description: "Consistency boundary wrapping the accepted order.",
    invariants: [
      "An accepted order must have at least one validated line.",
      "The total amount must equal the sum of priced lines.",
      "A paid order cannot be repriced without compensation."
    ]
  )
  |> Domain.add_event(:order_placed,
    label: "Order Placed",
    cluster: "order_taking",
    description: "Emitted when an order is accepted by the core domain."
  )
  |> Domain.add_read_model(:order_status,
    label: "Order Status View",
    cluster: "order_taking",
    description: "Customer-facing projection of the order lifecycle."
  )

  # Billing
  |> Domain.add_policy(:payment_saga,
    label: "Payment Policy",
    cluster: "billing",
    description: "Requests payment after an order is placed."
  )
  |> Domain.add_command(:request_payment,
    label: "Request Payment",
    cluster: "billing",
    description: "Ask Billing to authorize and capture payment."
  )
  |> Domain.add_aggregate(:billing_agg,
    label: "Billing Aggregate",
    cluster: "billing",
    description: "Protects invoice and payment state.",
    invariants: [
      "A captured payment must reference an issued invoice.",
      "A payment can be captured only once."
    ]
  )
  |> Domain.add_event(:payment_received,
    label: "Payment Received",
    cluster: "billing",
    description: "Emitted after successful payment capture."
  )

  # Inventory
  |> Domain.add_policy(:allocation_saga,
    label: "Allocation Policy",
    cluster: "inventory",
    description: "Reserves stock after payment succeeds."
  )
  |> Domain.add_command(:reserve_inventory,
    label: "Reserve Inventory",
    cluster: "inventory",
    description: "Reserve available stock for this order."
  )
  |> Domain.add_aggregate(:inventory_agg,
    label: "Inventory Aggregate",
    cluster: "inventory",
    description: "Protects available and reserved stock counts.",
    invariants: [
      "Reserved quantity cannot exceed available quantity.",
      "Stock reservations are idempotent by order ID."
    ]
  )
  |> Domain.add_event(:inventory_reserved,
    label: "Inventory Reserved",
    cluster: "inventory",
    description: "Emitted when stock is allocated for fulfillment."
  )

  # Shipping
  |> Domain.add_policy(:shipment_saga,
    label: "Shipment Policy",
    cluster: "shipping",
    description: "Initiates dispatch after inventory is reserved."
  )
  |> Domain.add_command(:ship_goods,
    label: "Ship Goods",
    cluster: "shipping",
    description: "Ask Shipping to create labels and dispatch goods."
  )
  |> Domain.add_aggregate(:shipping_agg,
    label: "Shipping Aggregate",
    cluster: "shipping",
    description: "Protects shipment lifecycle state.",
    invariants: [
      "A shipment cannot be dispatched before inventory is reserved.",
      "A shipment must have a delivery address."
    ]
  )
  |> Domain.add_event(:goods_shipped,
    label: "Goods Shipped",
    cluster: "shipping",
    description: "Emitted when the package leaves the warehouse."
  )

  # Semantic Event Storming edges
  |> Domain.initiates(:customer, :place_order)
  |> Domain.handles(:place_order, :order_agg)
  |> Domain.emits(:order_agg, :order_placed)
  |> Domain.projects_to(:order_placed, :order_status)
  |> Domain.triggers(:order_placed, :payment_saga)
  |> Domain.triggers(:payment_saga, :request_payment)
  |> Domain.handles(:request_payment, :billing_agg)
  |> Domain.emits(:billing_agg, :payment_received)
  |> Domain.projects_to(:payment_received, :order_status)
  |> Domain.triggers(:payment_received, :allocation_saga)
  |> Domain.triggers(:allocation_saga, :reserve_inventory)
  |> Domain.handles(:reserve_inventory, :inventory_agg)
  |> Domain.emits(:inventory_agg, :inventory_reserved)
  |> Domain.projects_to(:inventory_reserved, :order_status)
  |> Domain.triggers(:inventory_reserved, :shipment_saga)
  |> Domain.triggers(:shipment_saga, :ship_goods)
  |> Domain.handles(:ship_goods, :shipping_agg)
  |> Domain.emits(:shipping_agg, :goods_shipped)
  |> Domain.projects_to(:goods_shipped, :order_status)
  |> Domain.notifies(:goods_shipped, :customer)
  |> Domain.add_scenario(:happy_path,
    label: "Happy path: order to shipment",
    description: "The complete successful order placement, payment, inventory, and shipment path.",
    path: [
      :customer,
      :place_order,
      :order_agg,
      :order_placed,
      :payment_saga,
      :request_payment,
      :billing_agg,
      :payment_received,
      :allocation_saga,
      :reserve_inventory,
      :inventory_agg,
      :inventory_reserved,
      :shipment_saga,
      :ship_goods,
      :shipping_agg,
      :goods_shipped,
      :customer
    ]
  )
  |> Domain.add_scenario(:payment_path,
    label: "Payment path",
    description: "A shorter scenario showing order acceptance through payment capture.",
    path: [
      :customer,
      :place_order,
      :order_agg,
      :order_placed,
      :payment_saga,
      :request_payment,
      :billing_agg,
      :payment_received
    ]
  )

render_domain.(storming, height: "1700px")

3. Semantic Audit

The same model can be checked for basic Event Storming and DDD quality issues.

audit = Analysis.validate(storming)

if audit == [] do
  Kino.Markdown.new("✅ **Domain audit passed.** Commands, events, policies, aggregates, scenarios, and metadata look coherent.")
else
  audit
  |> Enum.map(fn {severity, message} -> "* `#{severity}` — #{message}" end)
  |> Enum.join("\n")
  |> Kino.Markdown.new()
end

Try breaking the model mentally:

  • a command with no aggregate/workflow target;
  • an event with no aggregate/workflow/external-system cause;
  • a policy that does not trigger a command;
  • an aggregate with no documented invariants;
  • a scenario path that references missing or disconnected nodes.

Those are the kinds of checks Choreo.Domain.Analysis is designed to catch early.


4. Scenario Focus

A full Event Storming board is useful, but a business conversation often needs one path. Named scenarios make that repeatable.

Domain.scenarios(storming)
payment_focus = Domain.focus_scenario(storming, :payment_path)

render_domain.(payment_focus, height: "1700px")

You can also trace backwards from a business event to discover all upstream causes.

Domain.causes(storming, :goods_shipped)
|> Enum.sort()

5. Mermaid Event Modeling Timeline

Mermaid’s eventmodeling syntax is timeline-oriented. It is a great companion view for a selected scenario.

Choreo.Domain maps the scenario path as follows:

Choreo node Mermaid Event Modeling frame
:actor ui
:command cmd
:event evt
:read_model rmo
:policy, :workflow, :external_system, :acl pcr
:aggregate skipped — aggregates have no direct Mermaid Event Modeling shape
event_modeling = Domain.to_mermaid(storming, syntax: :event_modeling, scenario: :happy_path)

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(event_modeling, height: "900px"),
  Source: mermaid_markdown.(event_modeling)
)

This is not a replacement for Event Storming. It is a scenario projection: the same model rendered as a temporal story.


6. Domain Modeling Made Functional: Types and Workflows

Scott Wlaschin’s style of domain modeling focuses on making illegal states unrepresentable and modeling workflows as transformations between explicit types.

Here is a compact order pipeline:

UnvalidatedOrder -> ValidateOrder -> ValidatedOrder -> PriceOrder -> PricedOrder -> AcknowledgeOrder -> OrderAcknowledgmentSent -> BillCustomer -> BillRequested

algebraic_pipeline =
  Domain.new()
  |> Domain.add_type(:unvalidated_order,
    label: "Unvalidated Order",
    fields: [
      {:customer_id, :string},
      {:lines, "list of UnvalidatedLine"}
    ],
    description: "Raw order submitted by the customer."
  )
  |> Domain.add_type(:validated_order,
    label: "Validated Order",
    fields: [
      {:customer_info, "CustomerInfo"},
      {:lines, "list of ValidatedLine"}
    ],
    description: "Order whose customer and line items have been validated."
  )
  |> Domain.add_type(:priced_order,
    label: "Priced Order",
    fields: [
      {:customer_info, "CustomerInfo"},
      {:lines, "list of PricedLine"},
      {:total_amount, :money}
    ],
    description: "Validated order with final prices and totals."
  )
  |> Domain.add_type(:order_acknowledgment_sent,
    label: "Order Acknowledgment Sent",
    fields: [
      {:customer_email, :string},
      {:receipt_id, :string}
    ],
    description: "Customer-visible acknowledgment that the order was accepted."
  )
  |> Domain.add_type(:bill_requested,
    label: "Bill Requested",
    fields: [
      {:invoice_id, :string},
      {:total, :money},
      {:payment_terms, [:due_on_receipt, :net_30, :net_60]}
    ],
    description: "Billing request created from an acknowledged order."
  )
  |> Domain.add_workflow(:validate_order,
    label: "Validate Order",
    invariants: ["Invalid customer IDs and empty order lines are rejected."]
  )
  |> Domain.add_workflow(:price_order,
    label: "Price Order",
    invariants: ["Every accepted line receives a price before totals are calculated."]
  )
  |> Domain.add_workflow(:acknowledge_order,
    label: "Acknowledge Order",
    invariants: ["Acknowledgment is sent once per accepted order."]
  )
  |> Domain.add_workflow(:bill_customer,
    label: "Bill Customer",
    invariants: ["Billing receives only priced and acknowledged orders."]
  )
  |> Domain.connect(:unvalidated_order, :validate_order, label: "input")
  |> Domain.connect(:validate_order, :validated_order, label: "ok")
  |> Domain.connect(:validated_order, :price_order, label: "input")
  |> Domain.connect(:price_order, :priced_order, label: "ok")
  |> Domain.connect(:priced_order, :acknowledge_order, label: "input")
  |> Domain.connect(:acknowledge_order, :order_acknowledgment_sent, label: "sent")
  |> Domain.connect(:order_acknowledgment_sent, :bill_customer, label: "input")
  |> Domain.connect(:bill_customer, :bill_requested, label: "requested")

Kino.Layout.tabs(
  ClassDiagram: Choreo.Lab.Siren.new(Domain.to_mermaid(algebraic_pipeline, syntax: :class_diagram)),
  ERD: Choreo.Lab.Siren.new(Domain.to_mermaid(algebraic_pipeline, syntax: :erd)),
  Graphviz: Kino.VizJS.render(Domain.to_dot(algebraic_pipeline), height: "1100px")
)

7. Aggregate Detail: Invariants, Entities, and Value Objects

Aggregates matter because they protect business invariants. The diagram below shows the internal shape of the Order aggregate.

aggregate_detail =
  Domain.new()
  |> Domain.add_aggregate(:order_root,
    label: "Order (Aggregate Root)",
    fields: [
      {:id, :uuid},
      {:status, [:placed, :paid, :allocated, :shipped]},
      {:customer_info, "CustomerInfo"},
      {:total_price, :money}
    ],
    invariants: [
      "Order status transitions must follow the lifecycle.",
      "Total price equals the sum of order-line prices.",
      "Customer information must be valid before payment."
    ]
  )
  |> Domain.add_type(:order_line,
    label: "OrderLine (Entity)",
    fields: [
      {:id, :uuid},
      {:product_id, :string},
      {:quantity, :integer},
      {:price, :money}
    ]
  )
  |> Domain.add_type(:customer_info,
    label: "CustomerInfo (Value Object)",
    fields: [
      {:customer_id, :uuid},
      {:name, :string},
      {:email, :string},
      {:shipping_address, :string}
    ]
  )
  |> Domain.connect(:order_root, :customer_info, label: "contains")
  |> Domain.connect(:order_root, :order_line, label: "has 1..*")

Kino.Layout.tabs(
  ClassDiagram: Choreo.Lab.Siren.new(Domain.to_mermaid(aggregate_detail, syntax: :class_diagram)),
  Graphviz: Kino.VizJS.render(Domain.to_dot(aggregate_detail), height: "650px"),
  Audit: Analysis.validate(aggregate_detail) |> inspect(pretty: true) |> Kino.Text.new()
)

8. Sequence Timeline for One Integration Slice

Domain diagrams explain business semantics. Sequence diagrams are still useful when you want to show runtime collaboration between systems.

seq =
  Sequence.new()
  |> Sequence.add_actor(:customer, label: "Customer")
  |> Sequence.add_participant(:order_service, label: "Order Service")
  |> Sequence.add_participant(:billing_service, label: "Billing Service")
  |> Sequence.add_participant(:stripe, label: "Stripe Gateway")
  |> Sequence.message(:customer, :order_service, label: "Submit Order")
  |> Sequence.activate(:order_service)
  |> Sequence.message(:order_service, :billing_service, label: "Request Payment")
  |> Sequence.activate(:billing_service)
  |> Sequence.message(:billing_service, :stripe, label: "Authorize Charge")
  |> Sequence.return(:stripe, :billing_service, label: "Token Approved")
  |> Sequence.return(:billing_service, :order_service, label: "Payment Received")
  |> Sequence.deactivate(:billing_service)
  |> Sequence.return(:order_service, :customer, label: "Order Accepted")
  |> Sequence.deactivate(:order_service)

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(Sequence.to_mermaid(seq)),
  Graphviz: Kino.VizJS.render(Sequence.to_dot(seq)),
  Sketch: Choreo.Lab.Sketch.new(Sequence.to_mermaid(seq))
)

9. Final Domain Pack

A good domain notebook should leave you with several reusable artifacts:

  • a context map for strategy and team ownership;
  • an Event Storming board for tactical flow;
  • an audit report for model quality;
  • a glossary for ubiquitous language;
  • scenario timelines for product conversations;
  • type/workflow diagrams for functional design.
Kino.Layout.tabs(
  "Context glossary": Kino.Markdown.new(Analysis.ubiquitous_language(context_map)),
  "Tactical glossary": Kino.Markdown.new(Analysis.ubiquitous_language(storming)),
  "Storming audit": Analysis.validate(storming) |> inspect(pretty: true) |> Kino.Text.new(),
  "Event modeling source": mermaid_markdown.(event_modeling)
)