Why Diagrams-as-Code?
Diagrams-as-code means your architecture documentation lives in your repository, diffs with your PRs, and never drifts out of sync because a developer forgot to update a Confluence page. When the code changes, the diagram changes — ideally automatically.
The core principle: a diagram you can't version control is a diagram you can't trust. Visio screenshots and draw.io exports rot the moment they're committed.
Treat diagrams like tests. They should live next to the code they describe, run in CI, and fail loudly when stale.
The Four Questions Every Diagram Answers
- What exists? — Context & component diagrams. The static inventory of your system.
- How does it connect? — Dependency graphs, class diagrams. Structural relationships.
- What happens at runtime? — Sequence diagrams, event flows. Dynamic behavior.
- How does it change? — State machines. How an entity moves through its lifecycle.
Choosing Your Format
Mermaid is the pragmatic default — native in GitHub, GitLab, Notion, and most documentation platforms. PlantUML is more powerful but requires a render server. Use the right tool for the audience.
🧜 Mermaid
Renders natively in GitHub/GitLab markdown. Best for sequence, flowcharts, state, ER, and basic C4. No install required for readers.
🌱 PlantUML
More complete UML spec support. Better for C4 (via library), component diagrams, and anything Mermaid can't express. Needs a render server or local Java.
📐 Structurizr
The official C4 model tool by Simon Brown. DSL-first, workspace-based, exports to PlantUML/Mermaid. Best for teams committing to C4 seriously.
🔷 D2
Modern alternative with cleaner syntax than PlantUML. Auto-layout is excellent. Growing ecosystem. Good for architecture overviews.
| Diagram Type | Mermaid | PlantUML | Structurizr | D2 |
|---|---|---|---|---|
| Context (C4 L1) | OK | Best | Native | OK |
| Component | OK | Best | Native | Good |
| Sequence | Best | Good | OK | OK |
| Class/ER | Good | Best | No | OK |
| State Machine | Good | Good | No | OK |
| Event Storming | Manual | Manual | No | Manual |
The C4 Framework — Four Levels of Zoom
The C4 model, created by Simon Brown, is a hierarchical approach to documenting software architecture. Think of it like Google Maps: you zoom in for more detail, each level answering a more specific question.
- Level 1 — System Context: Your system and everything outside it. Users, external services, third-party APIs. The "30,000-foot view." Audience: anyone — business stakeholders, PMs, new engineers.
- Level 2 — Container: The deployable units inside your system — web app, background worker, database, cache, message broker. Audience: developers and architects.
- Level 3 — Component: Inside a single container, the major structural blocks — Rails engines, service objects, modules. Audience: developers working on that container.
- Level 4 — Code: Class diagrams, ER diagrams. Usually auto-generated. Audience: developers in that component. (Often skipped — the code IS the documentation at this level.)
In Rails apps, a "container" is typically: your Rails web process, Sidekiq worker process, PostgreSQL, Redis, and any external APIs you call. Start there for your L2 diagram.
C4 Context Diagram (Level 1) — Mermaid
%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#2a6b8a'}}}%% C4Context title System Context — MyApp %% Actors Person(user, "End User", "Uses the web interface") Person(admin, "Admin", "Manages system via back-office") %% Your system — the box System(myapp, "MyApp", "Rails monolith with background jobs") %% External systems System_Ext(stripe, "Stripe", "Payment processing") System_Ext(sendgrid, "SendGrid", "Transactional email") System_Ext(s3, "AWS S3", "File storage") %% Relationships Rel(user, myapp, "Uses", "HTTPS") Rel(admin, myapp, "Administers", "HTTPS") Rel(myapp, stripe, "Charges cards via", "HTTPS API") Rel(myapp, sendgrid, "Sends email via", "HTTPS API") Rel(myapp, s3, "Stores files in", "AWS SDK")
Mermaid's C4 support is still maturing. For the most complete C4 output, use PlantUML with the C4-PlantUML library, which gives you boundary boxes, proper icon support, and correct layout.
C4 Container Diagram (Level 2) — PlantUML
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml LAYOUT_WITH_LEGEND() title Container Diagram — MyApp Person(user, "User") Person(admin, "Admin") System_Boundary(myapp, "MyApp") { Container(web, "Rails App", "Ruby on Rails", "Handles HTTP, renders views, exposes API") Container(worker, "Sidekiq Workers", "Ruby/Sidekiq", "Processes background jobs") ContainerDb(db, "PostgreSQL", "PostgreSQL 15", "Primary data store") ContainerDb(cache, "Redis", "Redis 7", "Job queue, Rails cache, sessions") } Rel(user, web, "HTTPS") Rel(web, db, "ActiveRecord", "TCP") Rel(web, cache, "Enqueues jobs, reads cache", "Redis protocol") Rel(worker, db, "Reads/writes", "TCP") Rel(worker, cache, "Dequeues jobs", "Redis protocol")
System Context — The High-Level View
Context diagrams answer "what is this system, and who/what does it talk to?" They're the entry point for any new team member and the diagram most useful for non-technical stakeholders.
Key Rules for Context Diagrams
- Your system is a single box — don't show internals
- Label all relationships with the protocol or technology
- Include human actors (users, admins, external teams)
- Show external systems — don't hide dependencies
- Keep it to one page. Ruthlessly prune.
Simple Flowchart Context in Mermaid (no C4 library needed)
flowchart TB %% Style definitions classDef system fill:#2a6b8a,color:#fff,stroke:#1a4b6a classDef external fill:#6a6a5a,color:#fff,stroke:#4a4a3a classDef person fill:#d4521a,color:#fff,stroke:#a43212 User([👤 End User]):::person Admin([👤 Admin]):::person App["**MyApp**\nRails on Fly.io"]:::system Stripe[Stripe\nPayments]:::external SG[SendGrid\nEmail]:::external S3[AWS S3\nStorage]:::external Twilio[Twilio\nSMS]:::external User -->|HTTPS| App Admin -->|HTTPS| App App -->|REST API| Stripe App -->|SMTP/API| SG App -->|SDK| S3 App -->|REST API| Twilio
Put context diagrams in your repo's root README.md or a top-level docs/architecture/ directory. GitHub renders Mermaid blocks natively in markdown — no image exports needed.
Component & Dependency Diagrams
Component diagrams zoom into a single container and show its internal structure — services, modules, classes, and how they depend on each other. In Rails, this is the layer where you document your service objects, query objects, presenters, and engines.
Class Diagram in Mermaid
Use class diagrams to show domain models and their relationships, including inheritance, composition, and associations.
classDiagram class Order { +Integer id +String status +Decimal total_cents +place() +cancel() +refund() } class OrderItem { +Integer quantity +Decimal unit_price +subtotal() Decimal } class Product { +String name +String sku +Boolean in_stock? } class Customer { +String email +lifetime_value() Decimal } class PaymentProcessor { <<Service>> +charge(order) Result +refund(order) Result } Customer "1" --> "many" Order : places Order "1" *-- "many" OrderItem : contains OrderItem "many" --> "1" Product : references Order ..> PaymentProcessor : uses
Module/Service Dependency Graph (Mermaid)
Show how your Rails service objects, concerns, and modules depend on each other. This is invaluable for identifying circular dependencies and god objects.
flowchart LR %% Controllers subgraph Controllers OC[OrdersController] PC[PaymentsController] end %% Services subgraph Services OP[OrderPlacer] PR[PaymentRefunder] NS[NotificationService] IS[InventoryService] end %% Models subgraph Models O[Order] P[Product] C[Customer] end OC --> OP PC --> PR OP --> O OP --> IS OP --> NS PR --> O PR --> NS IS --> P NS --> C
Auto-Generating Rails Dependency Graphs
The rails-mermaid_erd gem auto-generates ER diagrams from your ActiveRecord models. Install it and add a Rake task:
# Gemfile (development group) gem 'rails-mermaid_erd', group: :development gem 'railroady', group: :development # for class/model diagrams
# Generate ER diagram from your AR models bundle exec rake erd:mermaid # Railroady — generates SVG/dot class diagrams bundle exec railroady -M | dot -Tsvg > docs/diagrams/models.svg bundle exec railroady -C | dot -Tsvg > docs/diagrams/controllers.svg
Static Diagrams — Structure & Data
Static diagrams describe things that exist, not things that happen. They answer: "What is the shape of this system?" Three types are most useful in Rails work.
Entity Relationship (ER) Diagrams
ER diagrams map your database schema. Mermaid's erDiagram type is excellent for this.
erDiagram CUSTOMERS { int id PK string email string name timestamp created_at } ORDERS { int id PK int customer_id FK string status int total_cents timestamp placed_at } ORDER_ITEMS { int id PK int order_id FK int product_id FK int quantity int unit_price_cents } PRODUCTS { int id PK string name string sku boolean active } CUSTOMERS ||--o{ ORDERS : places ORDERS ||--|{ ORDER_ITEMS : contains PRODUCTS ||--o{ ORDER_ITEMS : referenced_in
You can auto-generate Mermaid ER diagrams from your Rails schema using rails-mermaid_erd. Run it as part of your migration workflow so diagrams stay current.
State Machine Diagrams
Document AASM or state_machines-activerecord transitions explicitly. State diagrams make edge cases visible that are easy to miss in code.
stateDiagram-v2 [*] --> pending : Order created pending --> payment_processing : User submits payment payment_processing --> paid : Payment succeeds payment_processing --> payment_failed : Payment declined paid --> fulfilling : Inventory confirmed paid --> refunded : Admin refunds fulfilling --> shipped : Label created shipped --> delivered : Carrier confirms shipped --> lost : 30-day timeout payment_failed --> pending : User retries payment_failed --> cancelled : Abandoned (48h) delivered --> [*] cancelled --> [*] refunded --> [*] note right of fulfilling : Triggers FulfillmentJob
Database Schema Visualization (rake task)
# lib/tasks/diagrams.rake namespace :diagrams do desc "Generate Mermaid ER diagram from current schema" task erd: :environment do output = MermaidErd.generate File.write("docs/diagrams/erd.md", output) puts "ER diagram written to docs/diagrams/erd.md" end end # Hook into db:migrate automatically if Rails.env.development? Rake::Task["db:migrate"].enhance do Rake::Task["diagrams:erd"].invoke end end
Runtime Diagrams & Event Storming
Runtime diagrams capture what happens over time — the choreography of a request, the sequence of messages, the order of side effects. These are the diagrams developers actually reach for when debugging a bug they can't reproduce.
Sequence Diagrams
Sequence diagrams are the workhorse of runtime documentation. Use them for API request flows, webhook handlers, multi-service orchestration, and anywhere the order of operations matters.
sequenceDiagram autonumber actor U as User participant C as OrdersController participant OP as OrderPlacer participant DB as PostgreSQL participant Q as Redis/Sidekiq Queue U->>C: POST /orders C->>OP: call(order_params) OP->>DB: BEGIN transaction OP->>DB: INSERT order OP->>DB: UPDATE inventory (lock rows) alt Inventory available OP->>DB: COMMIT OP->>Q: Enqueue PaymentJob(order_id) OP->>C: Success(order) C->>U: 201 Created else Out of stock OP->>DB: ROLLBACK OP->>C: Failure(:out_of_stock) C->>U: 422 Unprocessable end
Event Storming in Mermaid
Event storming is a workshop technique, but you can capture its output as diagrams. The key notation: domain events (things that happened, past tense), commands (what triggered them), and read models (what queries consume them).
Use a horizontal timeline flowchart to represent the event flow:
flowchart LR %% Color coding via classDef mimics sticky notes classDef event fill:#d4521a,color:#fff,stroke:none classDef command fill:#2a6b8a,color:#fff,stroke:none classDef policy fill:#7a5a9a,color:#fff,stroke:none classDef readmodel fill:#2a7a4a,color:#fff,stroke:none classDef actor fill:#c8a000,color:#000,stroke:none %% Commands (blue) PlaceOrder[Place Order]:::command ProcessPayment[Process Payment]:::command FulfillOrder[Fulfill Order]:::command %% Events (orange) OrderPlaced([Order Placed]):::event PaymentProcessed([Payment Processed]):::event PaymentFailed([Payment Failed]):::event OrderFulfilled([Order Fulfilled]):::event OrderShipped([Order Shipped]):::event %% Policies (purple) — "when X then Y" Policy1{When OrderPlaced\nthen start payment}:::policy Policy2{When PaymentProcessed\nthen begin fulfillment}:::policy %% Read models (green) OrderDash[(Order Dashboard)]:::readmodel %% Flow PlaceOrder --> OrderPlaced OrderPlaced --> Policy1 Policy1 --> ProcessPayment ProcessPayment --> PaymentProcessed ProcessPayment --> PaymentFailed PaymentProcessed --> Policy2 Policy2 --> FulfillOrder FulfillOrder --> OrderFulfilled OrderFulfilled --> OrderShipped OrderPlaced --> OrderDash PaymentProcessed --> OrderDash
Event storming diagrams are most useful when created collaboratively. Use the Mermaid format to capture the session output, not to run the session itself. Miro or physical stickies for the workshop; Mermaid for the permanent record.
Representing Sidekiq & Async Jobs
Asynchronous operations are where most Rails diagrams fall apart — they're invisible in standard request/response flows. The trick is making the queue explicit and showing the temporal gap between enqueue and execution.
Key Conventions for Async Flows
- Use dashed arrows for async/deferred operations
- Label the queue name explicitly:
-->>|sidekiq:critical| - Show Sidekiq as a separate participant with a clock indicator
- Distinguish enqueue-time from execution-time visually
- Show retry behavior and dead-letter queues for critical jobs
Sidekiq Job Flow — Sequence Diagram
sequenceDiagram participant Web as Rails Web Process participant Redis as Redis Queue participant SK as Sidekiq Worker participant Stripe as Stripe API participant DB as PostgreSQL participant Email as SendGrid note over Web,Redis: Enqueue phase (synchronous, fast) Web->>Redis: LPUSH sidekiq:critical PaymentJob{order_id: 42} Redis-->>Web: OK (enqueued) Web-->>Web: Return 201 to user immediately note over Redis,SK: Some time later... SK->>Redis: BRPOP sidekiq:critical Redis-->>SK: PaymentJob payload note over SK,DB: Execution phase (async, can fail + retry) SK->>DB: SELECT * FROM orders WHERE id=42 FOR UPDATE SK->>Stripe: POST /v1/charges Stripe-->>SK: {status: "succeeded"} SK->>DB: UPDATE orders SET status='paid' SK->>Email: POST /v3/mail/send (receipt) rect rgba(212,82,26,0.08) note over SK,Redis: On failure: Sidekiq retries with exponential backoff SK->>Redis: LPUSH sidekiq:retry (job + retry_count + next_run_at) end
Job Dependency DAG
When jobs trigger other jobs, document the dependency graph. This prevents circular chains and makes the async architecture explicit.
flowchart TD classDef job fill:#1a1a1a,color:#e8dcc8,stroke:#3a3a3a classDef trigger fill:#2a6b8a,color:#fff,stroke:none classDef queue stroke:#d4521a,stroke-width:2px %% Trigger WebReq[HTTP Request\nPOST /orders]:::trigger %% Job graph J1[PaymentJob\nqueue: critical]:::job J2[FulfillmentJob\nqueue: default]:::job J3[EmailReceiptJob\nqueue: low]:::job J4[InventoryUpdateJob\nqueue: default]:::job J5[AnalyticsTrackJob\nqueue: low]:::job WebReq -->|enqueues| J1 J1 -->|on success: enqueues| J2 J1 -->|always enqueues| J3 J2 -->|enqueues| J4 J1 -->|enqueues| J5 %% Dead letter DLQ[(Dead Letter Queue)] J1 -.->|after 25 retries| DLQ J2 -.->|after 25 retries| DLQ
Documenting Scheduled Jobs (Cron)
flowchart LR classDef cron fill:#7a5a9a,color:#fff,stroke:none classDef job fill:#1a1a1a,color:#e8dcc8,stroke:#3a3a3a C1[⏰ Daily 2am UTC]:::cron C2[⏰ Every 15min]:::cron C3[⏰ Weekly Mon 6am]:::cron J1[ExpiredSessionCleanupJob]:::job J2[CacheWarmingJob]:::job J3[WeeklyReportJob]:::job C1 --> J1 C2 --> J2 C3 --> J3
For complex Sidekiq setups, document your queue priorities and concurrency configuration explicitly in a diagram. Show how many threads each queue gets and why — this is rarely documented and constantly confusing for new team members.
Keeping Diagrams Fresh — Auto-generation
Diagrams are only useful if they're accurate. Manual updates don't scale. Here's how to wire up your Rails app so diagrams regenerate automatically when the code changes.
Gems Worth Installing
| Gem | Generates | Format |
|---|---|---|
rails-mermaid_erd |
ER diagram from AR models | Mermaid markdown |
railroady |
Model & controller class diagrams | SVG via Graphviz |
annotate |
Schema comments on models/specs | Ruby comments |
bullet |
N+1 warnings (feeds into docs) | Logs |
Rake Task: Regenerate All Diagrams
# lib/tasks/diagrams.rake namespace :diagrams do DIAGRAM_DIR = Rails.root.join("docs/diagrams") desc "Regenerate all architecture diagrams" task all: [:erd, :models, :controllers] task erd: :environment do puts "→ Generating ER diagram..." output = MermaidErd.generate File.write(DIAGRAM_DIR.join("erd.md"), output) end task models: :environment do puts "→ Generating model diagram..." sh "bundle exec railroady -M --transitive | dot -Tsvg -o #{DIAGRAM_DIR}/models.svg" end task controllers: :environment do puts "→ Generating controller diagram..." sh "bundle exec railroady -C | dot -Tsvg -o #{DIAGRAM_DIR}/controllers.svg" end # Hook ER generation into migrations Rake::Task["db:migrate"].enhance { Rake::Task["diagrams:erd"].invoke } end
Git Hook: Regenerate on Schema Change
#!/bin/bash
# .git/hooks/post-merge (also add to post-checkout)
CHANGED=$(git diff HEAD@{1} HEAD --name-only)
if echo "$CHANGED" | grep -q "db/schema.rb"; then
echo "🔄 Schema changed — regenerating diagrams..."
bundle exec rake diagrams:erd
git add docs/diagrams/erd.md
git commit -m "chore: regenerate ER diagram after schema change" --no-verify
fi
Guard Integration (watch for file changes in dev)
# Guardfile guard :rake, task: 'diagrams:erd' do watch('db/schema.rb') end guard :rake, task: 'diagrams:models' do watch(%r{^app/models/.+\.rb$}) end
CI/CD: GitHub Actions
# .github/workflows/diagrams.yml name: Regenerate Diagrams on: push: paths: ['db/schema.rb', 'app/models/**'] jobs: diagrams: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Ruby uses: ruby/setup-ruby@v1 with: bundler-cache: true - name: Install Graphviz run: sudo apt-get install -y graphviz - name: Regenerate diagrams run: bundle exec rake diagrams:all - name: Commit updated diagrams uses: stefanzweifel/git-auto-commit-action@v5 with: commit_message: "chore: auto-regenerate architecture diagrams [skip ci]" file_pattern: 'docs/diagrams/**'
A Practical Documentation Workflow
Here's a concrete workflow for adding diagrams to a feature from scratch.
- New feature branch: Before writing code, sketch the C4 L2 container view and a sequence diagram for the happy path. Writing the diagram first forces architectural decisions up front. This is design-by-diagram.
- During implementation: Keep the sequence diagram as a living doc in the PR description. Update it as the implementation diverges from the plan. (It will. That's fine.)
- New model or migration? Run
rake diagrams:erdand commit the updated ER diagram in the same PR as the migration. Reviewers see schema changes and diagram together. - Async job added? Add it to the job dependency graph. If it's a new Sidekiq class, add a sequence diagram showing its enqueue/execute flow.
- State machine change? Update the state diagram in the same commit as the AASM/state_machines change. Never let these drift.
- PR review: Add
docs/diagrams/as a required review path in CODEOWNERS so architecture changes get architectural review. - Post-merge: CI auto-regenerates any auto-generated diagrams (ER, class diagrams) and commits them back. Hand-maintained diagrams (context, sequence) are committed manually.
Directory Structure
docs/
└── diagrams/
├── README.md # Index with embedded diagrams
├── context.md # C4 L1 — system context (manual)
├── containers.md # C4 L2 — containers (manual)
├── erd.md # ER diagram (auto-generated)
├── models.svg # Class diagram (auto-generated)
├── state-machines/
│ ├── order.md # Order state machine
│ └── subscription.md # Subscription state machine
├── sequences/
│ ├── checkout-flow.md # Checkout sequence diagram
│ └── webhook-flow.md # Stripe webhook handling
└── jobs/
└── job-dependencies.md # Sidekiq job graph
Add a docs/diagrams/README.md that embeds all your Mermaid diagrams inline. GitHub renders them beautifully, giving you a visual architecture dashboard that any team member can browse without special tooling.
The Golden Rule
A diagram that requires manual updates will become wrong.
Auto-generate everything you can. Version control everything else.
The best diagram is the one that exists.