Powered by AppSignal & Oban Pro

Choreo Dependency: Comprehensive Walkthrough

dependency_walkthrough.livemd

Choreo Dependency: Comprehensive Walkthrough

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

# For local development:
# Mix.install([
#   {:choreo, path: Path.expand("~/repos/elixir/choreo")},
#   {:kino_vizjs, "~> 0.9.0"}
# ])

Section

Rendering diagrams: This livebook uses Kino.VizJS to render DOT diagrams inline and Choreo.Lab.Siren / Choreo.Lab.Sketch for Mermaid previews.


What is Choreo.Dependency?

Choreo.Dependency maps software component relationships โ€” applications, libraries, modules, interfaces, and tests โ€” to help you visualise coupling, detect cycles, enforce architectural layers, and measure instability.

Unlike static dependency graphs drawn by hand, Choreo dependency graphs are analysable. You can ask:

  • "What breaks if I change the Auth module?"
  • "Where are the circular dependencies?"
  • "Is the Repository layer calling the API layer?"
  • "Which components are the most coupled?"
  • "What's the deepest dependency chain?"

Node Types

Type Shape Purpose
application ๐Ÿ“ฆ box3d Deployable service or app
library ๐Ÿ›ข๏ธ cylinder External or shared library
module โฌ› box Internal code unit
interface ๐Ÿ’Ž diamond API, contract, or protocol
test ๐Ÿ“ note Test suite or spec

Edge Types

Type Style Meaning
:uses solid General dependency
:imports dashed Explicit import / require
:calls solid Runtime function call
:inherits dotted Inheritance / implementation
:dev dashed grey Development-only dependency

API Approaches: Programmatic Pipe API vs Lab DSL

Choreo provides two ways to author dependency graphs:

  1. Programmatic Pipe API (Choreo.Dependency): Explicit, pipe-first builder functions (add_application/3, add_library/3, add_module/3, add_interface/3, add_test/3, add_cluster/3, depends_on/4). Best for production services, build tool integrations, and static analysis compilers.
  2. Lab DSL (Choreo.Lab.DSL.Dependency): Concise, sketch-oriented syntax (dependency do ... end, variable binding, ~>, and pipe modifiers like |> calls("verifies"), |> uses(), |> type(:dev)). Best for Livebooks, architecture reviews, refactoring discussions, and rapid coupling modeling.

In this guide, the introductory legend demonstrates the programmatic pipe API, while subsequent examples showcase the expressive Lab DSL.

alias Choreo.Dependency
alias Choreo.Dependency.Analysis
import Choreo.Lab.DSL.Dependency

legend =
  Dependency.new()
  |> Dependency.add_application(:app, label: "Application")
  |> Dependency.add_library(:lib, label: "Library")
  |> Dependency.add_module(:mod, label: "Module")
  |> Dependency.add_interface(:iface, label: "Interface")
  |> Dependency.add_test(:test, label: "Test")
  |> Dependency.depends_on(:app, :lib, type: :uses)
  |> Dependency.depends_on(:app, :mod, type: :calls)
  |> Dependency.depends_on(:mod, :iface, type: :inherits)
  |> Dependency.depends_on(:test, :app, type: :dev)

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(Dependency.to_mermaid(legend), height: "600px"),
  Class_Diagram:
    Choreo.Lab.Siren.new(Dependency.to_mermaid(legend, syntax: :class_diagram), height: "600px"),
  Graphviz: Kino.VizJS.render(Dependency.to_dot(legend), engine: "twopi", height: "600px"),
  Sketch: Choreo.Lab.Sketch.new(Dependency.to_mermaid(legend))
)

Example 1: Simple Microservice Dependencies

A classic three-service architecture: API Gateway, Auth Service, and User Service. Each depends on shared libraries and infrastructure.

microservices =
  dependency do
    api_gateway = application("API Gateway")
    auth_service = application("Auth Service")
    user_service = application("User Service")

    phoenix = library("Phoenix")
    ecto = library("Ecto")
    jwt = library("Joken JWT")
    redis = library("Redix")

    auth_iface = interface("AuthContract")

    api_tests = test("API Tests")
    auth_tests = test("Auth Tests")

    api_gateway ~> phoenix |> uses()
    api_gateway ~> auth_service |> calls()
    api_gateway ~> user_service |> calls()

    auth_service ~> phoenix |> uses()
    auth_service ~> ecto |> uses()
    auth_service ~> jwt |> imports()
    auth_service ~> redis |> uses()
    auth_service ~> auth_iface |> implements()

    user_service ~> phoenix |> uses()
    user_service ~> ecto |> uses()
    user_service ~> auth_iface |> implements()

    api_tests ~> api_gateway |> dev()
    auth_tests ~> auth_service |> dev()
  end

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(Dependency.to_mermaid(microservices), height: "600px"),
  Graphviz: Kino.VizJS.render(Dependency.to_dot(microservices), height: "600px")
)

