Generated documentation
Hand-written architecture documents fall behind the code. A diagram drawn last quarter does not show the listener added last week, and nobody can tell which parts are still true. sofie’s answer follows from its fixed structure: because the framework already reads the whole app into one model, the documentation can be a view of that model, regenerated whenever the code changes and checked like any other generated file.
What sofie docs writes
Section titled “What sofie docs writes”sofie docs writes Markdown with Mermaid diagrams into docs/gen/. It is a command of sofie-cli
behind its docs feature, which is on by default, and it is never part of an app’s runtime.
sofie docs # write docs/gen/sofie docs --check # report pages that differ from the code, write nothing| Page | Derived from | What it shows |
|---|---|---|
README.md |
The model | An index of the pages below |
architecture.md |
Manifests, dependencies, areas, contracts | Modules by tier and the edges between them; a table of areas (prefix, principal, defining contract, modules routing in); a table of contracts (areas, events and their listeners, generated exports, implemented traits) |
modules/<module>.md |
Everything in the module | Its areas and routes with each route’s description, permission, owner scope, flag and modifiers; screens; data model; state machines; read models; inputs with their constraints; events with who raises and who hears them; cross-owner checks; jobs and schedules; flags; config keys and settings |
flows.md |
Routes, handler signatures and bodies, aggregate methods, events, listeners, jobs | A sequence diagram per endpoint and per schedule |
security.md |
Routes, areas, permissions, modifiers, inputs, gateways, split modules, config | Who reaches each area and which vendors the app calls; a row per route with its access, permission, owner scope, rate limit and checks; the public endpoints; each vendor’s allow-list keys; secret config keys; what the model cannot check |
telemetry.md |
Routes, handlers, listeners, jobs, schedules, tasks, contracts, gateways, #[span], product_event!, field types |
How spans nest; each span name the app produces; each metric with its labels; product events; fields logged only as their mask |
Every page starts with a header saying it is generated by sofie docs and must not be edited.
Inside a module page
Section titled “Inside a module page”A module page has two diagrams beside its tables:
- Data model. An entity diagram per module, built from schemas, owners, behaviors and ids. A
reference to another module’s record, such as an
AccountId, is drawn as a dashed edge, since no foreign key crosses modules. - State machines. A state diagram per status enum with
#[to]transitions. A state it never leaves is drawn as final.
The routes table is the module’s security surface at a glance. From the example shop:
| Method | Path | Name | Description | Modifiers |
|---|---|---|---|---|
| GET | /account/orders |
order.customer.list_orders |
— | screen(orders) |
| POST | /account/orders |
order.customer.place_order |
The order is sent to the email the customer’s account has now. | then_screen(orders#orders) |
Prose comes from doc comments
Section titled “Prose comes from doc comments”The generator writes no prose of its own about an app. Descriptions come from the code’s doc
comments: the first line of an action’s /// is its description everywhere it appears, in the
module page’s routes table and under its flow.
/// The order is sent to the email the customer's account has now.pub async fn place_order( customer: Principal<Customer>, input: PlaceOrder, read: Read, clock: Clock,) -> Result<Order> {How flows are derived
Section titled “How flows are derived”A flow is the chain a piece of work follows: entry point, action, aggregate methods, events, listeners, further actions and gateway calls. Nobody writes it; it is drawn from declarations. The model joins edges it can read exactly, without type inference:
- Endpoint to handler, from
routes.rs. A schedule starts its own flow. - Function to function, from calls whose path resolves to a function of the module through
crate,self,superand the file’s imports. - Function to aggregate method, from calls on an argument whose type the signature names
(
order: Order, thenorder.cancel()), onselfinside an impl, or written asOrder::place(…). - To an event, from
.raise(OrderPlaced { … })wherever it is written. - Event to listener, from
#[on]and the type of the listener’s first argument, in every module. - To a job, from
jobs.queue(actions::send_receipt, …)naming a#[job]. - To a contract, from a
Dep<dyn Trait>orExtensions<dyn Trait>argument.
Each flow is followed until no new step appears; a step reached twice is marked, not followed
again. The shop’s place-order endpoint, as flows.md draws it:
sequenceDiagram actor Caller participant S0 as order::actions::place_order participant S1 as order::order::factory::place participant S2 as event OrderPlaced participant S3 as order::listeners::audit_placement participant S4 as order::audit::factory::record Caller->>S0: POST /account/orders S0->>S1: calls S1->>S2: raises S2--)S3: delivers S3->>S4: callsWhat a flow does not claim
Section titled “What a flow does not claim”Branches inside a function are not drawn: a flow shows what can happen, not under which
condition. An edge the model cannot resolve is listed under the diagram with its file and line,
rather than guessed or dropped. Examples are a method of the module’s types called on a value
whose type only inference knows, an event raised from an expression, and a queued function that
is not a #[job]. A diagram never claims more than the code says.
Why it is committed
Section titled “Why it is committed”docs/gen/ lives in the repository beside the code. A pull request that adds a cross-module
dependency, a new event listener or an endpoint in a member area shows it as a diff in
docs/gen/, next to the code that caused it. Reviewing architecture becomes reading a diff.
Committed documentation stays current by the same mechanism as the wiring:
- Once an app keeps a
docs/gen/folder,sofie genrewrites it along with the wiring, andsofie gen --checkreports it stale. sofie checkreports a page that differs from the model asstale-docs. Since the app’s build script runssofie check, stale documentation fails the build.sofie gen,sofie docsorsofie fixanswers the finding.
An app that does not want generated documentation simply has no docs/gen/ folder; sofie gen
writes it only once the app keeps one.
API descriptions
Section titled “API descriptions”Every screen answers its loader’s data to a JSON caller, and every action reads its Input, so
an app’s routes are already an API. Two more sofie-cli commands describe it, each behind a feature
of its own that is on by default:
sofie openapi # an OpenAPI 3.1 document, on standard outputsofie typescript # TypeScript types and an Endpoints map, on standard outputBoth print text on standard output, to be written wherever the client needs it.
OpenAPI
Section titled “OpenAPI”sofie openapi prints an OpenAPI 3.1 document of the app’s endpoints:
- Each area is a tag.
- Each read model, input and enum is a schema.
- Values are described as their JSON shape:
Moneyasminor,amountandcurrency. - A committed aggregate is described as its
idandversion. - A GET’s
Query<Q>orListing<T, Q>is described as the fields ofQ, each anin: queryparameter. The listing’s ownpage,size,sortand filters are not described.
TypeScript
Section titled “TypeScript”sofie typescript prints the same descriptions as TypeScript types, with an Endpoints map from
route name to method, path, parameters, input and output. A GET’s query parameters appear as
query: Q in its endpoint. sofie bench uses the same types for the typed client its journeys
are written against.
Gated data
Section titled “Gated data”Part of a screen may be gated by a permission, flag or entitlement. Data bound only inside such a
part is withheld from a JSON caller as null, or as a boolean where only a condition outside the
gate tests it. The API descriptions say so: such paths are typed nullable (T | null, or
T | boolean | null where a test can hold while the read fails; anyOf in OpenAPI, as for every
optional field). A value object with only some parts shown answers a Withheld<Value> copy, such
as WithheldMoney, whose other parts are nullable.
Limits
Section titled “Limits”- Resources and webhooks are not described. Resource screens are built inside the compiled app, and webhooks are called by a provider, so neither appears in the OpenAPI or TypeScript output.
- Flows show possibility, not conditions. Branches are not drawn, and edges the model cannot resolve from source are listed rather than drawn.
- The security page lists what it cannot check. Raw SQL, webhook verifiers, gateways without
an adapter, area-wide limits and CORS are named on
security.mdas outside what the model can verify. - The model reads source without compiling. Anything the structure does not declare is invisible to the documentation.
Related
Section titled “Related”- Architecture overview: the app model the documentation is a view of.
- Observability: the spans and metrics
telemetry.mdlists. - Security: the protections
security.mdreports per route. - Commands: every
sofiecommand.
