# 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 `location`s 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`](<https://github.com/Sandloom-dev/sandloom/blob/main/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.

## Related reading

- [Deploy an application](<https://sandloom.dev/docs/operate/deploy/index.md>)
- [Deployment topologies](<https://sandloom.dev/docs/operate/topologies/index.md>)
- [Run migrations](<https://sandloom.dev/docs/modules/database/run-migrations/index.md>)
