Deployment topologies¶
Sandloom is a configurable monolith: one codebase and one image deploy as a
single process or as a set of services, by configuration alone.
SANDLOOM_MODULES (a JSON list) decides which modules a process loads;
SERVICE_BINDINGS tells a process where to reach the contracts it calls but
does not host. Modules talk only through events and contracts, which behave
the same locally and remotely, so moving a module into its own service needs
no code change. Moving that module's existing data into its own database is
a separate, offline step (sandloom migrate extract-module).
What you get by default / what you declare¶
| You get by default | You declare |
|---|---|
| Every module of the app loaded in one process | SANDLOOM_MODULES to load a subset per process |
| Contracts resolved locally first | SERVICE_BINDINGS for each consumed contract a process does not host |
| Startup refusal when a required consumed contract is neither local nor bound | Contract(REF, optional=True) only where "not deployed" has a correct answer |
| Events over the durable Redis Streams bus in production, each database with its own outbox and relay | nothing |
| One migrate one-shot per database: the core chain plus the chains of the modules it hosts | the module's database (see Migrations) |
| Production validators on every process environment | the required secrets and keys of the profile |
How to use it¶
Topologies¶
| Profile | File | Topology |
|---|---|---|
| Monolith | deploy/docker-compose.yml in an application, deploy/compose/monolith.yml in the framework |
migrate, then app (every module) plus worker and beat; one PostgreSQL database; Redis |
| Microservices | deploy/compose/microservices.yml, written by sandloom add deploy |
an nginx gateway in front of one module per service, each with its own database and its own migrate-<service> one-shot, wired by SERVICE_BINDINGS, on the Streams bus |
Both profiles run with ENVIRONMENT=prod, require TWOFA_ENCRYPTION_KEY and
an explicit BILLING_PROVIDER, and start the app and worker only after their
migrate one-shot completed (sandloom migrate upgrade-all --apply,
depends_on: service_completed_successfully).
- Monolith shares
JWT_SECRET(HS256) and needs no RPC keys (no bindings). - Microservices sign tokens with EdDSA:
JWT_PRIVATE_KEYandJWT_SECRETon the identity processes only,JWT_PUBLIC_KEYSandJWT_KIDeverywhere. They also requireINTERNAL_API_TOKEN(ops endpoints) and per-service RPC keys:RPC_DH_PRIVATE_KEYon each service,RPC_PRIVATE_KEYon each calling service (it signs commands), and the sharedRPC_SERVICE_DH_KEYS,RPC_SERVICE_KEYSandRPC_ALLOWED_CALLS, which must cover the services'SERVICE_BINDINGS.sandloom keys generate --purpose rpcandsandloom keys allowlistproduce them.
The authoritative description of both profiles (variables, sizing, gateway
and IP trust, the extraction checklist) is deploy/README.md in the
repository. The steps are in Deploy an application and
Split into services.
How a split works¶
- Loading. Each process loads the modules in
SANDLOOM_MODULESand nothing else: only their routes, events, consumers and contracts exist there. - Events ride the durable Streams bus. Each service's outbox lives in its own database and its relay dispatches it. Consumer groups are namespaced by module, so a module's consumers never share a group with another module's (see Events).
- Contracts resolve local-first: a co-deployed provider wins; otherwise
SERVICE_BINDINGSsupplies a generated RPC proxy, authenticated per service pair and pinned by"service_id"; otherwise the contract is not deployed. A required contract then refuses startup; an optional one isNone. See Contracts and Service RPC. - Routing. The gateway maps path prefixes to services; adding a service
adds a location block. It never routes
/_sandloom,/diagnosticsor/metrics, and re-resolves services through Docker DNS.
SERVICE_BINDINGS is validated strictly at startup: per binding only
base_url / url (absolute http(s)), timeout_seconds, critical,
required and service_id are accepted.
Invariants the profiles keep¶
- Shared security settings. Every security- or auth-relevant setting
(
JWT_*except the issuer-only keys, token lifetimes,INTERNAL_API_TOKEN, notifier and SMTP,BILLING_DENY_ON_PAST_DUE) lives in the shared environment anchor and is identical in every process. - One cache.
CACHE_URLis shared by all services: revocation state, rate limits, idempotency records and the bus live there. - Probes. Peers never feed
/health(operational health); a down peer fails/readyonly for a binding marked"required": true, probed concurrently with a cap./healthand/readyanswer 503 when degraded (the Kubernetes probe contract). - Workers are sized from the CPU quota (
WORKER_CONCURRENCYoverrides) and health-checked withpython -m sandloom.core.worker healthcheck; exactly one beat per broker holds the lease (see Background tasks). - The image builds from the locked
uv.lock, runs as a non-root user and serves with gunicorn and uvicorn workers (cgroup-aware default worker count;WEB_CONCURRENCYoverrides). CI builds the production image and runs a blocking dependency vulnerability audit.
How the framework proves it¶
- Subset loading: any module subset boots with exactly its own routes.
- Each standard module boots alone through
create_appand its lifespan, refusing only with the documented binding error for its required consumed contracts; any error logged at startup fails the test. Example apps run the same gate. - The split conformance matrix boots every subset of the standard identity, tenancy and billing modules as real processes, bound to a peer and alone. A bound subset must answer a request script identically to the monolith; an unbound one identically or with an explicit 404 / 501 / 503, never a 500 and never a different 2xx. A new cross-module route or gate belongs in that script.
- Every process environment of both profiles validates against the production settings.
Moving a module into its own service¶
sandloom add deploy writes the service, its database and migrate one-shot, the
bindings, the allowlist and the gateway routes: see
Split into services. A module that already
holds data needs one more step, moving its data.
What breaks it¶
| Code or error | When |
|---|---|
| SANDLOOM-B005 | a required consumed contract is neither provided locally nor bound |
| SANDLOOM-R005 | a call to a contract that is not deployed |
| SANDLOOM-T003 | a module that no deploy profile deploys (warning) |
RemoteServiceUnavailable (503) |
a bound provider is down, times out or rejects the caller's authentication |
| startup refusal | an invalid SERVICE_BINDINGS entry, a caller without its RPC key, an allowlist that does not cover the bindings, a consumer group already owned by another module |
Guarantees, precisely
- Same code, any subset (C-SPLIT-01, 03). Bound subsets answer the request script identically to the monolith; every subset, and each module alone, boots and becomes ready, refusing only with the documented binding error.
- Resolution and probes (C-SPLIT-02, 04, 05). Local-first
resolution; peers never feed
/health; strict binding validation. - Shipped profiles (C-SPLIT-06, 07, C-DEPLOY-01 to 05). Each service binds exactly its consumed-but-not-local contracts; a token issuer deployed alone collects claims over RPC; the monolith and microservices stacks boot and fit the connection budget.
- Contracts across a split (G-CONTRACT-12, G-COMPAT-04 to 06).
Required contracts are present at startup; incompatible remote
providers fail
/ready(or startup withstrict). - Not provided: atomicity across services. Each database has its own unit; cross-service consistency is a saga or event choreography.
See the guarantee ledger.