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.9"},
  {:choreo, path: Path.expand("~/repos/elixir/choreo"), force: true},
  {:kino_vizjs, "~> 0.8.0"}
])

Section

> Rendering diagrams: Mermaid output is rendered natively in Livebook. Graphviz DOT output is rendered using Kino.VizJS. > To ensure the best first impression, we list Mermaid as the default tab.

alias Choreo.Domain
alias Choreo.Domain.Analysis
alias Choreo.Sequence

1. Strategic Context Mapping

In Strategic Domain-Driven Design (DDD), we divide a large system into distinct partitions called Bounded Contexts. We then map how these contexts communicate.

Below, we model a complete strategic context map for our Order Placement & Fulfillment Stream modeled after the core workflow in Domain Modeling Made Functional:

  • Order Taking (Core Bounded Context)
  • Billing (Supporting Context)
  • Shipping (Generic Context)
  • Inventory (Supporting Context)
  • Legacy CRM (Upstream External System)
  • Online Shopping Cart (Upstream Customer/Supplier)
context_map =
  Domain.new()
  # Add Bounded Context nodes
  |> Domain.add_context(:legacy_crm, label: "Legacy CRM System")
  |> Domain.add_context(:shopping_cart, label: "Online Shopping Cart")
  |> Domain.add_context(:order_taking, label: "Order Taking Context")
  |> Domain.add_context(:billing, label: "Billing Bounded Context")
  |> Domain.add_context(:inventory, label: "Inventory Bounded Context")
  |> Domain.add_context(:shipping, label: "Shipping Bounded Context")

  # Define Strategic DDD context-mapping relationships
  |> Domain.connect_contexts(:legacy_crm, :order_taking,
       relationship: :acl,
       label: "CRM 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: "Stock Levels Sync"
     )
  |> Domain.connect_contexts(:order_taking, :shipping,
       relationship: :conformist,
       label: "Deliveries Feed"
     )

# Render Strategic Map (Mermaid tab is primary)
Kino.Layout.tabs([
  "Mermaid": Kino.Mermaid.new(Domain.to_mermaid(context_map)),
  "Graphviz": Kino.VizJS.render(Domain.to_dot(context_map))
])

2. Tactical Event Storming

During an Event Storming session, we explore the flow of business actions. We place colored “sticky notes” to map how users/actors initiate Commands, which target Aggregates, which then emit Domain Events that trigger reaction Policies.

Here we model the full Order fulfillment flow:

  1. A Customer places an order, updating the Order Aggregate, emitting Order Placed.
  2. The Payment Saga Policy reacts to Order Placed by issuing Request Payment to the Billing Aggregate, which emits Payment Received.
  3. The Allocation Saga Policy reacts to Payment Received by issuing Reserve Inventory to the Inventory Aggregate, which emits Inventory Reserved.
  4. The Shipment Saga Policy reacts to Inventory Reserved by issuing Ship Goods to the Shipping Aggregate, which emits Goods Shipped to notify the Shipping Agent.
