Powered by AppSignal & Oban Pro

Choreo Dependency: Comprehensive Walkthrough

livebooks/dependency_walkthrough.livemd

Choreo Dependency: Comprehensive Walkthrough

Section

Mix.install([
  {:choreo, "~> 0.6"},
  {:kino_vizjs, "~> 0.5.0"}
])

> Rendering diagrams: This livebook uses Kino.VizJS to render DOT diagrams inline. You can also copy DOT output into PlantText or run dot -Tpng diagram.dot -o diagram.png locally.


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
alias Choreo.Dependency
alias Choreo.Dependency.Analysis

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.VizJS.render(Dependency.to_dot(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.new()
  |> Dependency.add_application(:api_gateway, label: "API Gateway")
  |> Dependency.add_application(:auth_service, label: "Auth Service")
  |> Dependency.add_application(:user_service, label: "User Service")
  |> Dependency.add_library(:phoenix, label: "Phoenix")
  |> Dependency.add_library(:ecto, label: "Ecto")
  |> Dependency.add_library(:jwt, label: "Joken JWT")
  |> Dependency.add_library(:redis, label: "Redix")
  |> Dependency.add_interface(:auth_iface, label: "AuthContract")
  |> Dependency.add_test(:api_tests, label: "API Tests")
  |> Dependency.add_test(:auth_tests, label: "Auth Tests")
  |> Dependency.depends_on(:api_gateway, :phoenix, type: :uses)
  |> Dependency.depends_on(:api_gateway, :auth_service, type: :calls)
  |> Dependency.depends_on(:api_gateway, :user_service, type: :calls)
  |> Dependency.depends_on(:auth_service, :phoenix, type: :uses)
  |> Dependency.depends_on(:auth_service, :ecto, type: :uses)
  |> Dependency.depends_on(:auth_service, :jwt, type: :imports)
  |> Dependency.depends_on(:auth_service, :redis, type: :uses)
  |> Dependency.depends_on(:auth_service, :auth_iface, type: :inherits)
  |> Dependency.depends_on(:user_service, :phoenix, type: :uses)
  |> Dependency.depends_on(:user_service, :ecto, type: :uses)
  |> Dependency.depends_on(:user_service, :auth_iface, type: :inherits)
  |> Dependency.depends_on(:api_tests, :api_gateway, type: :dev)
  |> Dependency.depends_on(:auth_tests, :auth_service, type: :dev)

Kino.VizJS.render(Dependency.to_dot(microservices))

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.new()
  |> Dependency.add_cluster("data", label: "Data Layer", fillcolor: "#dbeafe")
  |> Dependency.add_cluster("service", label: "Service Layer", fillcolor: "#dcfce7")
  |> Dependency.add_cluster("api", label: "API Layer", fillcolor: "#fef3c7")
  |> Dependency.add_module(:user_repo, label: "UserRepo", cluster: "data")
  |> Dependency.add_module(:order_repo, label: "OrderRepo", cluster: "data")
  |> Dependency.add_module(:user_service, label: "UserService", cluster: "service")
  |> Dependency.add_module(:order_service, label: "OrderService", cluster: "service")
  |> Dependency.add_module(:auth_service, label: "AuthService", cluster: "service")
  |> Dependency.add_module(:api_controller, label: "APIController", cluster: "api")
  |> Dependency.add_module(:web_socket, label: "WebSocket", cluster: "api")
  # Correct flows
  |> Dependency.depends_on(:user_service, :user_repo, type: :calls)
  |> Dependency.depends_on(:order_service, :order_repo, type: :calls)
  |> Dependency.depends_on(:auth_service, :user_repo, type: :calls)
  |> Dependency.depends_on(:api_controller, :user_service, type: :calls)
  |> Dependency.depends_on(:api_controller, :order_service, type: :calls)
  |> Dependency.depends_on(:web_socket, :auth_service, type: :calls)
  # VIOLATION: repo calls back up to API layer!
  |> Dependency.depends_on(:order_repo, :api_controller, type: :calls)
  # VIOLATION: API layer talks directly to another API module
  |> Dependency.depends_on(:web_socket, :api_controller, type: :calls)

Kino.VizJS.render(Dependency.to_dot(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.new()
  |> Dependency.add_module(:cart, label: "Cart Module")
  |> Dependency.add_module(:order, label: "Order Module")
  |> Dependency.add_module(:payment, label: "Payment Module")
  |> Dependency.add_module(:inventory, label: "Inventory Module")
  |> Dependency.add_module(:shipping, label: "Shipping Module")
  |> Dependency.add_module(:notification, label: "Notification Module")
  # Cycle 1: cart ↔ order
  |> Dependency.depends_on(:cart, :order, type: :calls)
  |> Dependency.depends_on(:order, :cart, type: :imports)
  # Cycle 2: order ↔ payment ↔ inventory → order
  |> Dependency.depends_on(:order, :payment, type: :calls)
  |> Dependency.depends_on(:payment, :inventory, type: :uses)
  |> Dependency.depends_on(:inventory, :order, type: :calls)
  # Clean edges
  |> Dependency.depends_on(:order, :shipping, type: :calls)
  |> Dependency.depends_on(:order, :notification, type: :calls)

Kino.VizJS.render(Dependency.to_dot(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

Which components are completely disconnected from each other?

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

Example 4: Instability and Coupling 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.new()
  |> Dependency.add_application(:api, label: "API")
  |> Dependency.add_application(:auth, label: "Auth")
  |> Dependency.add_application(:orders, label: "Orders")
  |> Dependency.add_library(:phoenix, label: "Phoenix")
  |> Dependency.add_library(:logger, label: "Logger")
  |> Dependency.add_library(:config, label: "Config")
  |> Dependency.depends_on(:api, :auth, type: :calls)
  |> Dependency.depends_on(:api, :orders, type: :calls)
  |> Dependency.depends_on(:api, :phoenix, type: :uses)
  |> Dependency.depends_on(:auth, :phoenix, type: :uses)
  |> Dependency.depends_on(:auth, :logger, type: :uses)
  |> Dependency.depends_on(:orders, :phoenix, type: :uses)
  |> Dependency.depends_on(:orders, :logger, type: :uses)
  |> Dependency.depends_on(:orders, :config, type: :uses)

Kino.VizJS.render(Dependency.to_dot(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: Deep Dependency Chain

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

deep =
  Dependency.new()
  |> Dependency.add_module(:web, label: "Web Layer")
  |> Dependency.add_module(:api, label: "API Layer")
  |> Dependency.add_module(:service, label: "Service Layer")
  |> Dependency.add_module(:repository, label: "Repository Layer")
  |> Dependency.add_module(:db_client, label: "DB Client")
  |> Dependency.add_module(:connection_pool, label: "Connection Pool")
  |> Dependency.add_module(:tcp_stack, label: "TCP Stack")
  |> Dependency.depends_on(:web, :api, type: :calls)
  |> Dependency.depends_on(:api, :service, type: :calls)
  |> Dependency.depends_on(:service, :repository, type: :calls)
  |> Dependency.depends_on(:repository, :db_client, type: :calls)
  |> Dependency.depends_on(:db_client, :connection_pool, type: :uses)
  |> Dependency.depends_on(:connection_pool, :tcp_stack, type: :uses)

Kino.VizJS.render(Dependency.to_dot(deep))

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: Fixing a Broken Dependency Graph

Start with a graph that has structural issues and use analysis as a checklist.

broken =
  Dependency.new()
  |> Dependency.add_module(:a, label: "Module A")
  |> Dependency.add_module(:b, label: "Module B")
  |> Dependency.add_module(:c, label: "Module C")
  |> Dependency.add_module(:orphan, label: "Orphan")
  |> Dependency.depends_on(:a, :b, type: :calls)
  |> Dependency.depends_on(:b, :c, type: :calls)
  |> Dependency.depends_on(:c, :a, type: :imports)
  # orphan has no edges

Kino.VizJS.render(Dependency.to_dot(broken))

Diagnosis

Analysis.validate(broken)
|> Enum.each(fn {sev, msg} ->
  icon = if sev == :error, do: "❌", else: "⚠️"
  IO.puts("#{icon} #{msg}")
end)

The Fix

Break the cycle by introducing an interface:

fixed =
  Dependency.new()
  |> Dependency.add_module(:a, label: "Module A")
  |> Dependency.add_module(:b, label: "Module B")
  |> Dependency.add_module(:c, label: "Module C")
  |> Dependency.add_interface(:contract, label: "Contract")
  |> Dependency.depends_on(:a, :b, type: :calls)
  |> Dependency.depends_on(:b, :c, type: :calls)
  |> Dependency.depends_on(:c, :contract, type: :inherits)
  |> Dependency.depends_on(:a, :contract, type: :inherits)

IO.puts("After fixing:")
Analysis.validate(fixed) |> IO.inspect()
Kino.VizJS.render(Dependency.to_dot(fixed))

Advanced: Custom Theming

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.VizJS.render(Dependency.to_dot(microservices, theme: brand_theme))

Summary

Question Function
“Where are the cycles?” Analysis.cyclic_dependencies/1
“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_subsystems/1
“Is the graph sound?” Analysis.validate/1
“Render to DOT” Dependency.to_dot/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.