The Graphviz app runs the real dot layout engine — Graphviz compiled to WebAssembly — so everything the DOT language can express, it renders, live as you type, straight to SVG. Seven engines are available (dot, neato, fdp, circo, twopi, osage, patchwork), but for architecture diagrams you want dot: it's the one that understands rank, and rank is how you make a diagram say "these are layers".
This piece assumes you've drawn a -> b -> c before and want diagrams you'd actually put in a design doc.
Direction and rank
dot lays nodes out in ranks — rows (or columns) of nodes at the same depth. Two attributes control the skeleton of every architecture diagram:
rankdir=TB(default) stacks ranks top to bottom — right for layered architectures, where "down" reads as "further from the user".rank=sameinside a subgraph pins a set of nodes to one rank, which is how you say "these three services are peers" even when the edges alone wouldn't align them.
digraph {
rankdir=TB;
lb -> api_a; lb -> api_b;
{ rank=same; api_a; api_b; }
api_a -> db; api_b -> db;
}
Without the rank=same line this happens to align anyway; in a real graph with uneven edge counts it won't, and pinning the tier is what keeps the picture honest.
Clusters group services into boxes
A subgraph whose name starts with cluster gets drawn as a labelled box around its members — your network boundary, your VPC, your "this is one deployable" line:
subgraph cluster_edge {
label="Edge";
style=rounded; color=gray60;
cdn; lb;
}
Everything you set inside applies to the cluster: label, style (rounded, dashed, filled), color, bgcolor. Nodes are members of whichever cluster they're first declared in, so declare every node deliberately inside its box rather than letting an edge conjure it into existence outside one.
Edge styling: sync vs async
A diagram that doesn't distinguish a blocking call from a fire-and-forget message is lying by omission. The convention that reads instantly:
api -> db // solid: synchronous call
api -> queue [style=dashed]; // dashed: async publish
queue -> worker [style=dashed];
worker -> db;
Add arrowhead=open for events if you want a second axis, and label="gRPC" or label="SQS" sparingly — every label costs layout room.
For structured node labels, shape=record works in this engine (one of the built-in samples uses it for an entity diagram): label="{User|id\lname\l}" gives you a titled box with left-aligned fields. For architecture boxes, though, plain shape=box, style=rounded is usually the better-looking answer.
Annotation edges: constraint=false
Sometimes an edge is commentary, not structure — "metrics flow to the collector" — and you don't want it dragging the layout around. constraint=false draws the edge but tells the ranker to ignore it:
api -> metrics [style=dotted, constraint=false];
db -> metrics [style=dotted, constraint=false];
Without it, the metrics node gets pulled a rank below everything that points at it and your tidy three-tier picture grows a basement.
A three-tier architecture in three steps
Step 1 — the skeleton. Nodes and honest edges only:
digraph arch {
rankdir=TB;
node [shape=box, style=rounded, fontname="system-ui"];
cdn -> lb;
lb -> web_a; lb -> web_b;
web_a -> api; web_b -> api;
api -> db; api -> cache; api -> queue;
queue -> worker; worker -> db;
}
Step 2 — pin the tiers and box the boundaries.
Step 3 — style the edge semantics. Together:
digraph arch {
rankdir=TB;
node [shape=box, style=rounded, fontname="system-ui"];
edge [fontname="system-ui", fontsize=10];
subgraph cluster_edge {
label="Edge"; style=rounded; color=gray60;
cdn [label="CDN"]; lb [label="Load balancer"];
}
subgraph cluster_app {
label="Application tier"; style=rounded; color=gray60;
web_a [label="web-1"]; web_b [label="web-2"];
api [label="API"];
{ rank=same; web_a; web_b; }
}
subgraph cluster_data {
label="Data tier"; style=rounded; color=gray60;
db [label="Postgres"]; cache [label="Redis"];
queue [label="Queue"]; worker [label="Worker"];
{ rank=same; db; cache; queue; }
}
cdn -> lb;
lb -> web_a; lb -> web_b;
web_a -> api; web_b -> api;
api -> db; api -> cache;
api -> queue [style=dashed, label="publish"];
queue -> worker [style=dashed];
worker -> db;
}
Paste that in and you get three labelled boxes, peers aligned within each, dashed lines where nothing blocks. When it renders, Copy SVG or Save exports it, and the Send button hands the SVG to the Editor or Notes for a final touch — it travels as text, because SVG is text.
The traps
rank=same only works within one subgraph scope. The rank constraint applies to the nodes listed together in the same anonymous subgraph — { rank=same; a; b; }. Listing rank=same in two places doesn't merge them into one row; each block is its own constraint. And when your nodes live inside clusters, put the rank=same block inside that cluster: a cluster is laid out as its own unit, and a rank constraint reaching across a cluster boundary is at best ignored and at worst distorts the box. If two nodes in different clusters must align, you usually want them in the same cluster after all — the layout is telling you something about the architecture.
The cluster prefix is magic, and its absence is silent. subgraph cluster_data draws a box; subgraph data_tier draws nothing — it's still a perfectly legal subgraph, just an invisible grouping, so there's no error to catch. If your box vanished, check the name before anything else. (Same trap in reverse: renaming cluster_a to a_cluster while tidying "fixes" nothing and deletes your boundary.)