Skip to content
Menu

Documentation

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 --force to 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.