Impact Analysis

If you change the Auth Contract interface, what else might break?

Analysis.affected_by(microservices, :auth_iface)
|> IO.inspect(label: "Components affected by auth_iface changes")

Transitive Dependencies

What does the API Gateway need in order to function?

Analysis.depends_on(microservices, :api_gateway)
|> IO.inspect(label: "api_gateway transitively depends on")

Example 2: Layered Architecture with Violations

A well-designed system has layers: Repository (data access) โ†’ Service (business logic) โ†’ API (HTTP handlers). But during refactoring, layers can get crossed.

layers =
  dependency do
    cluster "data", label: "Data Layer", fillcolor: "#dbeafe" do
      user_repo = module("UserRepo")
      order_repo = module("OrderRepo")
    end

    cluster "service", label: "Service Layer", fillcolor: "#dcfce7" do
      user_service = module("UserService")
      order_service = module("OrderService")
      auth_service = module("AuthService")
    end

    cluster "api", label: "API Layer", fillcolor: "#fef3c7" do
      api_controller = module("APIController")
      web_socket = module("WebSocket")
    end

    # Correct flows
    user_service ~> user_repo |> calls()
    order_service ~> order_repo |> calls()
    auth_service ~> user_repo |> calls()
    api_controller ~> user_service |> calls()
    api_controller ~> order_service |> calls()
    web_socket ~> auth_service |> calls()

    # VIOLATION: repo calls back up to API layer!
    order_repo ~> api_controller |> calls()

    # VIOLATION: API layer talks directly to another API module
    web_socket ~> api_controller |> calls()
  end

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(Dependency.to_mermaid(layers)),
  Graphviz: Kino.VizJS.render(Dependency.to_dot(layers), height: "500px"),
  Sketch: Choreo.Lab.Sketch.new(Dependency.to_mermaid(layers))
)

Layer Enforcement

Define the expected layers and find violations:

layer_map = %{
  user_repo: 1,
  order_repo: 1,
  user_service: 2,
  order_service: 2,
  auth_service: 2,
  api_controller: 3,
  web_socket: 3
}

Analysis.layer_violations(layers, layer_map)
|> Enum.each(fn {_from, _to, desc} ->
  IO.puts("โŒ  #{desc}")
end)

The layer check flags edges where a lower-numbered layer (foundation) depends on a higher-numbered layer (presentation). In clean architecture, dependencies should only flow downward.

Transitive Reduction

Some dependencies are redundant. If A โ†’ B, B โ†’ C, and A โ†’ C, the direct A โ†’ C edge is often implied.

Analysis.transitive_reduction(layers)
|> IO.inspect(label: "Redundant edges")

Removing redundant edges simplifies the diagram without losing information.


Example 3: Circular Dependencies

The most dangerous pattern in dependency graphs. Let's model a messy codebase and find the cycles.

messy =
  dependency do
    cart = module("Cart Module")
    order = module("Order Module")
    payment = module("Payment Module")
    inventory = module("Inventory Module")
    shipping = module("Shipping Module")
    notification = module("Notification Module")

    # Cycle 1: cart โ†” order
    cart ~> order |> calls()
    order ~> cart |> imports()

    # Cycle 2: order โ†” payment โ†” inventory โ†’ order
    order ~> payment |> calls()
    payment ~> inventory |> uses()
    inventory ~> order |> calls()

    # Clean edges
    order ~> shipping |> calls()
    order ~> notification |> calls()
  end

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(Dependency.to_mermaid(messy)),
  Graphviz: Kino.VizJS.render(Dependency.to_dot(messy), height: "500px"),
  Sketch: Choreo.Lab.Sketch.new(Dependency.to_mermaid(messy))
)

Finding Cycles

Analysis.cyclic_dependencies(messy)
|> Enum.each(fn cycle ->
  path = Enum.join(cycle, " โ†’ ")
  IO.puts("๐Ÿ” Cycle: #{path}")
end)

Validation

Analysis.validate(messy)
|> Enum.each(fn {sev, msg} ->
  icon = if sev == :error, do: "โŒ", else: "โš ๏ธ"
  IO.puts("#{icon} #{msg}")
end)

