Architecture overview
Every sofie app has the same shape. That is the point: because the layout is fixed, the
framework can read an app into one model and derive its wiring, documentation and checks from it.
This page walks through that shape from the outside in: the folders, the roles of the files in
them, the modules they form, the model sofie gen reads, and the crates that implement it all.
Layout of an app
Section titled “Layout of an app”An app is a Cargo workspace with one binary crate and one folder per module:
AGENTS.md generated: the conventions, for agents and peopleapp/ the binary build.rs runs `sofie check` and compiles screens and islands src/ main.rs wiring.rs generated: MODULES, app, worker, route tablemodules/ order/ contract/ crate order-contract, the only importable part src/ lib.rs traits other modules call events.rs area.rs an area, only in the module that owns its principal gen/ generated: exposed DTOs, route builders src/ crate order module.rs manifest: the module declares itself wiring.rs generated: what the manifest spreads config.rs its configuration and environment keys order/ one folder per aggregate: mod.rs, factory.rs actions/ one file per action queries/ one file per query areas/customer/ routes.rs, menu.rs, screens/, islands/ gateways/ gateway traits and one adapter per vendor extensions/ domain/ private code: value objects, calculations migrations/ NNNN_name.sql lang/ <language>.tomlA module needs only module.rs and one aggregate folder to start. Every other folder appears
when its first file does. A small module may also keep its actions in actions.rs and its queries
in queries.rs, as the example shop does. The layout reference lists
the place of every file.
The app crate holds no module code. Its main.rs hands the generated wiring to sofie::run:
fn main() -> sofie::core::Result<()> { sofie::run(shop::MODULES, shop::app, shop::worker)}Its build script runs sofie check, so a finding fails cargo build:
fn main() -> std::process::ExitCode { sofie_cli::check_in_build_script(std::path::Path::new(".."))}Roles and what each one buys
Section titled “Roles and what each one buys”A role is a kind of file the framework acts on. Each exists because something is generated or enforced from it; everything else in a module is plain private Rust.
| Role | Location | The framework generates or enforces |
|---|---|---|
| Manifest | module.rs |
Module registry, tier and dependency checks, feature and setting catalogs, architecture docs, the boundary a split cuts along |
| Config | config.rs |
A typed config struct, boot-time validation, the module’s .env.example, injection into its handlers |
| Aggregate | <aggregate>/mod.rs |
Columns, field metadata, load and save, validation, input DTOs, permissions, form fields, table columns, fixtures, contract DTOs for exposed fields |
| Read model | beside the queries that use it | One SQL statement with its joins, decoding, screen binding |
| Migration | migrations/*.sql |
Applied in order, compared with the schema, checked not to lock a table the running version uses or to name another module’s schema |
| Factory | <aggregate>/factory.rs |
The only constructor, enforced by module privacy; derived by default when absent |
| Action | actions/*.rs |
Arguments resolved by type, the commit of what it returns, span and metrics; by annotation a queued job, a schedule or an event listener |
| Query | queries/*.rs |
A read-only connection (a type, so writing does not compile), screen data binding |
| Area definition | contract/src/area.rs |
The audience’s principal, prefix, sign-in path and layout |
| Routes | areas/<area>/routes.rs |
The module’s endpoints in that area, with derived route names |
| Menu | areas/<area>/menu.rs |
The area’s navigation, linked by route name |
| Screen | areas/<area>/screens/*.html |
Rendering, bindings checked against its loader, translation keys |
| Island | areas/<area>/islands/*.svelte |
Build and hot reload, props typed from its loader, one bundle per area |
| Resource | one line in routes.rs |
Index, show, create, edit and delete screens and their actions, with no file |
| Contract | contract/ |
In-process wiring today; a remote client and server once the module is split |
| Event | contract/src/events.rs |
Outbox write, delivery, retries, listener registry |
| Gateway and adapter | gateways/ |
The configured vendor chosen at boot, span and metrics per vendor call, a test fake |
| Extension | extensions/ |
Registration with the extension point |
| Translations | lang/ |
Labels derived from field names, so files hold only what differs; a parity check across languages |
The pages on data, handlers and routes, messages, screens and areas cover each role in depth.
Modules, tiers, contexts and contracts
Section titled “Modules, tiers, contexts and contracts”Modules
Section titled “Modules”A module is the unit of ownership. It owns its data, its rules and its screens, and it is a pair of crates: the module itself and its contract. Each module’s tables live in its own database schema, named after the module.
Contracts
Section titled “Contracts”The contract is a module’s public surface: the traits other modules call, the data they
receive and the events it publishes. It is the only thing another module may import; sofie check refuses a module that depends on another module’s implementation crate, and a contract
that depends on any module. No foreign key or join crosses modules either. When one module needs
to filter by another’s fields it keeps a mirror, a local read-only copy kept current from change
events.
A contract also exports generated code in contract/src/gen/: DTOs of the fields an aggregate
exposes, their change events, and typed route builders, so modules link to each other’s endpoints
without depending on implementations.
A module’s tier is Experience, Core or Platform, and dependencies only flow downward:
- Experience modules compose others for an audience, such as a checkout that calls the catalog, order and payment contracts in one action.
- Core modules hold the business: catalog, order, payment.
- Platform modules serve every other module: files, communication. A Platform module reacts to Core only by being called; its listeners hear Platform events only.
sofie check enforces the order, reading Cargo metadata and each manifest’s tier. Cargo itself
refuses cycles.
Contexts
Section titled “Contexts”A context is a named group of Core modules that belong to one part of the business, such as
commerce. It is metadata for the documentation and for splitting several modules together, and
it grants no extra access.
Module declaration and configuration
Section titled “Module declaration and configuration”A module declares itself in one file and carries its configuration with it, so that splitting it out moves files rather than untangling them.
use sofie::{Module, Tier};
pub const MODULE: Module = Module { name: "order", tier: Tier::Core, context: Some("commerce"), settings: &[], ..crate::wiring::GENERATED};The manifest states the module’s name, tier, context and runtime settings, and spreads what
sofie gen wrote into src/wiring.rs: migrations, area routes and menus, route names,
listeners, schedules and jobs. name must be the crate name, because it is also the module’s
database schema and its configuration prefix; sofie check reports a mismatch.
Configuration is a struct deriving sofie::Config:
#[derive(sofie::Config)]pub struct OrderConfig { /// Printed before every invoice number. invoice_prefix: String, /// Hours a placed order may stay unpaid; 24 when unset. payment_window_hours: Option<u64>, payment_api_key: Secret<String>, database: Database, queue: Queue,}
// a handler or job asks for it by typepub async fn expire_unpaid(read: Read, config: Config<OrderConfig>) -> Result<Vec<Order>>- Keys are prefixed by the module.
OrderConfigreadsORDER_INVOICE_PREFIX,ORDER_PAYMENT_API_KEYand so on, and onlyORDER_*keys. Two modules cannot collide. - Infrastructure inherits by default.
DatabasereadsORDER_DATABASE_URLand falls back toDATABASE_URL;QueuereadsORDER_QUEUE_URLand falls back toDATABASE_URLtoo. Before a split, an own value that differs fromDATABASE_URLstops the start with an error. - Declared once. The struct is the only list of keys.
sofie genreads it to write the module’s.env.example, with each field’s doc comment, and the app’s. - Fail at boot. The wiring loads every module’s config before the app is built; a missing or
malformed key stops the app, naming the module and the key. A
Secretis never printed, logged or serialized. - A module reads only its own config. No code outside the framework’s loader reads environment
variables;
sofie checkrefusesenv::varandenv!in modules.
Settings, unlike config, change at runtime. They are declared in the manifest with a default,
stored in the database behind the feature-flags feature, and read by a handler through
sofie::RuntimeSettings.
The app model
Section titled “The app model”sofie gen reads the app once, from source and without compiling, into a single model of its
modules, schemas, routes, areas, screens, flows and rules. The model lives in the sofie-model
crate, which also holds the naming rules, so a name such as a route name
(module.area.handler) is derived by one function wherever it is needed.
Every generator, every sofie check rule, the generated documentation, sofie explain and the MCP
server read that same model. They cannot disagree about what the app contains.
What sofie gen writes
Section titled “What sofie gen writes”sofie gen writes Rust in two places only, the app’s wiring and what each contract exports, and
no Rust per type. Everything else it writes is not Rust.
| Output | Contents |
|---|---|
modules/<module>/src/wiring.rs |
GENERATED, the Module the manifest spreads: migrations, area routes and menus, route names, listeners, schedules, #[job] actions and mirrors’ listeners, named from inside the crate so none of them needs to be pub |
app/src/wiring.rs |
MODULES, the screens the build script compiled and their forms, and app and worker built from them: config loading, contract implementations, extensions, route flag and permission tables, #[task] functions |
contract/src/gen/ |
DTOs of exposed fields and their change events, route builders and, with Permitted areas, permission constants |
.env.example |
One per module and one for the app |
| Template binding checks | Errors only, no files |
AGENTS.md |
The app’s modules and areas, the commands, where each kind of code goes, and the rules it is held to; one written by hand is left alone |
docs/gen/ |
Rewritten with the wiring once an app keeps it; see Generated documentation |
Generated files are committed and never edited. sofie gen --check verifies them, and sofie check reports stale wiring as a finding, which fails the build.
A Module is data, not behaviour. At runtime sofie::run migrates the modules’ migrations, the
wiring’s app loads the screens and mounts the areas, and its worker starts the message worker
from the same list. The same app can build a web binary and a worker binary from one codebase.
The crates of the framework
Section titled “The crates of the framework”The framework is split by what a binary must pull in, not by how many concepts there are. A crate exists when some app or some binary can do without it.
| Crate | Holds |
|---|---|
sofie |
The facade: re-exports everything an app uses; features choose the crates |
sofie-model |
The app model and naming rules, parsed with syn; nothing of it is in the binary |
sofie-macros |
The derives (Aggregate, Value, Enum, ReadModel, Input, Config) and inert attributes |
sofie-core |
The value model, Error, ids, the clock, encodings and the registration rules boot and sofie check share |
sofie-values |
Shipped values: Money, Email, Instant, Date, Slug, Url, Secret and more, most behind features |
sofie-auth |
Signing and sealing: session cookies, signed links, token digests |
sofie-telemetry |
Tracing setup, log masking, metrics; OTLP export behind a feature |
sofie-engine |
The template renderer, the component catalog, embedded htmx, island mounting |
sofie-db |
The handler model, store, unit of work, query builder, read models, migrations, the message table and its worker, the database seam’s traits |
sofie-postgres |
The database seam for Postgres |
sofie-sqlite |
The database seam for SQLite in one file, for an app running as one instance |
sofie-client |
Outgoing HTTP for gateways and remote contracts, traced and held to allowed hosts |
sofie-http |
The server on hyper, the router, areas, guards, sessions, security headers, Module |
sofie-flags |
Feature flags and runtime settings |
sofie-cache |
Opt-in cache in memory, in the database or in Redis |
sofie-storage |
Opt-in files, in a local directory or an S3-compatible bucket |
sofie-mail |
Opt-in mail rendered from templates and sent by a job |
sofie-billing |
Opt-in plans, entitlements, quotas and subscriptions |
sofie-notify |
Opt-in inbox, mail and web push per principal’s choices |
sofie-content |
Opt-in Markdown pages with publishing and a sitemap |
sofie-identity |
Opt-in module: accounts, memberships, passwords, sign-up, sign-in and sign-out |
sofie-tenancy |
Opt-in module: organizations owning records, members, roles, invitations |
sofie-testing |
The test harness |
sofie-check |
The rules sofie check and an app’s build script run |
sofie-cli |
The sofie command: new, gen, check, fix, docs, openapi, typescript, explain, split and more |
An app depends on sofie alone and turns features on or off rather than wiring crates by hand.
What not every app needs is a feature, off by default, so the framework compiled into an app stays
small.
Splitting a module out
Section titled “Splitting a module out”Because a module owns its data, imports only contracts and reads only its own config, it can be
moved into an app of its own. sofie split <module> prints a plan and changes nothing; sofie split <module> --write carries it out, and a failure part-way undoes every move and edit.
sofie split payment # the plansofie split payment --write # carry it outThe plan names what moves, what is served over HTTP, the keys each app needs, and the contract
methods that cannot cross apps: ones that are not async, are generic, take &mut or a borrowed
type, or answer other than a Result of an owned type. Their callers stop compiling. It also
lists the app’s test files naming the module, which it does not move.
- Where it goes. The module folder moves to
split/<module>/modules/, beside a new app cratesplit/<module>/app. Both apps stay members of one Cargo workspace, so the contract crate is shared by path. Thensofie genruns in both apps. - Data, not code. Each app’s
[package.metadata.sofie]says which modules itservesand which areremote. The app left behind drops the module’s crate, keeps its contract and turns on sofie’sremote-contractsfeature. - Wiring. For a remote module the wiring calls
sofie::remote::connect("<module>")?, reading<MODULE>_REMOTE_BASE_URLand the other<MODULE>_REMOTE_*keys. Events cross apps as a feed each app publishes and others pull with a cursor, so no broker is needed. - Sign-in. Sign-in stays with
sofie-identity; both apps read the session cookie and shareSOFIE_SIGNING_KEYS. - Reverse proxy.
proxy.tomlbeside the app left maps each path prefix of the module’s routes to the new app./_sofie/is never routed from outside the network. - Database. Setting
<MODULE>_DATABASE_URLpoints the moved module at its own database with no code change.
Trade-offs
Section titled “Trade-offs”- A fixed layout. Folders and file roles are set by the framework. Code that fits no role is
private Rust in
domain/and gets no framework support. - Split-readiness has a price. Modules never share a transaction or a join, even inside one binary. Cross-module reads use contracts, batch lookups or mirrors.
- Generated files in version control. Wiring, contract exports and
.env.exampleare committed and must be regenerated when the app changes; the build fails until they are. - The model reads source, not types.
sofie gendoes not compile the app, so data bound by a screen must be a read model it can read, and some edges it cannot resolve are reported rather than guessed.