storming =
  Domain.new()
  # Define 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")

  # Define visual Event Storming sticky notes
  # 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: "Command to submit cart contents for validation."
     )
  |> Domain.add_aggregate(:order_agg,
       label: "Order Aggregate",
       cluster: "order_taking",
       description: "Consistency boundary wrapping order entities and lines."
     )
  |> Domain.add_event(:order_placed,
       label: "Order Placed Event",
       cluster: "order_taking",
       description: "Emitted when order is validation-cleared."
     )

  # Context: Billing
  |> Domain.add_policy(:payment_saga,
       label: "Payment Saga Policy",
       cluster: "billing",
       description: "Saga executing payment collection on order placed."
     )
  |> Domain.add_command(:request_payment,
       label: "Request Payment",
       cluster: "billing",
       description: "Triggers payment gateway processing."
     )
  |> Domain.add_aggregate(:billing_agg,
       label: "Billing Aggregate",
       cluster: "billing",
       description: "Manages invoice status records."
     )
  |> Domain.add_event(:payment_received,
       label: "Payment Received Event",
       cluster: "billing",
       description: "Emitted after payment validation."
     )

  # Context: Inventory
  |> Domain.add_policy(:allocation_saga,
       label: "Allocation Saga Policy",
       cluster: "inventory",
       description: "Saga verifying stock levels once payment is secured."
     )
  |> Domain.add_command(:reserve_inventory,
       label: "Reserve Inventory",
       cluster: "inventory",
       description: "Deducts pending stock counts."
     )
  |> Domain.add_aggregate(:inventory_agg,
       label: "Inventory Aggregate",
       cluster: "inventory",
       description: "Maintains real-time stock balances."
     )
  |> Domain.add_event(:inventory_reserved,
       label: "Inventory Reserved Event",
       cluster: "inventory",
       description: "Emitted when stock is allocated."
     )

  # Context: Shipping
  |> Domain.add_policy(:shipment_saga,
       label: "Shipment Saga Policy",
       cluster: "shipping",
       description: "Saga initiating shipping dispatch."
     )
  |> Domain.add_command(:ship_goods,
       label: "Ship Goods",
       cluster: "shipping",
       description: "Commands warehouse to box items."
     )
  |> Domain.add_aggregate(:shipping_agg,
       label: "Shipping Aggregate",
       cluster: "shipping",
       description: "Handles shipping labels."
     )
  |> Domain.add_event(:goods_shipped,
       label: "Goods Shipped Event",
       cluster: "shipping",
       description: "Emitted when package departs."
     )
  |> Domain.add_actor(:shipping_agent,
       label: "Shipping Agent",
       description: "Warehouse picker dispatcher."
     )

  # Connect the execution flow
  # Customer -> Order Placed
  |> Domain.connect(:customer, :place_order)
  |> Domain.connect(:place_order, :order_agg)
  |> Domain.connect(:order_agg, :order_placed)

  # Order Placed -> Payment Received
  |> Domain.connect(:order_placed, :payment_saga)
  |> Domain.connect(:payment_saga, :request_payment)
  |> Domain.connect(:request_payment, :billing_agg)
  |> Domain.connect(:billing_agg, :payment_received)

  # Payment Received -> Inventory Reserved
  |> Domain.connect(:payment_received, :allocation_saga)
  |> Domain.connect(:allocation_saga, :reserve_inventory)
  |> Domain.connect(:reserve_inventory, :inventory_agg)
  |> Domain.connect(:inventory_agg, :inventory_reserved)

  # Inventory Reserved -> Goods Shipped
  |> Domain.connect(:inventory_reserved, :shipment_saga)
  |> Domain.connect(:shipment_saga, :ship_goods)
  |> Domain.connect(:ship_goods, :shipping_agg)
  |> Domain.connect(:shipping_agg, :goods_shipped)
  |> Domain.connect(:goods_shipped, :shipping_agent)

# Render Event Storming Flow
Kino.Layout.tabs([
  "Mermaid": Kino.Mermaid.new(Domain.to_mermaid(storming)),
  "Graphviz": Kino.VizJS.render(Domain.to_dot(storming))
])

3. Tactical ADT Data Pipelines

In Domain Modeling Made Functional, Scott Wlaschin focuses on modeling business workflows as pipelines of pure functions that transform algebraic data types.

Here, we map the core order validation, pricing, and billing pipeline showing how types are transformed: UnvalidatedOrder -> [ValidateOrder] -> ValidatedOrder -> [PriceOrder] -> PricedOrder -> [AcknowledgeOrder] -> OrderAcknowledgmentSent -> [BillCustomer] -> BillRequested

Choreo.Domain renders these types as structured UML-like tables listing fields and their types.

algebraic_pipeline =
  Domain.new()
  # Define Domain Types with fields (renders as structured UML Class tables)
  |> Domain.add_type(:unvalidated_order,
       label: "Unvalidated Order",
       fields: [
         {:customer_id, :string},
         {:lines, "list of UnvalidatedLine"}
       ]
     )
  |> Domain.add_type(:validated_order,
       label: "Validated Order",
       fields: [
         {:customer_info, "CustomerInfo"},
         {:lines, "list of ValidatedLine"}
       ]
     )
  |> Domain.add_type(:priced_order,
       label: "Priced Order",
       fields: [
         {:customer_info, "CustomerInfo"},
         {:lines, "list of PricedLine"},
         {:total_amount, :money}
       ]
     )
  |> Domain.add_type(:order_acknowledgment_sent,
       label: "Order Acknowledgment Sent",
       fields: [
         {:customer_email, :string},
         {:pdf_receipt, :binary}
       ]
     )
  |> Domain.add_type(:bill_requested,
       label: "Bill Requested",
       fields: [
         {:invoice_id, :string},
         {:total, :money},
         {:payment_terms, [:due_on_receipt, :net_30, :net_60]}
       ]
     )

  # Define Workflow action pipeline functions
  |> Domain.add_workflow(:validate_order, label: "Validate Order (Workflow)")
  |> Domain.add_workflow(:price_order, label: "Price Order (Workflow)")
  |> Domain.add_workflow(:acknowledge_order, label: "Acknowledge Order (Workflow)")
  |> Domain.add_workflow(:bill_customer, label: "Bill Customer (Workflow)")

  # Wire the pipeline transitions
  |> Domain.connect(:unvalidated_order, :validate_order)
  |> Domain.connect(:validate_order, :validated_order)
  |> Domain.connect(:validated_order, :price_order)
  |> Domain.connect(:price_order, :priced_order)
  |> Domain.connect(:priced_order, :acknowledge_order)
  |> Domain.connect(:acknowledge_order, :order_acknowledgment_sent)
  |> Domain.connect(:order_acknowledgment_sent, :bill_customer)
  |> Domain.connect(:bill_customer, :bill_requested)

# Render UML-like Pipeline Flow
Kino.Layout.tabs([
  "Mermaid": Kino.Mermaid.new(Domain.to_mermaid(algebraic_pipeline, syntax: :erd)),
  "Graphviz": Kino.VizJS.render(Domain.to_dot(algebraic_pipeline))
])

4. Entity vs. Aggregate: Detailed Design Example

An Entity has a persistent identity that spans time and state, while an Aggregate represents a transactional consistency boundary wrapping one or more entities and value objects.

Below, we detail the internal schemas of our Order Aggregate boundary. Inside the aggregate, we have:

  • The Order Entity (its root)
  • An Order Line Entity (representing selected items)
  • A Customer Info Value Object (representing client details)
aggregate_detail =
  Domain.new()
  |> Domain.add_aggregate(:order_root,
       label: "Order (Aggregate Root Entity)",
       fields: [
         {:id, :uuid},
         {:status, [:placed, :processing, :fulfilled]},
         {:customer_info, "CustomerInfo (Value Object)"},
         {:total_price, :money}
       ]
     )
  |> Domain.add_type(:order_line,
       label: "OrderLine (Entity)",
       fields: [
         {:id, :uuid},
         {:product_id, :string},
         {:quantity, :integer},
         {:price, :money}
       ]
     )
  |> Domain.add_type(:customer_info_vo,
       label: "CustomerInfo (Value Object)",
       fields: [
         {:customer_id, :uuid},
         {:name, :string},
         {:email, :string}
       ]
     )

  # Connect internal relations inside the Aggregate
  |> Domain.connect(:order_root, :customer_info_vo, label: "contains")
  |> Domain.connect(:order_root, :order_line, label: "has 1..*")

# Render detailed Aggregate structures
Kino.Layout.tabs([
  "Mermaid": Kino.Mermaid.new(Domain.to_mermaid(aggregate_detail, syntax: :class_diagram)),
  "Graphviz": Kino.VizJS.render(Domain.to_dot(aggregate_detail))
])

5. Sequence Diagram: Timeline Workflow

To visualize the sequential timeline of messages exchanged during order validation and payment collection, we construct a Sequence Diagram using Choreo.Sequence.

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")

  # Message flow
  |> 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: "Authorise Charge")
  |> Sequence.return(:stripe, :billing_service, label: "Token Approved")
  |> Sequence.return(:billing_service, :order_service, label: "Invoice Issued")
  |> Sequence.deactivate(:billing_service)
  |> Sequence.return(:order_service, :customer, label: "Receipt Received")
  |> Sequence.deactivate(:order_service)

# Render Sequence Diagram (Mermaid is native)
Kino.Mermaid.new(Sequence.to_mermaid(seq))

6. Ubiquitous Language (Domain Glossary)

The Ubiquitous Language represents the common glossary of terms agreed upon by developers and domain experts.

By extracting definitions directly from the graph node labels and description parameters, Choreo enforces that code remains the single, compile-verified source of truth for the domain dictionary:

# Extract glossary dynamically from code
glossary_md = Analysis.ubiquitous_language(storming)

# Render as Markdown in Livebook
Kino.Markdown.new(glossary_md)

7. Scenario Tracing & Semantic Auditing

With Choreo, you can highlight specific transaction scenarios (paths) and run semantic checks against your diagrams.

Highlighting a Scenario: “The Successful Order-to-Payment Lifecycle”

We highlight only the path from the customer’s request down to the payment validation event, leaving the rest of the storming board grayed out.

payment_scenario = [:customer, :place_order, :order_agg, :order_placed, :payment_saga, :request_payment, :billing_agg, :payment_received]

highlighted_storm = Domain.focus_path(storming, payment_scenario)

# Render highlighted scenario
Kino.Layout.tabs([
  "Mermaid": Kino.Mermaid.new(Domain.to_mermaid(highlighted_storm)),
  "Graphviz": Kino.VizJS.render(Domain.to_dot(highlighted_storm))
])

Tracing Roots

We can trace backward from the shipping event to determine all preceding triggers across contexts:

Domain.trace_cause(storming, :goods_shipped)

Semantic Design Auditor

We run semantic rule checks on our Event Storming board to verify that there are no dangling triggers or unhandled events.

warnings = Analysis.warnings(storming)

if warnings == [] do
  IO.puts("✅ Clean Event Storming graph: All triggers and outputs resolve properly.")
else
  Enum.each(warnings, &IO.puts("⚠️  #{&1}"))
end