Skip to content

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.

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.

Terminal window
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.

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)

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.

examples/shop/modules/order/src/actions.rs
/// 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> {

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, super and the file’s imports.
  • Function to aggregate method, from calls on an argument whose type the signature names (order: Order, then order.cancel()), on self inside an impl, or written as Order::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> or Extensions<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: calls

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.

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 gen rewrites it along with the wiring, and sofie gen --check reports it stale.
  • sofie check reports a page that differs from the model as stale-docs. Since the app’s build script runs sofie check, stale documentation fails the build.
  • sofie gen, sofie docs or sofie fix answers 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.

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:

Terminal window
sofie openapi # an OpenAPI 3.1 document, on standard output
sofie typescript # TypeScript types and an Endpoints map, on standard output

Both print text on standard output, to be written wherever the client needs it.

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: Money as minor, amount and currency.
  • A committed aggregate is described as its id and version.
  • A GET’s Query<Q> or Listing<T, Q> is described as the fields of Q, each an in: query parameter. The listing’s own page, size, sort and filters are not described.

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.

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.

  • 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.md as outside what the model can verify.
  • The model reads source without compiling. Anything the structure does not declare is invisible to the documentation.