Sizing & Scaling
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.
Three axes
| 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. |
Vertical scaling — by configuration today
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 |
|
Tip
|
Watch the multiplication: with N replicas each holding a pool of |
How the architecture enables horizontal scaling
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.
Horizontal readiness matrix
| 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 |
✅ * |
Replica-safe via |
Topologies
| 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 scales horizontally via |
Web / worker split (future) |
Run dedicated job-worker processes separate from web. The jobs Redis queue already supports it, but there is currently no |
Production checklist
-
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-limitwith the Redis cache if you need a global limit. -
Keep
replicas × maximum-pool-sizeunder Postgresmax_connections. -
Confirm your load balancer points health probes at
/health/ready(503-aware), not/health/live. -
For WebSocket: use
:provider :redison:boundary/realtimeto scale across replicas (ADR-035). Sticky sessions / single-node are only required with the default:in-memoryprovider. -
Add Redis (and, for testing N replicas, a load balancer) to your deployment —
docker-compose.ymlcurrently defines a single app service only.
Functional decomposition (slicing services out)
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.
What already enables 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 |
|
What must be built
-
Generic remote-port adapter — a
clj-httpclient 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 falsebut no retry/breaker. -
Break the allowlisted dependency cycles —
check_deps.cljallowsadmin↔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 —
IEventBusis defined inlibs/user/ports.cljbut 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.mainexposes onlyserverandcli; slicing needs an entrypoint that boots a named module subset as a service.
Sliceability by module
| 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 |
|
|
Tip
|
Recommended path: build the generic remote-port adapter once, prove it by
extracting payments as a standalone service, then tackle |
Known gaps & roadmap
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 adapter — shipped in BOU-85 (ADR-035). WebSocket is now replica-safe via
:provider :redison:boundary/realtime. -
Graceful connection draining — configurable shutdown grace so rollouts finish in-flight requests.
-
Default rate-limit wiring — apply
http-rate-limitwith 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-httpclient implementing a module protocol, Malli (de)serialization, context propagation, error unwrap, retry/circuit-breaker. Pilot by extractingpayments. -
Service launch mode —
boundary.mainentrypoint 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. -
IEventBusimplementation — Redis Streams / Kafka adapter for async, event-driven inter-service decoupling (port already defined, unimplemented).