Isolated Subsystems

Build a graph with multiple disconnected groups to see isolated_subsystems/1 in action:

subsystems_graph =
  dependency do
    # Group A: e-commerce
    cart = module("Cart")
    checkout = module("Checkout")
    cart ~> checkout

    # Group B: analytics
    tracker = module("Tracker")
    reporter = module("Reporter")
    tracker ~> reporter

    # Group C: orphan
    legacy_parser = module("Legacy Parser")
  end

Analysis.isolated_subsystems(subsystems_graph)
|> Enum.each(fn component ->
  IO.puts("Subsystem: #{Enum.join(Enum.sort(component), ", ")}")
end)

Example 4: Instability Metrics

Robert C. Martin's instability metric measures how likely a component is to change. A component with many outgoing dependencies (efferent) and few incoming (afferent) is unstable โ€” it depends on the world but the world doesn't depend on it.

metrics =
  dependency do
    api = application("API")
    auth = application("Auth")
    orders = application("Orders")
    phoenix = library("Phoenix")
    logger = library("Logger")
    config = library("Config")

    api ~> auth |> calls()
    api ~> orders |> calls()
    api ~> phoenix |> uses()
    auth ~> phoenix |> uses()
    auth ~> logger |> uses()
    orders ~> phoenix |> uses()
    orders ~> logger |> uses()
    orders ~> config |> uses()
  end

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(Dependency.to_mermaid(metrics)),
  Graphviz: Kino.VizJS.render(Dependency.to_dot(metrics), height: "500px"),
  Sketch: Choreo.Lab.Sketch.new(Dependency.to_mermaid(metrics))
)

Instability Scores

Analysis.instability(metrics)
|> Enum.sort_by(fn {_id, score} -> score end, :desc)
|> Enum.each(fn {id, score} ->
  bar = String.duplicate("โ–ˆ", ceil(score * 10))
  IO.puts("#{id}: #{:erlang.float_to_binary(score, decimals: 2)} #{bar}")
end)
  • Score 0.0 (stable): Many things depend on it; it depends on nothing. Safe to change? No โ€” high impact.
  • Score 1.0 (unstable): Depends on many things; nothing depends on it. Safe to change? Yes โ€” low impact.

Centrality Ranking

Which components are the most connected?

Analysis.centrality(metrics, limit: 5)
|> IO.inspect(label: "Most coupled components")

Leaves and Roots

IO.inspect(Analysis.leaves(metrics), label: "Components with no dependents (safe to change)")
IO.inspect(Analysis.roots(metrics), label: "Components with no dependencies (foundations)")

Example 5: Longest Dependency Chain

Long dependency chains slow down builds, tests, and deployments. Let's find the longest one.

deep =
  dependency do
    web = module("Web Layer")
    api = module("API Layer")
    service = module("Service Layer")
    repository = module("Repository Layer")
    db_client = module("DB Client")
    connection_pool = module("Connection Pool")
    tcp_stack = module("TCP Stack")

    web ~> api |> calls()
    api ~> service |> calls()
    service ~> repository |> calls()
    repository ~> db_client |> calls()
    db_client ~> connection_pool |> uses()
    connection_pool ~> tcp_stack |> uses()
  end

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(Dependency.to_mermaid(deep, direction: :lr)),
  Graphviz: Kino.VizJS.render(Dependency.to_dot(deep, rankdir: :lr))
)

Longest Chain

case Analysis.longest_dependency_chain(deep) do
  {:ok, chain, length} ->
    IO.puts("Longest chain (#{length} hops): #{Enum.join(chain, " โ†’ ")}")

  :error ->
    IO.puts("Graph contains cycles โ€” longest chain undefined")
end

Example 6: Dependency Inversion in Practice

The Dependency Inversion Principle states that high-level modules should not depend on low-level modules; both should depend on abstractions. Start with a graph that violates this principle and use analysis to guide the refactor.

broken =
  dependency do
    report_generator = module("Report Generator")
    pdf_exporter = module("PDF Exporter")
    email_sender = module("Email Sender")
    orphan = module("Orphan")

    # High-level Report Generator directly depends on low-level implementations
    report_generator ~> pdf_exporter |> calls()
    report_generator ~> email_sender |> calls()
    # Circular: pdf_exporter calls back up to report_generator
    pdf_exporter ~> report_generator |> calls()
  end

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(Dependency.to_mermaid(broken)),
  Graphviz: Kino.VizJS.render(Dependency.to_dot(broken), engine: "circo"),
  Sketch: Choreo.Lab.Sketch.new(Dependency.to_mermaid(broken))
)

