Domain Modeling Made Functional with Choreo
Mix.install([
{:choreo, "~> 0.14.1"},
# {: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
eventmodelingtimelines. - Semantic auditing — design checks for dangling commands, missing causes, empty invariants, and broken scenarios.
Rendering diagrams: Mermaid output is shown with
Choreo.Lab.SirenandChoreo.Lab.Sketch. Graphviz DOT output is shown withKino.VizJS.Mermaid
eventmodelingrequires Mermaid 11.15+. If your Livebook frontend does not support it yet, keep the generated text and paste it into a newer Mermaid renderer.
Choreo provides two complementary ways to model domains:
- Programmatic Pipe API (
Choreo.Domain) — A stable, pipe-first interface ideal for dynamic builders, code generators, and analysis pipelines. - Lab DSL (
Choreo.Lab.DSL.Domain) — A concise, Livebook-friendly syntax for sketching event storming flows, aggregates, types, workflows, and scenarios.
The introductory example below (Section 1: Strategic Context Map) uses the explicit pipe-first syntax. All subsequent examples throughout this guide demonstrate the Lab DSL.
alias Choreo.Domain
alias Choreo.Domain.Analysis
alias Choreo.Sequence
import Choreo.Lab.DSL.Domain
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, height: Keyword.get(opts, :height, "600px")),
Graphviz: Kino.VizJS.render(dot, height: Keyword.get(opts, :height, "600px"))
)
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:
- A Customer initiates
Place Order. - The Order Aggregate handles the command and emits
Order Placed. - The Payment Policy reacts and requests payment.
- The Billing Aggregate emits
Payment Received. - The Allocation Policy reserves inventory.
- The Inventory Aggregate emits
Inventory Reserved. - The Shipment Policy ships goods.
- The Shipping Aggregate emits
Goods Shippedand notifies the customer.
With the Lab DSL, you can state those relationships directly using semantic edge verbs and modifiers:
initiates/|> initiates(...)handles/|> handles(...)emits/|> emits(...)triggers/|> triggers(...)projects_to/|> projects_to(...)notifies/|> notifies(...)
storming =
domain do
customer =
actor("Customer",
description: "The buyer requesting an order placement."
)
context_boundary "Order Taking Context", id: "order_taking" do
place_order =
command("Place Order",
description: "Submit cart contents for validation and pricing."
)
order_agg =
aggregate("Order Aggregate",
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."
]
)
order_placed =
event("Order Placed",
description: "Emitted when an order is accepted by the core domain."
)
order_status =
read_model("Order Status View",
description: "Customer-facing projection of the order lifecycle."
)
end
context_boundary "Billing Context", id: "billing" do
payment_saga =
policy("Payment Policy",
description: "Requests payment after an order is placed."
)
request_payment =
command("Request Payment",
description: "Ask Billing to authorize and capture payment."
)
billing_agg =
aggregate("Billing Aggregate",
description: "Protects invoice and payment state.",
invariants: [
"A captured payment must reference an issued invoice.",
"A payment can be captured only once."
]
)
payment_received =
event("Payment Received",
description: "Emitted after successful payment capture."
)
end
context_boundary "Inventory Context", id: "inventory" do
allocation_saga =
policy("Allocation Policy",
description: "Reserves stock after payment succeeds."
)
reserve_inventory =
command("Reserve Inventory",
description: "Reserve available stock for this order."
)
inventory_agg =
aggregate("Inventory Aggregate",
description: "Protects available and reserved stock counts.",
invariants: [
"Reserved quantity cannot exceed available quantity.",
"Stock reservations are idempotent by order ID."
]
)
inventory_reserved =
event("Inventory Reserved",
description: "Emitted when stock is allocated for fulfillment."
)
end
context_boundary "Shipping Context", id: "shipping" do
shipment_saga =
policy("Shipment Policy",
description: "Initiates dispatch after inventory is reserved."
)
ship_goods =
command("Ship Goods",
description: "Ask Shipping to create labels and dispatch goods."
)
shipping_agg =
aggregate("Shipping Aggregate",
description: "Protects shipment lifecycle state.",
invariants: [
"A shipment cannot be dispatched before inventory is reserved.",
"A shipment must have a delivery address."
]
)
goods_shipped =
event("Goods Shipped",
description: "Emitted when the package leaves the warehouse."
)
end
# Semantic Event Storming edges
customer ~> place_order |> initiates()
place_order ~> order_agg |> handles()
order_agg ~> order_placed |> emits()
order_placed ~> order_status |> projects_to()
order_placed ~> payment_saga |> triggers()
payment_saga ~> request_payment |> triggers()
request_payment ~> billing_agg |> handles()
billing_agg ~> payment_received |> emits()
payment_received ~> order_status |> projects_to()
payment_received ~> allocation_saga |> triggers()
allocation_saga ~> reserve_inventory |> triggers()
reserve_inventory ~> inventory_agg |> handles()
inventory_agg ~> inventory_reserved |> emits()
inventory_reserved ~> order_status |> projects_to()
inventory_reserved ~> shipment_saga |> triggers()
shipment_saga ~> ship_goods |> triggers()
ship_goods ~> shipping_agg |> handles()
shipping_agg ~> goods_shipped |> emits()
goods_shipped ~> order_status |> projects_to()
goods_shipped ~> customer |> notifies()
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
]
)
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
]
)
end
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")
)
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 do
unvalidated_order =
type("Unvalidated Order",
fields: [
{:customer_id, :string},
{:lines, "list of UnvalidatedLine"}
],
description: "Raw order submitted by the customer."
)
validated_order =
type("Validated Order",
fields: [
{:customer_info, "CustomerInfo"},
{:lines, "list of ValidatedLine"}
],
description: "Order whose customer and line items have been validated."
)
priced_order =
type("Priced Order",
fields: [
{:customer_info, "CustomerInfo"},
{:lines, "list of PricedLine"},
{:total_amount, :money}
],
description: "Validated order with final prices and totals."
)
order_acknowledgment_sent =
type("Order Acknowledgment Sent",
fields: [
{:customer_email, :string},
{:receipt_id, :string}
],
description: "Customer-visible acknowledgment that the order was accepted."
)
bill_requested =
type("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."
)
validate_order =
workflow("Validate Order",
invariants: ["Invalid customer IDs and empty order lines are rejected."]
)
price_order =
workflow("Price Order",
invariants: ["Every accepted line receives a price before totals are calculated."]
)
acknowledge_order =
workflow("Acknowledge Order",
invariants: ["Acknowledgment is sent once per accepted order."]
)
bill_customer =
workflow("Bill Customer",
invariants: ["Billing receives only priced and acknowledged orders."]
)
unvalidated_order ~> validate_order |> on("input")
validate_order ~> validated_order |> on("ok")
validated_order ~> price_order |> on("input")
price_order ~> priced_order |> on("ok")
priced_order ~> acknowledge_order |> on("input")
acknowledge_order ~> order_acknowledgment_sent |> on("sent")
order_acknowledgment_sent ~> bill_customer |> on("input")
bill_customer ~> bill_requested |> on("requested")
end
Kino.Layout.tabs(
ClassDiagram:
Choreo.Lab.Siren.new(Domain.to_mermaid(algebraic_pipeline, syntax: :class_diagram), height: "1600px"),
ERD: Choreo.Lab.Siren.new(Domain.to_mermaid(algebraic_pipeline, syntax: :erd), height: "1600px"),
Graphviz: Kino.VizJS.render(Domain.to_dot(algebraic_pipeline), height: "1600px")
)
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 do
order_root =
aggregate("Order (Aggregate Root)",
id: :order_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."
]
)
order_line =
type("OrderLine (Entity)",
id: :order_line,
fields: [
{:id, :uuid},
{:product_id, :string},
{:quantity, :integer},
{:price, :money}
]
)
customer_info =
type("CustomerInfo (Value Object)",
id: :customer_info,
fields: [
{:customer_id, :uuid},
{:name, :string},
{:email, :string},
{:shipping_address, :string}
]
)
order_root ~> customer_info |> on("contains")
order_root ~> order_line |> on("has 1..*")
end
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.
import Choreo.Lab.DSL.Sequence, only: [sequence: 1]
seq =
sequence do
customer = actor("Customer")
order_service = participant("Order Service")
billing_service = participant("Billing Service")
stripe = participant("Stripe Gateway")
customer ~> order_service |> call("Submit Order")
activate(order_service)
order_service ~> billing_service |> call("Request Payment")
activate(billing_service)
billing_service ~> stripe |> call("Authorize Charge")
reply(stripe ~> billing_service, "Token Approved")
reply(billing_service ~> order_service, "Payment Received")
deactivate(billing_service)
reply(order_service ~> customer, "Order Accepted")
deactivate(order_service)
end
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)
)
10. Cheat Sheet
Lab DSL Syntax
| Syntax | Description |
|---|---|
domain do ... end |
Define a domain model |
customer = actor("Customer") |
External actor initiating domain activity |
place_order = command("Place Order") |
Command requesting state change |
order = aggregate("Order", invariants: [...]) |
Consistency boundary protecting invariants |
placed = event("Order Placed") |
Business event emitted by aggregate/system |
saga = policy("Payment Policy") |
Reactive policy / event handler |
view = read_model("Order Status View") |
Read projection / query model |
validate = workflow("Validate Order", ...) |
Functional workflow transformation |
type "Order Info", fields: [...] |
Explicit domain type / value object schema |
system = external_system("Payment Gateway") |
External third-party system |
portal = ui("Web Portal") |
User interface component |
gateway = acl("Legacy CRM ACL") |
Anti-corruption layer boundary |
context_boundary "Checkout" do ... end |
Cluster boundary scoping enclosed nodes |
| customer ~> place_order | > initiates() | Semantic edge: trigger initiates command |
| place_order ~> order_agg | > handles() | Semantic edge: aggregate handles command |
| order_agg ~> order_placed | > emits() | Semantic edge: aggregate emits event |
| order_placed ~> payment_saga | > triggers() | Semantic edge: event triggers policy |
| order_placed ~> order_status | > projects_to() | Semantic edge: event projects to read model |
| goods_shipped ~> customer | > notifies() | Semantic edge: event notifies actor |
| cart ~> acl_node | > translates_via() | Semantic edge: translates through ACL |
| a ~> b | > on("label") | Piped edge label modifier |
| edge a ~> b, "label", opts | Piped or explicit edge with options |
| scenario :happy_path, path: [...] | Named scenario path for focus & timeline projection |
| theme :dark | Visual theme preset |
Programmatic Pipe API & Analysis
| Task / Feature | Command |
|---|---|
| Create Domain Model | Domain.new/1 |
| Add Bounded Context | Domain.add_context/3 (Opts: :label, :subdomain, :owner, :description) |
| Connect Contexts | Domain.connect_contexts/4 (Opts: :relationship, :label) |
| Add Context Boundary | Domain.add_context_boundary/3 (Opts: :label, :parent, :style) |
| Add Domain Nodes | Domain.add_actor/3, add_command/3, add_aggregate/3, add_event/3, add_policy/3, add_read_model/3, add_workflow/3, add_type/3, add_external_system/3, add_acl/3 |
| Add Scenario | Domain.add_scenario/3 (Opts: :label, :description, :path) |
| Connect Nodes | Domain.connect/4 (Opts: :label, :relationship, :type, :cost) |
| Semantic Connectors | Domain.initiates/4, Domain.handles/4, Domain.emits/4, Domain.triggers/4, Domain.projects_to/4, Domain.notifies/4, Domain.translates_via/4 |
| Query Model | Domain.nodes/1, Domain.edges/1, Domain.scenarios/1 |
| Scenario Focus | Domain.focus_scenario/2 (Produces subgraph for selected scenario) |
| Upstream Causes | Domain.causes/2 (Traces upstream causes for a node) |
| Downstream Effects | Domain.downstream/2 (Traces downstream effects from a node) |
| Render Mermaid Flowchart | Domain.to_mermaid/2 (Default syntax: :flowchart) |
| Render Class Diagram / ERD | Domain.to_mermaid/2 (syntax: :class_diagram, syntax: :erd) |
| Render Event Modeling Timeline | Domain.to_mermaid/2 (syntax: :event_modeling, scenario: name) |
| Render DOT Graphviz | Domain.to_dot/2 (Opts: :theme, :rankdir, :highlighted_nodes, :highlighted_edges) |
| Ubiquitous Language | Analysis.ubiquitous_language/1 (Generates Markdown glossary table) |
| Semantic Validation | Analysis.validate/1 (Runs 11 DDD / Event Storming integrity rules) |