Split into services¶
An sandloom new application runs as one service (deploy/docker-compose.yml).
The same code runs as microservices: one service per group of modules, each
with its own database, behind one gateway. No Python file changes. You choose
which modules run together; sandloom add deploy writes the rest.
uv run sandloom docs recipe split prints a short form of this guide inside an
application.
Split it¶
Run these commands from the project root. Put each module, or each group of modules, in a service:
uv run sandloom add deploy identity # service "identity"
uv run sandloom add deploy tenancy
uv run sandloom add deploy billing
uv run sandloom add deploy catalog enrollment --service academy
uv run sandloom add deploy reporting --service insights
The first run creates three files from templates shipped in the framework package:
deploy/compose/microservices.yml, the microservices profile;deploy/gateway/nginx.conf, the gateway;deploy/postgres/init-databases.sh, which creates one database per service.
Every run derives what the split needs from the modules themselves. It loads them (registration only, no I/O) and reads what each one provides, consumes and routes. It writes:
| What | Where |
|---|---|
One env anchor per service: its SANDLOOM_MODULES, its own database, its own broker and its RPC identity |
x-<service>-env |
A migrate-<service> one-shot, and the HTTP service, which starts after the one-shot completes |
services: |
A worker when a module ships tasks.py, and the one beat of its broker when it schedules periodic tasks |
<service>-worker, <service>-beat |
SERVICE_BINDINGS: exactly the contracts the service consumes from another service |
each env anchor |
The token issuer's bindings of every remote claims contributor (tenancy.claims.v1, billing.claims.v1 with "critical": false) |
the issuer's anchor |
RPC_ALLOWED_CALLS, derived from the bindings (deny by default) |
the shared x-common-env |
The databases, and a max_connections that fits every pool of every process |
db |
An upstream per service, the locations of each route prefix bounded at its segment (= /shop and /shop/: /shopping is not /shop's), and location = / / the catch-all location / for the service with root-level routes (/, /{slug}; two services claiming them are refused) |
gateway/nginx.conf |
The shared env anchor holds everything security- and auth-relevant, which
must be identical in every process: the JWT settings, one CACHE_URL, the
internal token, the RPC public keys, and the Streams event bus. The gateway
never routes /_sandloom, /diagnostics or /metrics.
Standard modules can each be their own service. A module that issues tokens
(identity) gets JWT_PRIVATE_KEY and JWT_SECRET. No other service gets
them, so no other service can mint a token.
Add a module later¶
The command is incremental. sandloom add deploy notes --service academy adds
notes to the existing academy service. sandloom add deploy notes creates a
new service. Each run re-derives every service's bindings, the allowlist and
the routes. A module that newly calls notes' contract gets its binding in
the same run.
A running stack keeps its Postgres volume, and init-databases.sh runs only
on an empty one. A new service's migrate-<service> one-shot therefore
creates its database when it is missing (sandloom migrate upgrade-all --apply
--create-databases), so docker compose ... up -d after the command starts
the new service on the existing volume. nginx reads its configuration at
start: docker compose ... restart gateway then routes the new prefixes.
After a code change, such as a newly consumed contract or a new route
prefix, run uv run sandloom add deploy --refresh. sandloom test reports a stale
profile (SANDLOOM-T014). The same command again changes nothing.
Your own edits¶
The command owns only the blocks between the
# >>> generated by sandloom add deploy: <block> and # <<< markers. Edit
anything outside them: a setting in x-common-env, another service, a
location in the gateway. Your edits are kept.
An edit inside a block makes the next run refuse. It prints a diff of the block, edited against generated. You can:
- move the edit out of the block (a setting in
x-common-env, or a compose override file:docker compose -f deploy/compose/microservices.yml -f deploy/override.yml up); - or rerun with
--forceto overwrite the block.
To move a module to another service, delete it from its service's
SANDLOOM_MODULES and run sandloom add deploy <module> --service <other> --force.
Then move its data with the skill sandloom-extract-module.
Check it¶
uv run sandloom add deploy --check # the invariants + every service boots alone
uv run sandloom test # also checks the profile (schema & deploy)
sandloom test's schema & deploy step checks the profile with the invariants
the framework's own profile is pinned to (SANDLOOM-T014). Every service must:
- merge the shared settings, identical everywhere;
- have its own database and a migrate one-shot that it waits for;
- bind exactly what it consumes from another service;
- hold its own RPC keys;
- keep peers out of its liveness probe.
The allowlist must equal the bindings, and the gateway must route every module route to its service.
--check also boots each service alone with exactly its environment, the
way sandloom doctor boots a process: settings, the startup preflight, the
composition root, every consumed contract bound. It needs no network. A
missing binding fails here, not at the first request in production.
Keys and running it¶
The profile requires per-service keys: an X25519 key for every service, an
Ed25519 key for every service that calls another, and the JWT pair. Each
${VAR:?...} names the command that generates it.
For development and CI only, one command writes them all into a plain file:
uv run sandloom keys dev-env --out deploy/.env.microservices
docker compose --env-file deploy/.env.microservices \
-f deploy/compose/microservices.yml up --build
The file holds fresh random keys, a demo payment provider
(BILLING_PROVIDER=fake) and the matching accepted risks. It sits in the
image's build context, so the app's .dockerignore excludes env files and
key files at any depth (**/.env.*, **/jwt_private_key, ...), and the
Dockerfile copies only pyproject.toml, uv.lock and the package: no
service's image holds another service's keys. sandloom add deploy adds the
exclusions to an older .dockerignore. Never use the file in
production. There, generate the keys into your secret store:
sandloom keys generate --kid jwt-1 # JWT_PRIVATE_KEY (issuer) + JWT_PUBLIC_KEYS
sandloom keys generate --purpose rpc --service academy # a calling service: Ed25519 + X25519
sandloom keys generate --purpose rpc-dh --service tenancy # a provider only: X25519
Merge the public halves into RPC_SERVICE_KEYS and RPC_SERVICE_DH_KEYS,
identical in every service. To rotate keys, see the skill
sandloom-key-rotation.
Existing data¶
A new deployment starts empty. The commands above change code and topology, not
data. A monolith that already holds data moves each module's tables to its service's
database with sandloom migrate extract-module <module> --from default --to <url>.
The move is offline, in a maintenance window: it copies the module's tables, its
alembic_version_<module> row and its pending outbox events, verifies them and
fences the source. It is a dry run by default. The procedure is in the skill
sandloom-extract-module and in
deploy/README.md.
Online (zero-downtime) extraction is not covered yet.
Roll out a contract change¶
Services upgrade one at a time, so a caller can briefly meet a provider of another
version. Every module commits contracts.snapshot.json, the describe documents of
the contracts it provides. sandloom contracts check --against <released snapshot>
fails CI on a breaking change: a removed method or parameter, a newly required
parameter, an incompatible type, a changed kind or idempotency key. Additive changes
pass.
At runtime each caller compares every bound provider's live describe document with the
snapshot in its own image. An incompatible provider fails /ready with a
contract_incompatible diagnostic (CONTRACT_COMPAT_CHECK=ready, the default;
strict also aborts startup; off disables the check). Ship additive changes
provider-first. A breaking change is a new contract version (.v2).
Proof¶
tools/smoke/split_check.py (CI job app-split) runs this guide on a fresh
sandloom new hub --with identity,tenancy,billing with two modules, orders
calling catalog's contract and consuming its event:
- the split commands;
docker compose config;- the invariants;
- every service booting alone.
With --smoke (the slow test tests/e2e/test_app_split.py), it starts
the stack and calls through the gateway. Login collects tenancy's claims
over RPC, orders calls catalog over RPC, and orders consumes
catalog's event.