Open Software backend (Rust)
This skill captures the house style used by os-accounts and os-platform
(the fellow codebase). Both repositories were built independently and
converged on the same shape — that convergence is not coincidence, it is the
recipe. Follow it when starting a new service or extending one of these two, and
your code will look at home in either repo.
The recipe in one sentence: a Cargo workspace of small, pointed crates where the domain doesn't know about HTTP, services don't know about Postgres, the API doesn't know about Privy or Stripe, and the binary wires it all together.
Read this file, then open the reference that matches the work.
Route the request first
| If the user asks for... | Open first | Non-negotiable |
|---|---|---|
| "scaffold a new backend" / "start a service like os-accounts" | workspace-layout.md | Seven crates, exact names: domain / services / persistence / providers / config / api / app. |
| "add an endpoint" / "what should this handler return" | api-envelope.md | Every endpoint returns ApiResponse<T>. Error code bands map to HTTP status. |
"where do I read DATABASE_URL" / "add a new config knob" |
config-and-providers.md | Never std::env::var. Add a typed field to *-config, plumb it down. |
| "wire up Stripe / Privy / a new external API" | config-and-providers.md | Define a trait in *-domain, implement in *-providers, inject Arc<dyn Trait> in app/main.rs. |
| "add a table / write a query / run a migration" | persistence.md | sqlx + MIGRATIONS!. Repository structs in *-persistence. Enum columns get CHECK constraints. |
| "test the new endpoint" / "how do I mock Privy" | testing.md | Unit = no I/O; integration = real Postgres + wiremock; e2e = HTTP against a deployed service. |
| "containerize it for GHCR" | dockerfile.md | cargo-chef multi-stage, debian-slim runtime, non-root user, ENTRYPOINT is the binary. |
Mental model: the dependency cone
app/main.rs
│ (only the binary knows everyone)
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
accounts-api accounts-services accounts-providers
│ │ │
└────────┬─────────┴──────────┬────────────┘
▼ ▼
accounts-persistence accounts-domain ◀── traits live here
│ ▲
└────────────────────┘
▲
accounts-config (shared types only)
Read the arrows as "depends on". The shape gives you four properties:
domainis pure. No HTTP, no DB, noreqwest. It defines value types, IDs, errors, and the traits (IdentityProvider,PaymentGateway, …) — the ports the rest of the world plugs into.persistenceandprovidersknow nothing about each other. Both depend ondomainso they can implement domain traits and return domain types, but they don't talk through anything exceptdomain. This means you can rewrite Stripe → Adyen without touching the database layer, and you can swap Postgres → CockroachDB without touching Privy.servicesis the orchestrator. This is where business logic lives — the things that read frompersistence, callproviders, return domain results. The API layer should be thin; if a handler has branching business logic, that logic belongs inservices.app/main.rsis the only place that names concrete provider impls.accounts_providers::ConfiguredIdentityProvider::from_config(...), thenArc::new(...) as Arc<dyn IdentityProvider>into the router state. The rest of the code only sees the trait.
If you find yourself adding sqlx to accounts-providers, or reqwest to
accounts-persistence, or axum to accounts-domain — stop. The shape is
wrong; the change probably belongs in a different crate.
Golden rules (and why they matter)
- One service-prefix, every crate.
accounts-api,accounts-domain, etc. for an "accounts" service;fellow-api,fellow-domain, etc. for theos-platformservice. The prefix is the service identity — it is how a reader recognizes which workspace they are in. - The binary crate's package name is the bare service noun.
accounts,fellow. The library crates carry the prefix; the binary doesn't, becausecargo install accountsreads more naturally thancargo install accounts-app. - Edition 2024 + pinned toolchain. The toolchain channel lives in
rust-toolchain.toml; the MSRV (rust-version) lives in the workspace[workspace.package]inCargo.tomland is mirrored byclippy.toml. Both repos pin1.95.0and let CI install exactly that. Don't let the toolchain float. - Workspace deps, not crate-local versions. Every external dep is declared
in the root
[workspace.dependencies]once. Member crates writeserde.workspace = true. No version skew across crates, no diamond problems. - Workspace lints are pedantic + paranoid.
clippy::pedanticat warn,unsafe_code = forbid,unwrap_used/expect_used/panic/todo/unimplemented/dbg_macro/print_stdout/print_stderrall at warn,-D warningsin CI. This is what keeps the codebase out of "Rust slop" territory — no.unwrap()sneaks in, nodbg!ships. std::env::varis aclippy.tomldisallowed-method. See spec/rust-config-over-env.md. Add the env knob to your*Configstruct and let figment merge it.- Every endpoint returns
ApiResponse<T>. No raw arrays at the top level, noJson<MyThing>returned directly. See api-envelope.md. - Error codes are application-level integers, banded by HTTP status.
1001–1999 → 404,2001–2999 → 400,3001–3999 → 403,4001–4099 → 409,4101–4199 → 410,4201–4299 → 422,4301–4399 → 402,4401–4499 → 429,5000–5099 → 500. The envelope sets the HTTP status from the band so clients can branch on either status orerror_code. tracingonly — noprintln!/eprintln!/dbg!. Use structured fields:tracing::error!(%err, "connection failed"), nottracing::error!("connection failed: {}", err). The JSON formatter relies on the fields being typed.- Wire enums use lowercase / snake_case in both JSON and SQL. Rust enums
stay PascalCase;
#[serde(rename_all = "snake_case")]and#[sqlx(rename_all = "snake_case")]translate at the boundary. Every enum column gets a SQLCHECKconstraint enumerating the allowed values — never an openTEXTcolumn. See persistence.md. - Functions with 3+ parameters take a
*Paramsstruct. Call sites read like documentation, adding a field is non-breaking. The repos already enforce this — see spec/struct-params.md. - No I/O in unit tests. If your test needs Postgres, it's an integration
test and lives in
crates/<x>/tests/. Mock providers withwiremock, not hand-rolled fakes inside#[cfg(test)].
Scaffold checklist (new service)
If the user is starting a brand-new service in this style (not extending
os-accounts or os-platform):
- Pick the service-prefix. A short noun, lowercase, no hyphens:
accounts,fellow,library,forum. Used as<prefix>-api,<prefix>-domain, etc., and as the binary's package name (bare, no prefix). - Create the workspace. Root
Cargo.tomlwithresolver = "3",members = ["crates/*"], the workspace-package fields, the full[workspace.dependencies]block (copy fromapi/Cargo.tomlin either repo and trim deps you don't need), and the workspace lints block. See workspace-layout.md for the exact template. - Create the seven crates under
crates/:domain,services,persistence,providers,config,api,app. Onlyappis a[[bin]]; the others are libraries. Each crate'sCargo.tomlis small — it inherits nearly everything from the workspace. - Add
rust-toolchain.toml,rustfmt.toml,clippy.tomlat the workspace root. Pin the toolchain (1.95.0is the current default), turn on the same rustfmt knobs (edition = "2024",max_width = 100,hard_tabs = false,tab_spaces = 4), and list the disallowed env methods inclippy.toml. - Define the first domain type and the first trait. Don't start with the
handler. Start with
domain/src/lib.rs: declare theUser(or whatever your first entity is), declare the first port (IdentityProvideretc.) as a#[async_trait]trait, declare aDomainErrorenum withthiserror. - Build
*-configsecond. A single root struct (AccountsConfig,FellowConfig) with nested sections (server,database, ...), apub fn load() -> Result<Self, ConfigError>that uses figment to merge defaults +config.toml+ env, and a small test that loads it from a fixture. - Add the
appbinary stub. ParseCliwithclap(subcommandsServe,Migrate), callaccounts_config::load(), inittracing_subscriberwith a JSON formatter, wireaxum::serveagainst a router fromaccounts-api. The binary is the only crate that depends on every other crate. - Add the
/livezhealth endpoint and theApiResponsetype next. Get the envelope shape right before writing real endpoints — every handler will use it. - Iterate. Real endpoints, real tables, real providers — but always in the
same crate boundaries. Each new endpoint: handler in
api, business logic inservices, queries inpersistence, external calls inproviders, types indomain.
Workspace deps quick-reference
These are the libraries every Open Software backend uses. Reach for these first before introducing alternatives — consistency is part of the recipe.
| Concern | Crate(s) | Notes |
|---|---|---|
| Async runtime | tokio ("full" features) |
One runtime, full features in the workspace. |
| HTTP server | axum, tower, tower-http |
tower-http features: trace, cors, timeout, limit. |
| HTTP client | reqwest ("json") |
Only inside *-providers. |
| Serialization | serde, serde_json, base64, hex |
serde always with derive. |
| Errors | thiserror (libraries), anyhow (binary only) |
Library crates never return anyhow::Error. |
| Logging | tracing, tracing-subscriber (env-filter, json) |
JSON formatter; EnvFilter::try_from_default_env(). |
| Database | sqlx (postgres, uuid, chrono, migrate, macros, bigdecimal, json) |
Only inside *-persistence and app. |
| Money | bigdecimal (serde) |
Never f64 for money. Prefer integer credits / cents at the wire — see api-envelope.md. |
| Config | figment (toml, env), dotenvy |
dotenvy only in app. |
| OpenAPI | utoipa, utoipa-axum, utoipa-scalar |
Derive ToSchema on every wire DTO. |
| CLI | clap (derive) |
Only inside app. |
| IDs | uuid (v4, v7, serde), nanoid |
v7 for new ids; nanoid for opaque short ids. |
| Time | chrono (serde) |
Persist TIMESTAMPTZ, serialize as RFC3339. |
| Auth/crypto | jsonwebtoken, hmac, sha2, subtle |
subtle::ConstantTimeEq for any secret compare. |
| Async traits | async-trait |
On every domain trait that has async methods. |
| URL parsing | url |
At trust boundaries (redirects, webhooks). |
| Testing | mockall, wiremock, rstest, pretty_assertions, insta |
See testing.md. |
Common mistakes to avoid
- Cramming everything into one crate. "It's a small service, I'll just have
a
src/directory." The crate split is the architectural review tool — you cannot accidentally use Stripe from inside a SQL query if*-persistencedoesn't depend on*-providers. Don't skip it because it feels heavy at scale 1; you'll regret it at scale 5. - Putting trait impls in the wrong crate.
impl IdentityProvider for PrivyClientlives in*-providers, not in*-domain. The trait lives indomain; impls live with the technology they wrap. - Returning
Json<T>directly from a handler. That ships a raw payload and breaks every caller'ssuccesscheck. Always wrap inApiResponse. - Reading
std::env::varinside a handler or service. Every config knob belongs in the*Configstruct and flows in viaApiState. The disallowed-method lint will catch you in CI; don't paper over it with#[allow(...)]. tracing::info!("user {} logged in", user_id)instead oftracing::info!(%user_id, "user logged in"). The first is a string — the JSON formatter can't index it. Use structured fields.- Logging a secret. Action-token bearers (
agts_…), refresh tokens, the App API key (osk_…), JWTs — none of these go in any log line, span attribute, error message, or response body. Types that hold a secret implementDebugmanually and redact the field; never?tokenin atracing::*!call. - Open
TEXTenum columns. Add aCHECKconstraint enumerating the values, keep the SQL value and the JSON wire value identical (lowercase / snake_case), and have the Rust enum translate via#[serde(rename_all)]. - One mega
errors.rsshared by every crate. Each layer defines its own error enum (DomainError,PersistenceError,ProviderError,ServiceError) and converts at boundaries with#[from]. The API layer maps the top-level service error into anApiResponsewith the right error code. Arc<Mutex<DbPool>>or other "I'll just clone the world into state".sqlx::PgPoolis already cheap to clone.Arc<dyn Trait>is the right shape for shared trait objects. Don't reach forMutexunless you're guarding mutable shared in-memory state (rare — the rate-limiter is the only example inos-accounts, and it's bounded).- Skipping integration tests because "the unit tests pass". The unit tests prove the pure logic; the integration tests prove the SQL is real, the migrations apply, the route is wired, the JSON shape is the envelope. You need both. See testing.md.
- Building a Docker image that runs as root or includes the build toolchain.
Multi-stage with cargo-chef; runtime is
debian:13-slim(or matching);useradda non-root user andUSERto it; copy only the release binary. See dockerfile.md.
After scaffolding: where to look next
- For wiring CI (format, clippy, tests, contract drift, GHCR image build,
artifact promotion via re-tag), use the sibling skill
os-rust-backend-ci. It ships templated workflows and composite actions that match this recipe. Deployment (how artifacts get to a running environment) is out of scope for both skills — that's owned by whoever runs the service. - For integrating apps that depend on this backend (Login with Open Software,
metering, etc.), see the
os-accounts-integrationskill. - For the actual deployed topology (domains, BFF), see
docs/deployment.mdin either repo. - For the live decisions that shaped this recipe, read
docs/adr/in either repo — particularly ADR-0001 (platform shape).