On this page
Language
Proschi language reference
Proschi documents describe an architecture (nodes, groups, connections) and
use cases (step-by-step request flows) as plain text. The parser lives in
frontend/src/dsl/ and never throws: problems come back as diagnostics with a
line and column, and everything else still renders.
The parser is the definition of the language. The same code powers the web
editor, the proschi command line and the language server, so every editor
reports the same problems. See Editor support for VS Code,
IntelliJ, Neovim, Helix, Sublime Text and CI, and the grammar below
for a summary of the syntax.
title "E-Commerce Platform"
group vpc "AWS VPC" {
gateway "API Gateway" [AWS API Gateway] @Platform
orders "Order Service" [REST API] @Orders "Handles order processing"
}
ordersDb "Orders DB" [PostgreSQL]
events "OrderEvents" [Kafka]
gateway -> orders : HTTP
orders -> ordersDb : SQL
orders -> events : Publish
usecase "Create order" {
gateway -> orders : POST /api/orders json {
"sku": "A1"
}
alt "Created" {
par {
orders -> ordersDb : INSERT order
orders ->> events : OrderCreated {"orderId": "o-1"}
}
orders --> gateway : 201 {"status": "pending"}
} alt "Invalid payload" {
orders --> gateway : 400 {"error": "sku required"}
} alt "DB down" {
orders -x ordersDb : INSERT order
orders --> gateway : 503
}
}
The architecture is written once. Any number of use cases can play over it,
and each use case can branch into scenarios (success and error paths) with
alt. A document can also carry a high-level design:
traffic, requirements, capacity, the data model, decisions and tests.
The canonical style is what proschi fmt (or Format code in the editor)
produces: two spaces per block and aligned columns in runs of similar lines; see
Formatting.
Statements #
| Statement | Syntax | Notes |
|---|---|---|
| Title | title "Text" ["Summary"] |
The optional second string is the system summary. |
| Import | import "path.proschi" |
Top level only. Adds everything the file declares. See Imports. |
| Node | id ["Name"] [Tech] [@team] ["Description"] [pos x,y] [x3] |
Parts after the id may come in any order; the first string is the name, the second the description. x3 sets the number of replicas (default 1). |
| Group | group id ["Name"] [Style] [pos x,y] { … } |
Holds nodes, nested groups and connections. Style is a grouping tech: Logical Group (default), Network Boundary, Security Zone, Service Group. |
| Connection | a -> b [: label] |
Architecture edge. Undeclared ids become plain nodes automatically. |
| Use case | usecase "Name" ["Description"] { steps } |
Top level only. |
| Parallel steps | par { steps } |
Inside a use case or an alt block; steps in one block run in parallel. Cannot be nested. |
| Scenario | alt "Name" [when "condition"] { steps } |
Inside a use case or another alt. See Scenarios. |
| Traffic, requirements, capacity | traffic { … }, requirements { … }, capacity { … } |
Top level only. See High-level design. |
| Entity | entity Name [in store] ["Description"] { fields } |
Top level only. The data model. |
| Decision | decision "Title" { … } or decision "Title" because "Reason" |
Top level only. |
| Test | test "Name" { assertions } |
Top level only. Flow assertions. |
| Comment | # … |
Anywhere a token can start. A # inside quotes or a JSON payload is kept. |
- Ids are letters, digits and
_, starting with a letter or_. - Tech names a technology from the catalog (about 210 of them, see Tech stacks), e.g.
[AWS Lambda],[Redis]or[Spring Boot]. It decides the node's icon, its kind and its default numbers in the simulation. - Annotation tech stacks (
Text Note,Sticky Note,Comment) make text nodes. The description, or else the name, becomes the text.
Tech stacks #
The catalog lives in frontend/src/catalog/componentCatalog.ts; the editor's
and the language server's completion list all of it. Each entry has the names
people usually write as aliases, so [S3], [Amazon S3] and [AWS S3] are
the same node, and so are [Postgres] and [PostgreSQL], [ALB] and
[AWS Load Balancer], [k8s] and [Kubernetes]. Matching ignores case,
spaces and punctuation ([route 53], [pubsub], [nodejs]) and a trailing
version ([PostgreSQL 16], [Redis 7.2]). The parse output always holds the
catalog name.
| Group | Examples |
|---|---|
| Generic, one per kind | Service, Worker, Database, NoSQL Database, Cache, Message Queue, Object Storage, CDN, Load Balancer, API Gateway, DNS, WAF, Function, Search Engine, Data Warehouse |
| Services and runtimes | REST API, gRPC, GraphQL, WebSocket, Spring Boot, Kotlin, Java, Go, Node.js, Python, Django, FastAPI, .NET, Kubernetes, Docker, AWS ECS, AWS EKS, GCP GKE, Azure AKS |
| Functions | AWS Lambda, GCP Cloud Functions, GCP Cloud Run, Azure Functions, Cloudflare Workers |
| Relational and NoSQL | PostgreSQL, MySQL, MariaDB, SQL Server, Oracle, AWS Aurora, AWS RDS, GCP Cloud SQL, CockroachDB, GCP Spanner, DynamoDB, Cassandra, ScyllaDB, MongoDB, Azure Cosmos DB, Neo4j, etcd |
| Caches | Redis, Valkey, Memcached, AWS ElastiCache, GCP Memorystore, Hazelcast |
| Queues and streams | Kafka, AWS Kinesis, AWS SQS, AWS SNS, RabbitMQ, GCP Pub/Sub, NATS, AWS EventBridge, Azure Service Bus, Apache Pulsar, Redpanda |
| Search and analytics | Elasticsearch, OpenSearch, Solr, GCP BigQuery, Snowflake, AWS Redshift, ClickHouse, InfluxDB, TimescaleDB |
| Storage | AWS S3, GCP Cloud Storage, Azure Blob Storage, MinIO, Cloudflare R2 |
| Edge | AWS CloudFront, Fastly, Akamai, Cloudflare, AWS Load Balancer, nginx, Envoy, HAProxy, Kubernetes Ingress, AWS API Gateway, Kong, AWS Route53, AWS WAF |
| External | Stripe, PayPal, Twilio, SendGrid, Auth0, Okta, APNs, FCM, Payment Gateway, Third Party API |
A tech the catalog does not know is a warning, not an error, so a document
that uses one still renders and simulates. The node keeps the text you wrote
and is drawn and simulated as the kind its name suggests: [TigerBeetle DB]
is a database, [Acme Queue] a queue, [In-house Search Index] a search
node. Without a telling word it takes the kind of the closest catalog tech
([Postgress] is a database), and otherwise it is a service. It is never a
client: an unknown node has a finite capacity, a latency and a price. The
warning names the closest catalog tech when there is one, and the language
server offers to replace the name with it:
Unknown tech stack 'Postgress'. Did you mean 'PostgreSQL'? Until then it is simulated as a generic database, like [Database]
To keep a product name the catalog lacks without the warning, use the generic
tech of its kind instead ([Database], [Message Queue]) and put the product
in the node's name or description.
Use case steps #
| Arrow | Meaning |
|---|---|
a -> b : … |
Synchronous request |
a ->> b : … |
Asynchronous, fire and forget. It becomes async request/response if a reply follows. |
b --> a : … |
Response to the latest unanswered request from a to b |
a -x b : … |
Failed call: the request never gets an answer (timeout, connection refused). It cannot be answered with -->. |
How a step label is read:
- A request label may start with
x<N>(fan-out: the step happens N times per request) and~<size>(payload size), in either order, e.g.worker -> feeds : x200 LPUSH feed:{follower}orclient -> blobs : ~2MB PUT /files/{id}. They are removed before the rest of the label is read. See Fan-out and payload size. POST /orderssets the HTTP method and endpoint. Only the standard verbs count: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS.- The path may be a template with
{param}segments, e.g.GET /orders/{id}. Braces inside the path are part of it, not a payload. - After the method and path,
json …,xml …ortext …sets the payload format and body. A body that starts with{,[or<is recognised without a keyword. - A JSON payload can span several lines. It continues until its brackets balance.
- Any other text becomes the step name, e.g.
INSERT orderorOrderCreated {…}. - On a response, a leading three-digit number is the status code, e.g.
201 {"id": 1}.
proschi check can compare these steps with the OpenAPI specs of the services
they call: endpoints, status codes and JSON payloads. See
Checking against OpenAPI.
Fan-out and payload size #
| Prefix | Meaning | Parse output |
|---|---|---|
x<N> |
The step happens N times per request (N ≥ 1). Load counts N calls; latency counts the step once (batched or parallel). | multiplier |
~<size> |
Payload size: a number (decimals allowed) and B, KB, MB, GB or TB. Units are decimal: 1 MB = 1000 KB. It adds transfer time and egress cost. |
sizeBytes |
A prefix counts only at the start of a request label and only when followed
by whitespace or the end of the label, so xml payload, x-request-id and
GET /x200 are ordinary labels. x0, a prefix given twice and a malformed
size (~2mb, ~2, ~MB) are errors at the prefix. Response (-->) labels
and connection labels outside use cases are read as written.
Reads and writes #
Every request step has an access, read or write:
- With an HTTP method:
POST,PUT,PATCHandDELETEwrite;GET,HEADandOPTIONSread. - Otherwise the first word of the label (after any prefixes, compared case-insensitively, the whole word) decides. The words in the table below write.
- Everything else reads: GET, SELECT, QUERY, SCAN, FETCH, LOOKUP, GEOSEARCH,
GetItem, MGET, an event name such as
OrderPlaced, …
| Write verbs | Typical labels |
|---|---|
| INSERT, UPDATE, UPSERT, DELETE, CREATE | INSERT order, UPDATE seat SET held |
| PUT, SET, WRITE, APPEND, SAVE, STORE, UPLOAD, COMMIT, RECORD, MARK | PUT pastes/k7Qz2, STORE body, MARK paid |
| PUTITEM, UPDATEITEM, DELETEITEM, BATCHWRITEITEM | DynamoDB: PutItem Url |
| INCR, INCRBY, DECR, HINCRBY, LPUSH, RPUSH, ZADD, HSET, SADD, XADD, MSET, GEOADD | Redis: INCR rate:42, ZADD feed:7 p_981 |
| RESERVE, HOLD, BOOK, CHARGE | claiming a resource or money: HOLD seat 12A, CHARGE card |
| PUBLISH, SEND, ENQUEUE, PRODUCE, EMIT, NOTIFY | handing something on: EMIT OrderShipped, NOTIFY bob |
Words that only start like a verb (Stored, Booking, Notification) read.
durable requirements and writes X before responding count write steps
only, and reads and writes load a store's read and write capacity separately.
How the protocol is inferred:
| Condition | Protocol |
|---|---|
->> arrow, or a queue target |
MESSAGING |
An HTTP method and a GraphQL, gRPC or SOAP API target |
GRAPHQL, GRPC or SOAP |
| Any other HTTP method | REST |
| Anything else | OTHER |
Steps should follow the architecture: when a step goes between two nodes that
no connection joins (in either direction), the parser warns
No connection between 'a' and 'b' in the architecture; add 'a -> b'. For a
response the suggested connection points the way the request went. Calls of a
node to itself are fine, and a document without any connection (only use
cases) is not checked. The language server offers a quick fix that adds the
connection.
Scenarios #
One endpoint rarely has one outcome. alt blocks split a use case into
scenarios without copying the steps they share:
usecase "Get order" {
gateway -> orders : GET /api/orders/42 # shared by every scenario
alt "Found" {
orders -> ordersDb : SELECT order 42
orders --> gateway : 200 {"id": 42}
} alt "Not found" {
orders -> ordersDb : SELECT order 42
orders --> gateway : 404 {"error": "not_found"}
} alt "DB timeout" {
orders -x ordersDb : SELECT order 42
orders --> gateway : 504
}
gateway ->> audit : OrderViewed # shared again, after every branch
}
altblocks that follow each other directly are one set of alternatives. Close one and open the next on the same line (} alt "B" {) or on the next line; a step between two blocks starts a new set.- Each branch becomes a scenario. Steps before a set are shared by all its branches, and steps after it are added to every branch.
altblocks can be nested. Nested sets, and several sets one after another, multiply: two sets of two branches give four scenarios, named likeA › X. A use case keeps at most 32 scenarios; the parser warns past that.- A response inside a branch can answer a request made before the branch.
- A scenario is an error path when the use case's first request is answered
with a 4xx or 5xx status, or fails with
-x. Playback draws error replies and failed calls in red. - A use case without
althas a single scenario.
A branch can say when it happens: alt "Not found" when "no order has that id" {
(also } alt "…" when "…" {). The condition is free text in quotes; when is
a keyword only in this position, so it still works as an id elsewhere. The
editor shows the condition on the scenario tab's tooltip and as a When: line
during playback. For nested branches the conditions on the path are joined
with ·, e.g. cache miss · db down.
Scenario ids are the slugged branch names (not-found, a-x), used in links.
Grouping by endpoint #
The use case picker in the editor groups use cases by the HTTP method and path
of their first step, e.g. all POST /api/orders flows together. Use cases
that don't start with an HTTP call are listed under Other flows.
Path templates. Calls to the same endpoint with different ids share a group:
- A path with a
{param}segment groups by itself:GET /orders/{id}. - Otherwise, a concrete path joins a template endpoint of another use case in
the document with the same method that matches it, where
{param}stands for exactly one non-empty segment:GET /orders/order-789joinsGET /orders/{id}. - Otherwise, segments that are all digits or a UUID are read as
{id}:GET /orders/42andGET /orders/43group asGET /orders/{id}.
The use case's endpoint stays the literal path; the group key is
endpointGroup in the parse output.
Imports #
Write the infrastructure once and keep use cases in other files, e.g. one per team or domain:
# infra.proschi
title "Shop infrastructure"
gateway "API Gateway" [AWS API Gateway]
orders "Order Service" [REST API] @Orders
ordersDb [PostgreSQL]
gateway -> orders
orders -> ordersDb
# checkout.proschi
title "Checkout"
import "infra.proschi"
usecase "Place order" {
gateway -> orders : POST /orders
orders -> ordersDb : INSERT order
orders --> gateway : 201
}
- The path is relative to the importing file. Imports may be nested: an imported file can import others.
- Everything an imported file declares (nodes, groups, connections, use cases) becomes part of the diagram. The importing document's
titlenames it; titles of imported files are ignored. - A file imported along several paths (
aandbboth importinfra) is included once. An import cycle is an error on the import that closes it:Import cycle: a.proschi → b.proschi → a.proschi. A file that does not exist is an error:Cannot find 'x.proschi'. - Ids are shared by all files: declaring the same id in two files is an error at the later declaration, naming the file and line of the first. Use case ids stay unique too (a repeated name gets a
-2suffix). - Undeclared ids become plain nodes only after every file is read, so a use case file may use nodes its imports declare.
- Problems in an imported file are reported with that file's path (
fileinproschi parseoutput).
Where imports are resolved:
| Where | Resolved against |
|---|---|
| Web editor | The diagrams saved in the browser, by file name. A diagram opened from a .proschi file keeps its name; others are named after their title when they are created, e.g. title "Shop Infra" → shop-infra.proschi. The name stays when the title changes, so imports keep working; rename it with the pencil in the Diagrams menu, where it is shown. A path that matches no saved name exactly falls back to the one diagram with the same file name. |
proschi check / parse, language server |
The file system. The language server prefers the text of open, unsaved documents. |
In the web editor, nodes and connections from imported files are drawn like any other, but canvas edits only change the open document: moving, renaming or deleting something declared in an imported file is refused with a short message. Edit that file instead. The problems panel lists problems in imported files with the file name in front; clicking one opens that diagram.
High-level design #
Beyond what talks to what, a document can say how much traffic flows, how fast and reliable the system must be, what data lives where and why it is built this way. These statements are blocks at the top level; each line inside a block follows that block's own rules. The design they describe is in docs/design/hld-and-practice.md; how the simulation turns them into numbers, and how far to trust those numbers, is on How the simulation works.
title "URL Shortener" "Turns long URLs into short codes and redirects visitors"
api "Shortener API" [REST API] x12
cache "Code cache" [Redis] x2
db "URL store" [PostgreSQL] x3
# … connections and the use cases "Redirect" and "Shorten"
traffic {
"Redirect" 100k rps mix "Cache hit" 90%, "Cache miss" 10%
"Shorten" 1k rps
}
requirements {
p99 "Redirect" < 100ms
availability >= 99.9%
durable "Shorten"
survive any node failure
cost <= 3000 usd/month
}
capacity {
db 20k rps latency 4ms
}
entity Url in db "One short code and where it points" {
code string key
target string
createdAt time index
}
decision "Cache redirects in Redis" {
because "Reads outnumber writes 100:1 and p99 must stay under 100 ms"
rejected "Read replicas only" "About 5 ms per read and many replicas at 100k rps"
}
test "Redirects are served from the cache" {
"Redirect" calls any cache before any database
"Redirect" scenario "Cache hit" never calls any database
}
The URL shortener HLD example in the editor is a complete document using every statement.
The words that open these blocks are keywords only in that shape (traffic {,
entity Name …, decision "…" { or decision "…" because, test "…" {),
so older documents that use them as node ids, e.g. test "Test runner" [REST API], still parse. A malformed line inside a block is an error on that
line and the rest of the block is still read; a misplaced block (inside a
group or a use case) is an error and its lines are skipped. Imported files
contribute all of these sections. In the parse output each section is left
out when the document has none.
Quantities #
A quantity is a number, an optional magnitude and an optional unit:
| Part | Values |
|---|---|
| Number | 120, 99.95 |
| Magnitude | k (thousand), m (million), b (billion) |
| Unit | rps, rpm, rpd (requests per second, minute, day); ms, s; %; usd/month; MB/s, GB/s (bandwidth); usd/GB (egress price) |
The unit may be attached (50ms, 99.9%, 100krps) or one space away
(50 ms, 100k rps). Rates are normalised to requests per second (6k rpm is
100 rps), durations to milliseconds (1.5s is 1500 ms) and bandwidth to
megabytes per second (1 GB/s is 1000 MB/s). ms is always
milliseconds. A statement that wants a unit reports a quantity without one, or
with the wrong one: '100k' needs a unit: expected a rate, e.g. 100k rps, 6k rpm or 1m rpd.
Traffic #
traffic {
"Redirect" 100k rps mix "Cache hit" 90%, "Cache miss" 10%
"Shorten" 1k rps
}
- One line per use case:
"<use case>" <rate>. mixsplits the use case's traffic over its scenarios by their full names (A › Bfor nested branches; a use case withoutalthas one scenario, named like the use case). Withoutmix, all traffic goes to the first scenario.- Shares should add up to 100%. Otherwise there is a warning and they are
scaled to fit. In the parse output shares are fractions (
0.9). - Unknown use case or scenario names are warnings, checked once every file is read. A second line for the same use case is an error.
Requirements #
requirements {
p99 "Redirect" < 50ms
p99 "Redirect" scenario "Cache hit" < 10ms
p95 < 300ms # every use case
availability "Redirect" >= 99.95%
availability >= 99.9% # every use case
durable "Shorten"
survive any node failure
survive failure of cache # a node id, [Tech], or any <kind>
cost <= 3000 usd/month
}
| Requirement | Meaning |
|---|---|
p50, p90, p95, p99 or p999 ["Use case"] < <duration> |
Latency percentile of the use case (or of every use case with traffic) under its traffic, over all its scenarios mixed by their shares |
p99 "Use case" scenario "S" < <duration> |
The same for one scenario of the use case. An unknown scenario is a warning. |
availability ["Use case"] >= <percent> |
Computed availability of the use case |
durable "Use case" |
Every success scenario writes to a durable node, synchronously, before the entry request is answered |
survive any node failure |
Losing any single node instance keeps every use case working: with replicas, the node does not saturate and every latency requirement that held still holds |
survive failure of <selector> |
The same, for the selected nodes only |
cost <= <usd/month> |
Sum of replica costs and egress |
<= is also accepted for latency and > for availability. Each requirement
becomes a test that the simulation evaluates.
Capacity #
capacity {
db 20k rps latency 4ms availability 99.95% cost 400 usd/month durable
cache 150k rps
users reads 30k rps writes 8k rps shards 4 consistency strong
blobs bandwidth 500 MB/s egress 0.05 usd/GB
pay timeout 300ms
}
Per-replica overrides of the default profile of a node, one line per node id, parts in any order:
| Part | Meaning | Parse output |
|---|---|---|
<rate> |
Requests per second per replica, reads and writes alike | rps |
reads <rate>, writes <rate> |
Separate read and write capacity per replica | readRps, writeRps |
shards <n> |
Number of shards (n ≥ 1). Single-primary stores scale writes with shards, not replicas. | shards |
latency <duration> |
Latency per call | latencyMs |
availability <percent> |
Availability per replica | availability |
cost <usd/month> |
Monthly cost per replica | costUsd |
durable or volatile |
Whether a write there is durable | durable |
consistency strong or consistency eventual |
What any strong store / any eventual store match |
consistency |
bandwidth <MB/s or GB/s> |
Network bandwidth per replica, for transfer time (and, for nodes you run, how many bytes they can move) | bandwidthMBps |
egress <usd/GB> |
Price of data the node sends to clients and third parties (internet egress) | egressUsdPerGb |
timeout <duration> |
What a failed call (-x) to the node costs; 1 000 ms by default |
timeoutMs |
An unknown node id is a warning, and so are shards or consistency on a
node that is not a data store. A part given twice, a rate together with
reads or writes, and a second line for the same node are errors.
Entities #
entity Url in db "One short code and where it points" {
code string key
target string
createdAt time index
}
entity <Name> [in <node id>] ["Description"] {, then one field per line:<name> <type> {flag}with the flagskey,index,uniqueandoptional. Types are free identifiers:string,int,time,uuid,json, …in <node>places the entity in a store. It is a warning when that node is not a data store: a cache, database, search, analytics, queue or storage node (see kinds).
Decisions #
decision "Cache redirects in Redis" {
because "Reads outnumber writes 100:1 and p99 must stay under 50 ms"
rejected "Read replicas only" "About 5 ms per read and many replicas at 100k rps"
rejected "Memcached" "No replication; losing a node empties the cache"
}
decision "Base62 codes from a counter" because "Short, unique, no collisions to retry"
because "<reason>" at most once, and any number of
rejected "<option>" "<reason>". The one-line form takes only because.
Tests #
Tests check how the design works. Every assertion line in a test must hold.
test "Redirect is served from the cache" {
"Redirect" calls any cache before any database
"Redirect" scenario "Cache hit" never calls any database
}
test "Clients only enter through the gateway" {
no path from client to any database
}
test "Checkout answers before slow work" {
"Checkout" never waits for any queue or any external
"Checkout" calls ledger after gateway
any service never calls blobs
"Retry charge" starts at any queue
}
| Assertion | Holds when |
|---|---|
U [scenario S] calls X |
some scenario of U (or S) has a step to a node matching X |
U [scenario S] every scenario calls X |
every scenario of U calls X |
U [scenario S] never calls X |
no scenario of U (or S) calls X |
U [scenario S] calls X before Y |
in every scenario that calls Y, X is called earlier; and some scenario calls Y |
U [scenario S] calls Y after X |
in every scenario that calls both, the last call to Y comes after the first call to X; and some scenario calls both |
U [scenario S] never waits for X |
no synchronous (->) call to X happens before U's entry response. Async sends and calls after the response are fine; it also holds when X is never called |
U [scenario S] writes X before responding |
every success scenario has a synchronous, non-failed step to X before the entry response |
U [scenario S] responds <status> |
some scenario's entry response has that status (201, or a class 2xx/4xx/5xx) |
U has scenario S |
the scenario exists |
U handles failure of X |
some success scenario of U contains a failed call (-x) to X |
U starts at X |
U's entry request is sent by a node matching X |
[in U] X calls Y |
some step (of U, or of any use case) is sent by a node matching X to a node matching Y |
[in U] X never calls Y |
no step (of U, or of any use case) is sent by X to Y |
no path from X to Y |
no connection or step goes directly from a node matching X to a node matching Y |
X has replicas >= <n> |
every node matching X has at least n replicas |
U and S are a use case and a scenario name in quotes. An assertion that
starts with a quoted use case is about that use case ("U" calls X: some step
reaches X); one that starts with a selector or in "U" is about the sender
(X calls Y: a step sent by X). has scenario, handles failure of and
starts at are about the whole use case and take no scenario. Order in a scenario
is the sequence order, requests and responses interleaved. Unknown use cases,
scenarios (after scenario) and nodes are warnings; a test without
assertions is a warning and two tests with one name are an error.
Selectors and kinds #
X and Y above, and survive failure of, take a selector:
| Selector | Matches |
|---|---|
db |
the node with that id |
[PostgreSQL] |
every node with that tech stack |
any database |
every node of that kind |
any strong store, any eventual store |
every data store of that consistency (relational databases and queues are strong; caches, most NoSQL stores, search, CDNs and object storage listings are eventual; capacity { x consistency … } overrides) |
X or Y [or Z] |
a node matching any of them, e.g. never calls any cache or any database |
A node's kind comes from its tech stack (an unknown tech: from its name, see Tech stacks):
| Kind | Tech stacks (examples) |
|---|---|
client |
Actor, Browser, Mobile App, shapes and nodes without a tech |
edge |
WAF, AWS WAF, Global Accelerator; any edge also selects the four kinds below |
cdn |
CDN, CloudFront, Fastly, Akamai, Cloudflare, Azure CDN, Cloud CDN, Front Door |
loadbalancer |
Load Balancer, AWS Load Balancer (ALB, NLB, ELB), nginx, Envoy, HAProxy, Traefik, Ingress |
gateway |
API Gateway, AWS API Gateway, Azure API Management, Apigee, Kong |
dns |
DNS, Route53, Azure DNS, Cloud DNS |
service |
Service, Worker, REST API, gRPC, GraphQL, WebSocket, Spring Boot, Go, Node.js, Kubernetes, EC2, ECS, EKS, Fargate, VMs |
function |
Function, Lambda, Cloud Functions, Azure Functions, Cloud Run, App Engine, Cloudflare Workers |
cache |
Cache, Redis, Valkey, ElastiCache, Memcached, Memorystore, Hazelcast, Aerospike |
database |
Database, PostgreSQL, MySQL, Aurora, RDS, CockroachDB, Spanner, DynamoDB, Cassandra, ScyllaDB, MongoDB, Cosmos DB, Neo4j, … |
search |
Search Engine, Elasticsearch, OpenSearch, Solr, Algolia |
analytics |
Data Warehouse, BigQuery, Snowflake, Redshift, ClickHouse, InfluxDB, TimescaleDB |
queue |
Message Queue, Kafka, SQS, SNS, Kinesis, Pub/Sub, RabbitMQ, NATS, Service Bus, … |
storage |
Object Storage, S3, Blob Storage, Cloud Storage, MinIO, R2, EFS, EBS |
external |
Third Party API, Payment Gateway, Stripe, Twilio, SendGrid, Auth0, Email/SMS Service |
other |
groups, text nodes and the Note shape |
Editing on the canvas #
The text is the source of truth. Edits on the diagram are written back into it:
| Canvas action | Text change |
|---|---|
| Drag a node or group | Adds or updates pos x,y on its declaration. Positions of group members are relative to the group. |
| Double-click a node | Sets its display name: id "New name". |
| Drag from one node's dot to another's | Adds a connection, a -> b. |
| Auto-layout button | Removes every pos x,y. |
A node that was only referenced, never declared, gets a declaration line above the first use case.
Links #
The address bar always holds the whole document: #code=…. When the document
imports other files, their text travels along as &imports=… (a compressed map
of path to source), so the link renders the same for someone who has none of
the files. Those files stay attached to the diagram opened from the link and
win over saved diagrams of the same name. While a use case is playing, the link also names the use case, the scenario (for use cases with alt blocks) and the step, e.g. #code=…&uc=create-order&alt=db-down&step=2, so a shared link opens playback at that step of that scenario.
Grammar #
A summary in EBNF. The language is line-oriented: each statement takes one line, except a step or connection label whose JSON payload continues until its brackets balance. Whitespace between tokens is ignored.
document = { line } ;
line = [ statement ] [ comment ] newline ;
statement = title | import | node | connection
| group-open | usecase-open | par-open | alt-open | close
| traffic | requirements | capacity | entity | decision | test ;
title = "title" , ( string | id ) , [ string ] ; (* 2nd string: summary *)
import = "import" , string ; (* a relative file path *)
node = id , { string | tech | team | position | replicas } ; (* 1st string: name, 2nd: description *)
connection = id , arrow , id , [ ":" , label ] ;
group-open = "group" , id , [ string ] , [ tech ] , [ position ] , "{" ;
usecase-open = "usecase" , ( string | id ) , [ string ] , "{" ;
par-open = "par" , "{" ;
alt-open = "alt" , ( string | id ) , [ "when" , string ] , "{" ;
close = "}" , [ alt-open ] ;
traffic = "traffic" , "{" , { string , quantity , [ "mix" , share , { "," , share } ] } , "}" ;
share = string , quantity ; (* "Cache hit" 90% *)
requirements = "requirements" , "{" , { requirement } , "}" ;
requirement = percentile , [ string , [ "scenario" , string ] ] , "<" , quantity
| "availability" , [ string ] , ">=" , quantity
| "durable" , string
| "survive" , ( "any" , "node" , "failure" | "failure" , "of" , selector )
| "cost" , "<=" , quantity ;
percentile = "p50" | "p90" | "p95" | "p99" | "p999" ;
capacity = "capacity" , "{" , { id , { capacity-part } } , "}" ;
capacity-part = quantity | "reads" , quantity | "writes" , quantity | "shards" , integer
| "latency" , quantity | "availability" , quantity | "cost" , quantity
| "durable" | "volatile" | "consistency" , ( "strong" | "eventual" )
| "bandwidth" , quantity | "egress" , quantity | "timeout" , quantity ;
entity = "entity" , id , [ "in" , id ] , [ string ] , "{" , { id , id , { flag } } , "}" ;
flag = "key" | "index" | "unique" | "optional" ;
decision = "decision" , string , ( "because" , string
| "{" , { "because" , string | "rejected" , string , string } , "}" ) ;
test = "test" , string , "{" , { assertion } , "}" ;
assertion = string , [ "scenario" , string ] , ( "calls" , selector , [ ( "before" | "after" ) , selector ]
| "every" , "scenario" , "calls" , selector | "never" , "calls" , selector
| "never" , "waits" , "for" , selector
| "writes" , selector , "before" , "responding" | "responds" , status )
| string , "has" , "scenario" , string
| string , "handles" , "failure" , "of" , selector
| string , "starts" , "at" , selector
| [ "in" , string ] , selector , [ "never" ] , "calls" , selector
| "no" , "path" , "from" , selector , "to" , selector
| selector , "has" , "replicas" , ">=" , integer ;
selector = selector-atom , { "or" , selector-atom } ;
selector-atom = id | tech | "any" , kind | "any" , ( "strong" | "eventual" ) , "store" ;
kind = "client" | "edge" | "service" | "function" | "cache" | "database"
| "search" | "analytics" | "queue" | "storage" | "external" | "other" ;
status = digit , digit , digit | digit , "xx" ; (* 201, 4xx *)
arrow = "->" | "->>" | "-->" | "-x" ;
position = "pos" , integer , "," , integer ;
replicas = "x" , digit , { digit } ; (* one token, e.g. x3 *)
quantity = number , [ magnitude ] , [ unit ] ; (* 50ms, 100k rps, 99.9% *)
number = digit , { digit } , [ "." , digit , { digit } ] ;
magnitude = "k" | "m" | "b" ;
unit = "rps" | "rpm" | "rpd" | "ms" | "s" | "%" | "usd/month" | "MB/s" | "GB/s" | "usd/GB" ;
id = ( letter | "_" ) , { letter | digit | "_" } ;
string = '"' , { character - '"' | "\" , character } , '"' ;
tech = "[" , { character - "]" } , "]" ;
team = "@" , { letter | digit | "_" | "-" } ;
integer = [ "-" ] , digit , { digit } ;
comment = "#" , { character } ; (* only where a token can start *)
label = [ label-prefix , [ label-prefix ] ] , { character } ; (* to end of line; see below *)
label-prefix = ( "x" , digit , { digit } | "~" , number , size-unit ) , whitespace ; (* requests only *)
size-unit = "B" | "KB" | "MB" | "GB" | "TB" ;
Where each statement may appear:
| Statement | Top level | In group |
In usecase / alt |
In par |
|---|---|---|---|---|
title |
✓ | |||
import |
✓ | |||
| node | ✓ | ✓ | ||
group |
✓ | ✓ | ||
connection (a -> b) |
✓ | ✓ | as a step | as a step |
usecase |
✓ | |||
par |
✓ | |||
alt |
✓ | |||
traffic, requirements, capacity, entity, decision, test |
✓ |
-x is only valid as a step. A label is read as described in
Use case steps: optional x<N> / ~<size> prefixes on a request, an optional HTTP method and path, an optional
json / xml / text payload, and on a response a leading status code.
The grammar describes syntax only. The parser also checks meaning: duplicate
ids, unknown tech stacks (a warning), responses without a matching request, steps between
nodes the architecture does not connect, import cycles and missing imported
files, unknown use cases, scenarios and nodes in the high-level design
sections, and so on.
Those checks are what the language server and proschi check report.