Diagnosis

Analysis.validate(broken)
|> Enum.each(fn {sev, msg} ->
  icon = if sev == :error, do: "โŒ", else: "โš ๏ธ"
  IO.puts("#{icon} #{msg}")
end)

The Fix

Apply Dependency Inversion: introduce an interface so both high- and low-level modules depend on an abstraction instead of on each other.

fixed =
  dependency do
    report_generator = module("Report Generator")
    pdf_exporter = module("PDF Exporter")
    email_sender = module("Email Sender")
    exportable = interface("Exportable")

    # High-level module depends on the abstraction
    report_generator ~> exportable |> inherits()
    # Low-level modules implement the abstraction
    pdf_exporter ~> exportable |> inherits()
    email_sender ~> exportable |> inherits()
  end

IO.puts("After applying DIP:")
Analysis.validate(fixed) |> IO.inspect()

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(Dependency.to_mermaid(fixed)),
  Graphviz: Kino.VizJS.render(Dependency.to_dot(fixed), height: "500px"),
  Sketch: Choreo.Lab.Sketch.new(Dependency.to_mermaid(fixed))
)

Example 7: Custom Theming

Build a theme that matches your organisation's brand colours.

brand_theme =
  Choreo.Theme.custom(
    colors: %{
      application: "#3b82f6",
      library: "#f59e0b",
      module: "#10b981",
      interface: "#8b5cf6",
      test: "#ec4899"
    },
    node_fontcolor: "white",
    edge_color: "#94a3b8",
    graph_bgcolor: "#0f172a"
  )

Kino.Layout.tabs(
  Siren:
    Choreo.Lab.Siren.new(Dependency.to_mermaid(microservices, theme: brand_theme),
      height: "600px"
    ),
  Graphviz:
    Kino.VizJS.render(Dependency.to_dot(microservices, theme: brand_theme), height: "600px"),
  Sketch: Choreo.Lab.Sketch.new(Dependency.to_mermaid(microservices, theme: brand_theme))
)

Example 8: Parallel Dependencies

A component can depend on another for multiple distinct reasons (e.g., both an explicit :uses runtime dependency and a :dev testing harness mock).

parallel_deps =
  dependency do
    api = application("API")
    auth = library("Auth Service")

    api ~> auth |> uses()
    api ~> auth |> dev()
  end

IO.puts("Number of dependencies: #{length(Dependency.edges(parallel_deps))}")

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

Example 9: Zooming and Architectural Views

When dealing with large systems, displaying every module and interface can be overwhelming. Choreo.Viewable allows you to "zoom out" to see the big picture, or "zoom in" for detail.

For Dependency graphs, zoom levels are semantic:

  • Level 0: Applications only (System Context)
  • Level 1: Applications and Libraries (Container/Dependency level)
  • Level 2: Applications, Libraries, and Modules (Component level)
  • Level 3: Applications, Libraries, Modules, and Interfaces
  • Level 4+: Everything (including Tests)
alias Choreo.View

system =
  dependency do
    frontend = application("Frontend API")
    backend = application("Backend Service")
    phx = library("Phoenix")
    router = module("Router")
    auth = module("Auth")
    auth_test = test("Auth Test")

    frontend ~> backend |> calls()
    frontend ~> phx |> uses()
    frontend ~> router |> calls()
    backend ~> auth |> calls()
    auth_test ~> auth |> dev()
  end

Compare all zoom levels side-by-side:

label = fn level ->
  case level do
    0 -> "0. Context"
    1 -> "1. Containers"
    2 -> "2. Components"
    3 -> "3. Interfaces"
    _ -> "4. Everything"
  end
end

zoomed_diagrams =
  for level <- 0..4 do
    view = View.zoom(system, level: level)
    {label.(level), Kino.VizJS.render(Dependency.to_dot(view), height: "400px")}
  end

Kino.Layout.tabs(zoomed_diagrams)

When you zoom out, intermediate nodes are hidden, but their transitive dependencies are preserved as virtual edges (dashed light-grey lines) so you don't lose the structural relationship between the visible components!


Cheat Sheet

Lab DSL Syntax (Choreo.Lab.DSL.Dependency)

Syntax Description
dependency do ... end Define a dependency graph model
app = application("API Gateway") Declare an application component
lib = library("Repo", version: "1.0") Declare a library or external dependency
mod = module("AuthService") Declare an internal module
iface = interface("Contract") Declare an abstract interface or protocol
t = test("TestSuite") Declare a test component or harness
cluster "Name", label: "..." do ... end Group components into an architectural layer or cluster
a ~> b Declare a dependency from a to b
`a ~> b > calls()`
`a ~> b > uses()`
`a ~> b > imports()`
`a ~> b > inherits()`
`a ~> b > dev()`
`a ~> b > on("label")`
`a ~> b > type(:calls, "label")`

| edge a ~> b, "label", type: :calls | Explicit edge statement with label and options |

Programmatic Pipe API & Analysis (Choreo.Dependency)

Task / Feature Command
Create Graph Dependency.new/1 (Opts: :strict)
Add Application / Library Dependency.add_application/3, Dependency.add_library/3 (Opts: :label, :cluster)
Add Module / Interface / Test Dependency.add_module/3, Dependency.add_interface/3, Dependency.add_test/3
Add Cluster / Subgraph Dependency.add_cluster/3 (Opts: :label, :fillcolor, :color, :parent)
Declare Dependency Dependency.depends_on/4 (Opts: :type, :label)
Render Mermaid Flowchart Dependency.to_mermaid/2 (Opts: :theme, :direction, :highlighted_nodes, :highlighted_edges)
Render Mermaid Class Diagram Dependency.to_mermaid/2 (syntax: :class_diagram)
Render DOT Graphviz Dependency.to_dot/2 (Opts: :theme, :rankdir, :engine, :highlighted_nodes, :highlighted_edges)
Themes Dependency.theme/2 (:default, :dark, :minimal, :warm, :forest, :ocean)
Cycle Check Analysis.cyclic?/1 (Detects if graph contains circular dependencies)
Detect Circular Dependencies Analysis.cyclic_dependencies/1 (Finds cycle paths)
Build / Compilation Order Analysis.build_order/1 (Computes bottom-up compilation/boot order)
Topological Sort Analysis.topological_sort/1 (Computes top-down execution ordering)
Direct Dependencies Analysis.direct_dependencies/2 (Immediate downstream components)
Direct Dependents Analysis.direct_dependents/2 (Immediate upstream components)
Blast Radius / Impact Analysis Analysis.affected_by/2 (Finds all upstream components affected by a change)
Transitive Dependencies Analysis.depends_on/2 (Finds all downstream components required)
Layer Enforcement Analysis.layer_violations/2 (Detects upwards or prohibited cross-layer calls)
Transitive Reduction Analysis.transitive_reduction/1 (Finds redundant edges in transitive paths)
Instability Metric Analysis.instability/1 (Calculates Robert C. Martin instability metric: $Ce / (Ca + Ce)$)
Coupling & Centrality Analysis.centrality/2 (Ranks components by degree of coupling)
Leaves & Roots Analysis.leaves/1, Analysis.roots/1 (Identifies entry points and foundations)
Longest Dependency Chain Analysis.longest_dependency_chain/1 (Finds deepest dependency path)
Isolated Nodes Analysis.isolated_nodes/1 (Finds disconnected components with no edges)
Isolated Subsystems Analysis.isolated_subsystems/1 (Identifies disconnected component clusters)
Graph Soundness Validation Analysis.validate/1 (Checks for cycles, orphans, and structural issues)
Semantic Zoom Views Choreo.View.zoom/2 (Level 0: apps, Level 1: +libs, Level 2: +modules, Level 3: +ifaces, 4: all)

Summary

Question Function
"Are there cycles?" Analysis.cyclic?/1
"Where are the cycles?" Analysis.cyclic_dependencies/1
"In what order should I build?" Analysis.build_order/1
"What directly depends on X?" Analysis.direct_dependents/2
"What does X directly need?" Analysis.direct_dependencies/2
"What breaks if I change X?" Analysis.affected_by/2
"What does X need?" Analysis.depends_on/2
"Are layers respected?" Analysis.layer_violations/2
"Which edges are redundant?" Analysis.transitive_reduction/1
"How stable is each component?" Analysis.instability/1
"What's most coupled?" Analysis.centrality/2
"What's the deepest chain?" Analysis.longest_dependency_chain/1
"Which components are isolated?" Analysis.isolated_nodes/1
"Is the graph sound?" Analysis.validate/1
"Render to DOT" Dependency.to_dot/2
"Render to Mermaid" Dependency.to_mermaid/2

Dependency graphs as code mean your architecture is version-controlled, reviewable, and automatically checked for cycles, layer violations, and coupling hotspots. Every time you add a new module or library, you can immediately see the impact on build order, test parallelism, and architectural integrity โ€” before the code compiles.