;; libs/cache — :in-memory (dev/test) | :redis (multi-instance)
;; libs/jobs — :in-memory (dev/test) | :redis (shared queue across workers)
:boundary/cache {:provider :redis ...}
Boundary’s architecture (FC/IS + hexagonal ports) is meant to let you scale vertically, horizontally, or a mix — mostly by configuration rather than rewrites. This page is an honest map of how far that holds today: which knobs exist, which components are already safe to run as many replicas, and which still hold in-process state that you must account for.
| Axis | Meaning |
|---|---|
Vertical | One process, more resources. Bigger heap, larger connection/thread pools, more CPU. Pure configuration in Boundary. |
Horizontal | Many processes (replicas) behind a load balancer, sharing backing services (Postgres, Redis). Requires that no request-handling state lives in a single process. |
Functional decomposition | Slice modules into separate deployables (microservices-style) that scale independently. A cross-module call that was in-process becomes a network call. Highest leverage, highest effort. |
All sizing knobs live in resources/conf/{dev,test,prod,acc}/config.edn plus
environment variables. Change the value, restart the process. No code.
| Knob | Where | Notes |
|---|---|---|
DB connection pool |
| HikariCP. prod default |
HTTP server |
| Jetty. |
JVM heap / GC |
| Default |
Cache (Redis) pool |
| Jedis pool. prod default |
|
Watch the multiplication: with N replicas each holding a pool of |
Cross-module calls go through protocols defined in each module’s ports.clj
(enforced by bb check:ports). Core logic depends on a protocol, never on a
concrete adapter. That seam is the scaling lever: swap an in-process adapter for
a distributed one — Redis, a queue, a remote service — without touching the
functional core.
Two libraries already ship both adapters and pick between them in config:
;; libs/cache — :in-memory (dev/test) | :redis (multi-instance)
;; libs/jobs — :in-memory (dev/test) | :redis (shared queue across workers)
:boundary/cache {:provider :redis ...}
This is the template every other seam follows: the protocol is the contract, the distributed adapter is "just configuration" once it exists.
| Component | N replicas | Detail |
|---|---|---|
Cache | ✅ | Redis adapter ( |
Jobs | ✅ * | Redis queue ( |
Auth / sessions | ✅ | DB-backed, pure core ( |
Multi-tenancy | ✅ | schema-per-tenant ( |
Email / external | ✅ | Async via the jobs queue / stateless IO adapters. |
Readiness checks | ✅ |
|
Rate limiting | ⚠️ | A Redis fixed-window limiter exists ( |
Graceful shutdown | ⚠️ | Integrant |
Realtime / WebSocket | ❌ | Connection registry and pub/sub are in-memory atoms only ( |
| Shape | When |
|---|---|
Single fat node | Vertical only. One process, large heap and pools. Everything works, including realtime and in-memory rate limiting. Simplest; capped by one machine. |
N stateless web replicas | The main horizontal mode. N copies of the uberjar behind a load balancer, sharing Postgres + Redis. Cache, jobs, auth, tenancy all scale. Caveats: wire Redis rate limiting; WebSocket needs sticky sessions or a single realtime node until the Redis adapter lands. |
Web / worker split (future) | Run dedicated job-worker processes separate from web. The jobs Redis queue already supports it, but there is currently no |
Use the Redis cache and jobs adapters, never :in-memory, for more than one replica.
Register all job handlers on every instance (a dequeued job with no local handler fails to the dead-letter queue).
Wire http-rate-limit with the Redis cache if you need a global limit.
Keep replicas × maximum-pool-size under Postgres max_connections.
Confirm your load balancer points health probes at /health/ready (503-aware), not /health/live.
For WebSocket today: pin realtime to one instance or use sticky sessions until the Redis pub/sub adapter exists.
Add Redis (and, for testing N replicas, a load balancer) to your deployment — docker-compose.yml currently defines a single app service only.
The third axis: run a module (or a few) as its own process, scaled and deployed
independently of the rest. This is where the ports.clj seam pays off most — and
where the most net-new infrastructure is needed. It is not free "by config"
today, but the architecture is positioned for it.
| Asset | How it helps |
|---|---|
Per-module activation | Modules are gated by |
The protocol seam | Consumers depend on the protocol (e.g. |
Wire format ready | Muuntaja (JSON / EDN / Transit) is already in the HTTP stack ( |
Remote-adapter template |
|
Clean data boundaries |
|
Context plumbing |
|
Generic remote-port adapter — a clj-http client that implements a module’s protocol, serializes via Malli, propagates correlation-id / tenant / auth, unwraps errors. None exists yet; all cross-module calls are in-process.
Network resilience — timeouts, retries, circuit breaker, service discovery (hardcoded URLs for MVP). External adapters use :throw-exceptions false but no retry/breaker.
Break the allowlisted dependency cycles — check_deps.clj allows admin↔user, platform↔{user,tenant,admin,workflow,search}. A cycle means two modules can’t be cleanly separated; these must be broken (e.g. extract the shared auth check behind a port) before slicing.
Async option — IEventBus is defined in libs/user/ports.clj but has no implementation. Event-driven decoupling needs a real adapter (Redis Streams / Kafka / RabbitMQ).
Data ownership decision — schema-per-tenant assumes co-located modules in one Postgres. Across services either share the DB (pragmatic) or give each service its own; there are no distributed transactions, so split writes become eventual-consistency.
Service launch mode — boundary.main exposes only server and cli; slicing needs an entrypoint that boots a named module subset as a service.
| Module | Effort | Why |
|---|---|---|
payments | Easy | Zero internal Boundary deps (only Maven). Already a self-contained provider. The natural pilot for the remote-adapter pattern. |
core, observability | Easy | Leaf / infra; no sibling deps. (Usually shared libs, not standalone services.) |
user, tenant, external | With work | Depend on platform + the in-process service assumption in middleware. Need the remote adapter + cycle-breaking (user↔admin, tenant↔platform). |
admin, search, workflow | Entangled |
|
|
Recommended path: build the generic remote-port adapter once, prove it by
extracting payments as a standalone service, then tackle |
The architecture delivers the promise; these are the concrete pieces that make "scale by configuration" fully true. Tracked under the BOU-84 spike:
Realtime Redis pub/sub + connection registry adapter — the one hard blocker for WebSocket across replicas.
Graceful connection draining — configurable shutdown grace so rollouts finish in-flight requests.
Default rate-limit wiring — apply http-rate-limit with the Redis cache in the standard pipeline.
Jobs hardening — fail-fast on missing handler registration; verify scheduled-job atomic claim across workers.
Deploy topology reference — compose + k8s example with N replicas, Redis, a load balancer, instance-id, and a web/worker split.
For functional decomposition (the bigger bet):
Generic remote-port adapter + RPC envelope — clj-http client implementing a module protocol, Malli (de)serialization, context propagation, error unwrap, retry/circuit-breaker. Pilot by extracting payments.
Service launch mode — boundary.main entrypoint that boots a named module subset as an independent service.
Break allowlisted dependency cycles — admin↔user, platform↔{user,tenant,admin,workflow,search} — prerequisite for slicing those modules.
IEventBus implementation — Redis Streams / Kafka adapter for async, event-driven inter-service decoupling (port already defined, unimplemented).
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |