Choreo: Advanced Architectural Analysis
Mix.install([
# {:choreo, "~> 0.9"},
{:choreo, path: Path.expand("~/repos/elixir/choreo"), force: true},
{:kino_vizjs, "~> 0.8.0"}
])
Section
Deeper Insights: Beyond simple visualization, Choreo provides structural metrics to identify single points of failure, coupled nuclei, and performance hotspots.
1. Single Point of Failure (SPOF) Detection
An Articulation Point (or Cut Vertex) is a node that, if removed, would split the graph into two or more disconnected components. In a microservice architecture, these are your most critical services.
Let’s model a realistic E-Commerce Microservices Architecture. Here, the :api_gateway and :order_service act as routing hubs. Let’s run articulation point detection and highlight the critical hotspots in red.
alias Choreo
alias Choreo.Analysis
ecommerce_system =
Choreo.new()
# Public entrypoint
|> Choreo.add_user(:customer, label: "Customer Browser")
# Core gateway
|> Choreo.add_service(:api_gateway, label: "API Gateway")
# Independent services
|> Choreo.add_service(:auth_service, label: "Auth Service")
|> Choreo.add_service(:product_catalog, label: "Product Catalog Svc")
# Business orchestration hub
|> Choreo.add_service(:order_service, label: "Order Processor")
# Downstream fulfillment services
|> Choreo.add_service(:payment_gateway, label: "Payment Gateway Integration")
|> Choreo.add_service(:inventory_service, label: "Inventory Controller")
|> Choreo.add_service(:shipping_service, label: "Shipping & Fulfillment")
# Connect the graph
|> Choreo.connect(:customer, :api_gateway)
|> Choreo.connect(:api_gateway, :auth_service)
|> Choreo.connect(:api_gateway, :product_catalog)
|> Choreo.connect(:api_gateway, :order_service)
|> Choreo.connect(:order_service, :payment_gateway)
|> Choreo.connect(:order_service, :inventory_service)
|> Choreo.connect(:order_service, :shipping_service)
# Run cut vertex analysis and heat-map the graph
spof_system = Analysis.heatmap(ecommerce_system, measure: :spof, palette: :heat)
graphviz =
Kino.Layout.grid(
[
Kino.VizJS.render(Choreo.to_dot(spof_system)),
Kino.VizJS.render(Choreo.to_dot(Analysis.legend(palette: :heat)))
],
columns: 2
)
mermaid =
Kino.Layout.grid(
[
Kino.Mermaid.new(Choreo.to_mermaid(spof_system)),
Kino.Mermaid.new(Choreo.to_mermaid(Analysis.legend(palette: :heat)))
],
columns: 2
)
Kino.Layout.tabs(
Mermaid: mermaid,
Graphviz: graphviz
)
2. Nucleus Detection (K-Core)
K-Core Decomposition identifies nested layers of connectivity. Higher “Core Numbers” represent nodes that are part of a tightly-coupled nucleus. This helps architects identify highly cohesive system components that should probably scale together or share database zones.
Let’s model a Billing Consensus & Ledger Cluster where three database nodes form a highly connected consensus ring, coordinated by a manager, with log shippers at the periphery.
consensus_mesh =
Choreo.new()
# Core consensus ring (3-core)
|> Choreo.add_database(:ledger_db1, label: "Ledger Replica 1")
|> Choreo.add_database(:ledger_db2, label: "Ledger Replica 2")
|> Choreo.add_database(:ledger_db3, label: "Ledger Replica 3")
# Coordinator
|> Choreo.add_service(:consensus_manager, label: "Consensus Manager")
# Periphery
|> Choreo.add_service(:log_shipper, label: "Log Shipper")
# Build the consensus mesh
|> Choreo.connect(:ledger_db1, :ledger_db2)
|> Choreo.connect(:ledger_db2, :ledger_db3)
|> Choreo.connect(:ledger_db3, :ledger_db1)
# Connect manager to all consensus nodes
|> Choreo.connect(:consensus_manager, :ledger_db1)
|> Choreo.connect(:consensus_manager, :ledger_db2)
|> Choreo.connect(:consensus_manager, :ledger_db3)
# Log shipper only polls the manager
|> Choreo.connect(:log_shipper, :consensus_manager)
# Highlight k-core depth
core_system = Analysis.heatmap(consensus_mesh, measure: :k_core, palette: :cool)
graphviz =
Kino.Layout.grid(
[
Kino.VizJS.render(Choreo.to_dot(core_system)),
Kino.VizJS.render(Choreo.to_dot(Analysis.legend(palette: :cool)))
],
columns: 2
)
mermaid =
Kino.Layout.grid(
[
Kino.Mermaid.new(Choreo.to_mermaid(core_system)),
Kino.Mermaid.new(Choreo.to_mermaid(Analysis.legend(palette: :cool)))
],
columns: 2
)
Kino.Layout.tabs(
Mermaid: mermaid,
Graphviz: graphviz
)
3. Workflow Latency Hotspots
Workflow heatmaps highlight tasks based on their cumulative latency (including retries and human-in-the-loop timeouts).
Let’s model a User Registration & KYC Verification Workflow where automated steps are lightning fast, but the manual KYC verification step takes up to 10 seconds (10,000ms), acting as a giant bottleneck.
alias Choreo.Workflow
alias Choreo.Workflow.Analysis, as: WorkflowAnalysis
kyc_wf =
Workflow.new()
|> Workflow.add_start(:start)
|> Workflow.add_task(:submit_details, label: "Submit Registration Info", timeout_ms: 100)
|> Workflow.add_task(:email_validation, label: "Verify Email Token", timeout_ms: 200)
|> Workflow.add_task(:automated_credit_check, label: "Credit Bureau Call", timeout_ms: 1500)
|> Workflow.add_task(:manual_kyc_verification, label: "Manual KYC Approval", timeout_ms: 10000)
|> Workflow.add_task(:create_user_account, label: "Create Db Records", timeout_ms: 300)
|> Workflow.add_end(:done)
# Connect sequence
|> Workflow.connect(:start, :submit_details)
|> Workflow.connect(:submit_details, :email_validation)
|> Workflow.connect(:email_validation, :automated_credit_check)
|> Workflow.connect(:automated_credit_check, :manual_kyc_verification)
|> Workflow.connect(:manual_kyc_verification, :create_user_account)
|> Workflow.connect(:create_user_account, :done)
# Heatmap by latency
heat_wf = WorkflowAnalysis.heatmap(kyc_wf, palette: :spectral)
graphviz =
Kino.Layout.grid(
[
Kino.VizJS.render(Choreo.to_dot(heat_wf)),
Kino.VizJS.render(Choreo.to_dot(Analysis.legend(palette: :spectral)))
],
columns: 2
)
mermaid =
Kino.Layout.grid(
[
Kino.Mermaid.new(Choreo.to_mermaid(heat_wf)),
Kino.Mermaid.new(Choreo.to_mermaid(Analysis.legend(palette: :spectral)))
],
columns: 2
)
Kino.Layout.tabs(
Mermaid: mermaid,
Graphviz: graphviz
)
4. Dataflow Throughput Hotspots
Dataflow heatmaps visualize volume by coloring stages based on their inbound event rates.
Let’s model an IoT Sensor Telemetry Ingestion Pipeline where high-frequency GPS tracking devices stream 50,000 events/sec into Kafka, but the PostgreSQL writer is throttled at 2,000 events/sec.
alias Choreo.Dataflow
alias Choreo.Dataflow.Analysis, as: DataflowAnalysis
telemetry_pipeline =
Dataflow.new()
# Inbound rates of sources, and capacities of transforms/sinks
|> Dataflow.add_source(:gps_sensors, label: "50k GPS Devices", rate: 50000)
|> Dataflow.add_transform(:kafka_ingest, label: "Kafka Ingestion Topic", capacity: 60000)
|> Dataflow.add_transform(:spark_processor, label: "Spark Stream Parser", capacity: 45000)
# Postgres writer is a transform with capacity, writing to the final database sink
|> Dataflow.add_transform(:postgres_writer, label: "Postgres Writer Service", capacity: 2000)
|> Dataflow.add_sink(:postgres_db, label: "Relational Audit DB")
# Connect pipeline
|> Dataflow.connect(:gps_sensors, :kafka_ingest)
|> Dataflow.connect(:kafka_ingest, :spark_processor)
|> Dataflow.connect(:spark_processor, :postgres_writer)
|> Dataflow.connect(:postgres_writer, :postgres_db)
# Heatmap showing processing bottlenecks
heat_df = DataflowAnalysis.heatmap(telemetry_pipeline, palette: :heat)
graphviz =
Kino.Layout.grid(
[
Kino.VizJS.render(Choreo.to_dot(heat_df, rankdir: :tb), height: "600px"),
Kino.VizJS.render(Choreo.to_dot(Analysis.legend(palette: :heat)))
],
columns: 2
)
mermaid =
Kino.Layout.grid(
[
Kino.Mermaid.new(Choreo.to_mermaid(heat_df)),
Kino.Mermaid.new(Choreo.to_mermaid(Analysis.legend(palette: :heat)))
],
columns: 2
)
Kino.Layout.tabs(
Mermaid: mermaid,
Graphviz: graphviz
)
5. ThreatModel Risk Hotspots
ThreatModel heatmaps identify security “Hotspots” based on node threat density and asset sensitivity.
Let’s model a Financial Transaction Threat Model with three zones: Public Network, Corporate DMZ, and PCI-Compliant secure zone. The credit card vault is the most sensitive data store and gets marked as a high security risk.
alias Choreo.ThreatModel
alias Choreo.ThreatModel.Analysis, as: ThreatAnalysis
security_model =
ThreatModel.new()
# Add boundaries
|> ThreatModel.add_trust_boundary("public_network", label: "Public Internet", level: 0)
|> ThreatModel.add_trust_boundary("corporate_dmz", label: "DMZ", level: 1)
|> ThreatModel.add_trust_boundary("pci_zone", label: "PCI-DSS Compliant Area", level: 2)
# Add entities
|> ThreatModel.add_external_entity(:customer_browser, label: "User Browser", boundary: "public_network")
|> ThreatModel.add_process(:web_server, label: "Nginx Gateway", boundary: "corporate_dmz")
|> ThreatModel.add_process(:payment_processor, label: "Card Transaction Svc", boundary: "pci_zone")
|> ThreatModel.add_data_store(:credit_card_vault, label: "CC Hash Vault", boundary: "pci_zone", sensitivity: :restricted)
# Connect data flows
|> ThreatModel.data_flow(:customer_browser, :web_server)
|> ThreatModel.data_flow(:web_server, :payment_processor)
|> ThreatModel.data_flow(:payment_processor, :credit_card_vault)
# Heatmap showing high risk concentration
heat_tm = ThreatAnalysis.heatmap(security_model, palette: :heat)
graphviz =
Kino.Layout.grid(
[
Kino.VizJS.render(Choreo.to_dot(heat_tm)),
Kino.VizJS.render(Choreo.to_dot(Analysis.legend(palette: :heat)))
],
columns: 2
)
mermaid =
Kino.Layout.grid(
[
Kino.Mermaid.new(Choreo.to_mermaid(heat_tm)),
Kino.Mermaid.new(Choreo.to_mermaid(Analysis.legend(palette: :heat)))
],
columns: 2
)
Kino.Layout.tabs(
Mermaid: mermaid,
Graphviz: graphviz
)
6. Transitive Reduction (Dependency Cleanup)
Transitive Reduction simplifies complex dependency graphs by removing redundant edges while preserving reachability. If A -> B, B -> C, and A -> C, the direct edge A -> C is removed because the dependency is already implied by the path through B.
Let’s model a Microservice Deployment Dependency Graph where circular or redundant dependencies often build up. Redundant edges make dependency tracking hard; transitive reduction cleans it up.
alias Choreo.Dependency
deploy_deps =
Dependency.new()
|> Dependency.add_application(:api_gateway)
|> Dependency.add_application(:auth_service)
|> Dependency.add_application(:user_database)
# Declare dependencies
|> Dependency.depends_on(:api_gateway, :auth_service)
|> Dependency.depends_on(:auth_service, :user_database)
# Redundant! The gateway depends on DB transitively via auth_service
|> Dependency.depends_on(:api_gateway, :user_database)
# Run Transitive Reduction
{:ok, reduced_deps} = Analysis.reduce_transitive(deploy_deps)
graphviz =
Kino.Layout.grid(
[
Kino.VizJS.render(Dependency.to_dot(deploy_deps)),
Kino.VizJS.render(Dependency.to_dot(reduced_deps))
],
columns: 2
)
mermaid =
Kino.Layout.grid(
[
Kino.Mermaid.new(Dependency.to_mermaid(deploy_deps)),
Kino.Mermaid.new(Dependency.to_mermaid(reduced_deps))
],
columns: 2
)
Kino.Layout.tabs(
Mermaid: mermaid,
Graphviz: graphviz
)
7. Path Analysis (Critical Paths & Bottlenecks)
Path Analysis finds the optimal route between two services based on domain-specific metrics.
Fastest Path (Workflow Latency)
Minimize cumulative latency across tasks (shortest path search).
alias Choreo.Workflow
order_flow =
Workflow.new()
|> Workflow.add_start(:start)
# Parallel path A (Fast Lane - Automated payment & release)
|> Workflow.add_task(:fast_payment, label: "Auth Card", timeout_ms: 150)
|> Workflow.add_task(:fast_ship, label: "Auto Release", timeout_ms: 100)
# Parallel path B (Slow Lane - Manual fraud check)
|> Workflow.add_task(:fraud_review, label: "Fraud Investigation", timeout_ms: 5000)
|> Workflow.add_end(:done)
# Connect fast path
|> Workflow.connect(:start, :fast_payment)
|> Workflow.connect(:fast_payment, :fast_ship)
|> Workflow.connect(:fast_ship, :done)
# Connect slow path
|> Workflow.connect(:start, :fraud_review)
|> Workflow.connect(:fraud_review, :done)
# Find the "Fastest Path" (minimum timeout_ms latency)
{:ok, fast_path} = Analysis.path(order_flow, :start, :done, measure: :latency)
graphviz = Kino.VizJS.render(Workflow.to_dot(order_flow, Analysis.highlight(fast_path)))
mermaid = Kino.Mermaid.new(Workflow.to_mermaid(order_flow, Analysis.highlight(fast_path)))
Kino.Layout.tabs(
Mermaid: mermaid,
Graphviz: graphviz
)
Widest Path (Dataflow Throughput Capacity)
Maximize the bottleneck capacity (minimum throughput) along the path.
alias Choreo.Dataflow
route_pipeline =
Dataflow.new()
|> Dataflow.add_source(:ingest, rate: 1000)
# Router A (Gigabit Fiber backup link)
|> Dataflow.add_transform(:high_speed_link, label: "Fiber Backup", capacity: 800)
# Router B (Dial-up backup link)
|> Dataflow.add_transform(:low_speed_link, label: "Satellite Link", capacity: 10)
|> Dataflow.add_sink(:backup_store, label: "Backup SAN")
# Connect paths
|> Dataflow.connect(:ingest, :high_speed_link)
|> Dataflow.connect(:high_speed_link, :backup_store)
|> Dataflow.connect(:ingest, :low_speed_link)
|> Dataflow.connect(:low_speed_link, :backup_store)
# Find the "Widest Path" (maximizing bottleneck throughput)
{:ok, wide_path} = Analysis.path(route_pipeline, :ingest, :backup_store, measure: :throughput)
graphviz = Kino.VizJS.render(Dataflow.to_dot(route_pipeline, Analysis.highlight(wide_path)))
mermaid = Kino.Mermaid.new(Dataflow.to_mermaid(route_pipeline, Analysis.highlight(wide_path)))
Kino.Layout.tabs(
Mermaid: mermaid,
Graphviz: graphviz
)
Custom Weighted Paths
Minimize arbitrary metrics like monetary costs or hops.
system = Choreo.new()
|> Choreo.add_service(:a)
|> Choreo.add_service(:b)
|> Choreo.add_service(:c)
|> Choreo.connect(:a, :b, cost: 10)
|> Choreo.connect(:b, :c, cost: 10)
|> Choreo.connect(:a, :c, cost: 50)
# Find the cheapest path using the 'cost' variable
{:ok, cost_path} = Analysis.path(system, :a, :c, measure: :cost)
graphviz = Kino.VizJS.render(Choreo.to_dot(system, Analysis.highlight(cost_path)))
mermaid = Kino.Mermaid.new(Choreo.to_mermaid(system, Analysis.highlight(cost_path)))
Kino.Layout.tabs(
Mermaid: mermaid,
Graphviz: graphviz
)
8. Cross-Diagram Semantic Tracing & Composition
Because Choreo allows you to compose different diagrams together using Choreo.embed/4, you can use semantic tracing to link your static C4 architectural components to runtime workflows or database entities.
By pulling tracing up to the coordinator level:
-
Domain modules (
C4,Workflow,ERD) remain completely decoupled. - Cross-domain dependencies are declared as traces.
- Impact analysis can be performed across boundaries.
Let’s build a composed system with:
- A C4 Model showing an internet banking backend container.
- A Workflow describing a money transfer execution.
- An ERD describing the database structure.
Then, we’ll draw traces connecting them, perform impact analysis, and calculate path tracing.
alias Choreo.C4
alias Choreo.Workflow
alias Choreo.ERD
alias Choreo.Analysis.Tracing
# 1. Define C4 Architecture L2
c4_model =
C4.new()
|> C4.add_container(:transfer_api, label: "Transfer API Service", technology: "Elixir/Phoenix")
|> C4.add_container(:audit_db, label: "Transaction Database", technology: "PostgreSQL")
|> C4.add_relationship(:transfer_api, :audit_db, label: "Writes transactions to")
# 2. Define business runtime Workflow
business_wf =
Workflow.new()
|> Workflow.add_start(:start)
|> Workflow.add_task(:validate_funds, label: "Validate Available Funds")
|> Workflow.add_task(:log_audit_trail, label: "Log Audit Record")
|> Workflow.add_end(:stop)
|> Workflow.connect(:start, :validate_funds)
|> Workflow.connect(:validate_funds, :log_audit_trail)
|> Workflow.connect(:log_audit_trail, :stop)
# 3. Define Database ERD
database_erd =
ERD.new()
|> ERD.add_table(:ledger_entries, columns: [
%{name: :id, type: :uuid},
%{name: :amount, type: :decimal},
%{name: :created_at, type: :timestamp}
])
# 4. Compose them in a top-level Choreo VPC
composed_system =
Choreo.new()
|> Choreo.add_cluster("secure_zone", label: "PCI Network Segment")
|> Choreo.embed(c4_model, "secure_zone", prefix: "c4_")
|> Choreo.embed(business_wf, "secure_zone", prefix: "wf_")
|> Choreo.embed(database_erd, "secure_zone", prefix: "erd_")
# 5. Draw semantic traces across domains
# - The 'log_audit_trail' task executes via the 'transfer_api' C4 component
# - The 'transfer_api' component writes and stores data inside the 'ledger_entries' database table
composed_system =
composed_system
|> Choreo.trace(:wf_log_audit_trail, :c4_transfer_api, type: :executes)
|> Choreo.trace(:c4_transfer_api, :erd_ledger_entries, type: :stores)
Visualizing Trace Edges
Trace edges are hidden from normal visual outputs by default to keep the diagrams clean. To display them, pass the :show_traces option set to true.
In DOT, trace edges render with constraint=false so the trace lines do not warp or disrupt the layout of the embedded diagrams.
Kino.Layout.tabs(
Mermaid: Kino.Mermaid.new(Choreo.to_mermaid(composed_system, show_traces: true)),
Graphviz: Kino.VizJS.render(Choreo.to_dot(composed_system, show_traces: true))
)
Performing Cross-Diagram Impact Analysis
If the database table ledger_entries schema is modified, which architectural components and business workflows are impacted? We run impact analysis by walking the traces backward:
# Returns list of impacted nodes: [:c4_transfer_api, :wf_log_audit_trail]
Tracing.impact_analysis(composed_system, :erd_ledger_entries)
Finding Execution Paths
We can calculate the semantic path from the runtime business task down to the database schema:
# Returns {:ok, [:wf_log_audit_trail, :c4_transfer_api, :erd_ledger_entries]}
{:ok, path} = Tracing.trace_path(composed_system, :wf_log_audit_trail, :erd_ledger_entries)
Focusing on a Trace Path
If the composed system is very large, you can use the Choreo.View.focus_trace/4 lens to filter the diagram down to only the nodes on the trace path:
# Filters the system diagram to show only the trace path
trace_view = Choreo.View.focus_trace(composed_system, :wf_log_audit_trail, :erd_ledger_entries)
Kino.Layout.tabs(
Mermaid: Kino.Mermaid.new(Choreo.to_mermaid(trace_view, show_traces: true)),
Graphviz: Kino.VizJS.render(Choreo.to_dot(trace_view, show_traces: true))
)
Nested Semantic Lens Analysis
Using Tracing.analyze/3, we can thread through the trace path and extract domain-specific metadata (such as workflow timeouts, retries, and database column sensitivity classifications) to compile a unified, queryable architectural posture report:
# Performs cross-domain metadata analysis along the trace path
{:ok, summary} = Tracing.analyze(composed_system, :wf_log_audit_trail, :erd_ledger_entries)