Choreo C4 Model: Comprehensive Walkthrough
Mix.install([
{:choreo, "~> 0.9.0"},
# {:choreo, path: Path.expand("~/repos/elixir/choreo"), force: true},
{:kino_vizjs, "~> 0.9.0"}
])
Section
Rendering diagrams: This livebook uses
Kino.VizJSto render DOT diagrams inline. It also supports rendering to native Mermaid.js syntax usingto_mermaid/2viaKino.Mermaid, which is supported natively in Livebook!
What is the C4 Model?
The C4 Model is a simple, hierarchical approach to visualising software architecture at different levels of abstraction. Created by Simon Brown, it helps teams communicate architecture to both technical and non-technical stakeholders.
The four levels are:
| Level | Name | Audience | Question answered |
|---|---|---|---|
| L1 | System Context | Everyone | What is this system, and who uses it? |
| L2 | Containers | Developers | What are the high-level technology choices? |
| L3 | Components | Developers | How is a container decomposed? |
| L4 | Code | Developers | What do the classes/interfaces look like? |
The Choreo Approach: One Model, Multiple Views
Unlike other drawing tools where you have to manually draw and maintain separate, redundant diagrams for each level (which quickly go out of sync), Choreo.C4 lets you model your entire architecture in a single, unified graph.
Once modeled, you use view lenses (Choreo.View.zoom/2 and Choreo.View.focus/3) to automatically generate System Context, Container, and Component diagrams.
Choreo automatically handles:
- Relationship Roll-up: Detailed component-level connections roll up to container-level and system-level connections when you zoom out.
- Diagram Scoping: Zooming in automatically expands only the software system or container in scope, collapsing other components and external entities.
- Auto-Clustering: Automatically groups elements inside visual boundaries (clusters) using parent metadata.
Node Types & Shapes
| Type | DOT Shape | Mermaid Shape | Purpose |
|---|---|---|---|
person |
Ellipse (thick border) |
Circle ((Label)) |
User, actor, role |
software_system |
Box (solid border) |
Rounded rect [Label] |
External or in-scope system |
container |
Box (rounded border) |
Stadium ([Label]) |
App, database, file system |
component |
Box (dashed border) |
Subroutine [[Label]] |
Controller, service, repository |
alias Choreo.C4
alias Choreo.C4.Analysis
legend =
C4.new()
|> C4.add_person(:person, label: "Person")
|> C4.add_software_system(:system, label: "Software System")
|> C4.add_container(:container, label: "Container", technology: "Elixir", parent: :system)
|> C4.add_component(:component, label: "Component", technology: "GenServer", parent: :container)
Kino.Layout.tabs(
Mermaid: Kino.Mermaid.new(C4.to_mermaid(legend)),
Graphviz: Kino.VizJS.render(C4.to_dot(legend))
)
Step 1: Defining the Unified Architecture Model
Let’s construct a complete model for an Internet Banking Application. We define people, systems, containers, and components, and connect them at their most specific level.
alias Choreo.C4
model =
C4.new()
# 1. Persons
|> C4.add_person(:customer,
label: "Personal Banking Customer",
description: "A customer of the bank who wants to view balances and make payments."
)
# 2. Software Systems
|> C4.add_software_system(:banking,
label: "Internet Banking System",
description: "Allows customers to view financial info and make payments.",
scope: :in
)
|> C4.add_software_system(:mainframe,
label: "Mainframe Banking System",
description: "Stores all core bank account, transaction, and customer details.",
scope: :out
)
|> C4.add_software_system(:email,
label: "E-mail System",
description: "The internal Microsoft Exchange e-mail system.",
scope: :out
)
# 3. L2 Containers (parent: :banking)
|> C4.add_container(:web_app,
label: "Single-Page Application",
technology: "React & TypeScript",
description: "Provides internet banking features to customers via their web browser.",
parent: :banking
)
|> C4.add_container(:api,
label: "API Application",
technology: "Elixir & Phoenix",
description: "Provides internet banking functionality via a JSON API.",
parent: :banking
)
|> C4.add_container(:db,
label: "Database",
technology: "PostgreSQL",
description: "Stores user credentials, hashed passwords, and audit logs.",
parent: :banking
)
# 4. L3 Components (parent: :api)
|> C4.add_component(:signin_controller,
label: "Sign In Controller",
technology: "Phoenix Controller",
description: "Authenticates users and generates JWT sessions.",
parent: :api
)
|> C4.add_component(:accounts_controller,
label: "Accounts Controller",
technology: "Phoenix Controller",
description: "Fetches summaries and transactions for bank accounts.",
parent: :api
)
|> C4.add_component(:accounts_context,
label: "Accounts Context",
technology: "Elixir Module",
description: "Coordinates access to account summaries and mainframe calls.",
parent: :api
)
|> C4.add_component(:auth_service,
label: "Security Service",
technology: "Elixir Module",
description: "Provides token generation, verification, and credentials checking.",
parent: :api
)
|> C4.add_component(:mailer,
label: "Mailer",
technology: "Swoosh / SMTP Adapter",
description: "Formats and sends transactional e-mails.",
parent: :api
)
# 5. Relationships (defined at their most detailed/specific levels)
|> C4.add_relationship(:customer, :web_app,
label: "Visits banking site using",
technology: "HTTPS"
)
|> C4.add_relationship(:web_app, :signin_controller,
label: "Submits credentials to",
technology: "JSON / HTTPS"
)
|> C4.add_relationship(:web_app, :accounts_controller,
label: "Requests account details from",
technology: "JSON / HTTPS"
)
|> C4.add_relationship(:signin_controller, :auth_service,
label: "Authenticates via"
)
|> C4.add_relationship(:accounts_controller, :auth_service,
label: "Authorizes requests using"
)
|> C4.add_relationship(:accounts_controller, :accounts_context,
label: "Fetches summaries from"
)
|> C4.add_relationship(:auth_service, :db,
label: "Verifies hashes in",
technology: "SQL / TCP"
)
|> C4.add_relationship(:accounts_context, :db,
label: "Reads transactions from",
technology: "SQL / TCP"
)
|> C4.add_relationship(:accounts_context, :mainframe,
label: "Fetches core ledger data from",
technology: "gRPC"
)
|> C4.add_relationship(:signin_controller, :mailer,
label: "Requests alert email from"
)
|> C4.add_relationship(:mailer, :email,
label: "Dispatches SMTP traffic to",
technology: "SMTP"
)
|> C4.add_relationship(:email, :customer,
label: "Delivers notifications to",
technology: "Exchange Protocol"
)
Step 2: L1 System Context Diagram
By zooming to level: 0, Choreo hides all containers and components, collapsing them up to their parent software systems. Relationships are rolled up automatically:
-
:customer -> :web_approlls up to:customer -> :banking -
:mailer -> :emailand:auth_service -> :dbare rolled up, ignoring self-loops on:banking.
# Render DOT (Graphviz)
context_view = Choreo.View.zoom(model, level: 0)
Kino.Layout.tabs(
Mermaid: Kino.Mermaid.new(Choreo.C4.to_mermaid(context_view, direction: :td)),
Graphviz: Kino.VizJS.render(Choreo.C4.to_dot(context_view, rankdir: :bt), height: "400px")
)
Step 3: L2 Container Diagram
Zooming to level: 1 automatically targets the in-scope software system (:banking), expanding it to show its containers (:web_app, :api, and :db) inside a boundary cluster, while keeping external systems (:mainframe, :email) and people collapsed.
All component-level relationships inside the API application roll up to target the :api container itself.
# Render DOT (Graphviz)
container_view = Choreo.View.zoom(model, level: 1)
Kino.Layout.tabs(
Mermaid: Kino.Mermaid.new(Choreo.C4.to_mermaid(container_view)),
Graphviz: Kino.VizJS.render(Choreo.C4.to_dot(container_view, rankdir: :bt), height: "500px")
)
Step 4: L3 Component Diagram
To view components, we zoom to level: 2. We set the active scope to the container we want to expand (:api).
Choreo expands :api to show all of its internal components (:signin_controller, :auth_service, etc.) inside a dashed container boundary, while keeping other containers (:web_app, :db) collapsed at the container level!
# Render DOT (Graphviz)
component_view =
model
|> C4.set_scope(:api)
|> Choreo.View.zoom(level: 2)
Kino.Layout.tabs(
Mermaid: Kino.Mermaid.new(Choreo.C4.to_mermaid(component_view)),
Graphviz:
Kino.VizJS.render(Choreo.C4.to_dot(component_view, rankdir: :bt),
height: "800px"
)
)
Focus Views
Sometimes you only want to show a specific node and its immediate neighborhood rather than a complete level view.
Neighborhood around DB
hops = fn i -> Choreo.View.focus(model, :db, radius: i) end
Kino.Layout.tabs(
"1-Hop": hops.(1) |> C4.to_mermaid() |> Kino.Mermaid.new(),
"2-Hop": hops.(2) |> C4.to_mermaid() |> Kino.Mermaid.new(),
"3-Hop": hops.(3) |> C4.to_mermaid() |> Kino.Mermaid.new()
)
Shortest Path from Customer to Database
path_view = Choreo.View.focus_between(model, :customer, :db)
Kino.Mermaid.new(C4.to_mermaid(path_view))
Themes & Customizations
Choreo supports standard theme overrides to produce professional and clean layouts. Let’s render the component view in different styles.
Themes
Kino.Layout.tabs(
Warm: Kino.Mermaid.new(C4.to_mermaid(component_view, theme: :warm)),
Ocean: Kino.Mermaid.new(C4.to_mermaid(component_view, theme: :ocean)),
Forest: Kino.Mermaid.new(C4.to_mermaid(component_view, theme: :forest))
)
Model Verification & Quality Analysis
Using the Choreo.C4.Analysis suite, we can run static analysis checks on our model to find typical architectural diagram design flaws.
Validate Structural Soundness
Checks for isolated nodes, missing parents, missing descriptions, missing technology labels, and blank relationships.
Analysis.validate(model)
Detect Missing Technology Labels
Containers and components should indicate their technology stack. Let’s see what is missing in our model (it should be empty, since our model is complete!).
Analysis.missing_technology(model)
Detect Missing Descriptions
Analysis.missing_descriptions(model)
Detect Parents Without Relationships
This finds systems or containers that have children, but do not have any relationships connecting them, indicating that you might have forgotten to connect their children to other elements.
Analysis.parents_without_relationships(model)
Cross-Diagram Semantic Tracing
Because Choreo allows you to compose different diagrams together using Choreo.embed/4, you can use semantic tracing to link your static C4 components to runtime workflows or database entities.
For example, let’s embed a User Login Workflow and a Database ERD into our system architecture, and then draw semantic traces connecting them.
# 1. Define a runtime login workflow
login_flow =
Choreo.Workflow.new()
|> Choreo.Workflow.add_start(:start)
|> Choreo.Workflow.add_task(:verify_credentials)
|> Choreo.Workflow.add_end(:stop)
|> Choreo.Workflow.connect(:start, :verify_credentials)
|> Choreo.Workflow.connect(:verify_credentials, :stop)
# 2. Define database tables in an ERD
database_erd =
Choreo.ERD.new()
|> Choreo.ERD.add_table(:users, columns: [%{name: :id, type: :integer}])
# 3. Create a master composed system and embed C4, Workflow, and ERD
system =
Choreo.new()
|> Choreo.add_cluster("banking_vpc", label: "Banking Infrastructure")
|> Choreo.embed(model, "banking_vpc", prefix: "c4_")
|> Choreo.embed(login_flow, "banking_vpc", prefix: "wf_")
|> Choreo.embed(database_erd, "banking_vpc", prefix: "erd_")
# 4. Declare semantic tracing links (executes, stores)
system =
system
|> Choreo.trace(:wf_verify_credentials, :c4_auth_service, type: :executes)
|> Choreo.trace(:c4_auth_service, :erd_users, type: :stores)
1. Visualizing Traces
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 Graphviz (DOT), trace edges are automatically rendered with constraint=false so the trace lines do not warp or disrupt the layout of the static C4 or Workflow diagrams.
Kino.Layout.tabs(
Mermaid: Kino.Mermaid.new(Choreo.to_mermaid(system, show_traces: true)),
Graphviz: Kino.VizJS.render(Choreo.to_dot(system, show_traces: true), height: "800px")
)
2. Performing Cross-Diagram Impact Analysis
If the database table users changes, which components and business tasks are affected? We can traverse the trace relationships backwards to run impact analysis:
# Returns list of impacted nodes: [:c4_auth_service, :wf_verify_credentials]
Choreo.Analysis.Tracing.impact_analysis(system, :erd_users)
3. Finding Execution Paths
We can calculate the tracing path (sequence of nodes) from a user task down to the database schema:
# Returns the sequence: [:wf_verify_credentials, :c4_auth_service, :erd_users]
{:ok, path} = Choreo.Analysis.Tracing.trace_path(system, :wf_verify_credentials, :erd_users)
4. Focusing on a Semantic 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(system, :wf_verify_credentials, :erd_users)
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))
)
Exporting to Markdown (Mermaid.js)
You can output native Mermaid flowchart syntax to embed your diagrams directly in markdown pages (like GitHub readmes, wikis, or Notion).
# Let's print out the Mermaid syntax for the Container diagram
IO.puts(C4.to_mermaid(container_view))
Further Reading
- C4 Model — Official site
- Software Architecture for Developers — Simon Brown’s book
- Choreo.C4 module documentation