System Design · Lesson 1 · Updated July 6, 2026

System Decomposition — Bella's Current Service Boundaries

What Bella splits, what it deliberately does not split, and what broke when Compose guarantees were lost

This lesson has been refreshed against the latest public reference: MetatechID/bella-enterprise main at dc42148. The important update is that Bella is no longer best described as “six containers plus local Postgres and Redis.” The current shape is a containerized application tier backed by external managed stores, with a larger and more explicit ADR trail.

Current reference snapshot

Bella runs Web, API, Worker, WhatsApp Bridge, Hindsight, optional Caddy/OTel, a wiki-dashboard, and deployment/reconciliation jobs as Dockerized services. Postgres, Redis, and MongoDB are external env-only stores via DATABASE_URL, REDIS_URL, LOGS_REDIS_URL, and MONGO_URL. The repo now contains 80 ADR files and the Drizzle schema has 81 table declarations.

The principle: split runtime responsibilities, not every data dependency

Service decomposition is not “make everything a microservice.” A boundary earns its cost when the two sides have different trust levels, runtime requirements, failure modes, or operator lifecycles. Bella's current decomposition is mostly about those runtime boundaries. Its data layer is intentionally not decomposed into one database per service.

The pattern is:

Current topology

flowchart TB A["Browser / user"] --> B["Traefik or Caddy"] B --> C["Web\nReact static server :3000"] C --> D["API\nHono :3000"] D --> E["Worker\nengine owner :8081"] F["WhatsApp Bridge\nGo :8080"] --> E E --> G["Engines\nclaude-code · hermes · pi · nullclaw"] E --> H["Hindsight\norg memory :8888"] D --> I["External stores\nPostgres · Redis · Mongo"] E --> I style C fill:#dbeafe,stroke:#2563eb style D fill:#dbeafe,stroke:#2563eb style E fill:#fef9c3,stroke:#ca8a04 style F fill:#f0fdf4,stroke:#16a34a style H fill:#faf5ff,stroke:#9333ea style I fill:#faf5ff,stroke:#9333ea

Production Web serves on :3000 inside the container; 5173 is a host/debug mapping and Vite dev port, not the production runtime. Support services such as wiki-dashboard, caddy, otel-collector, and migrate/reconciliation jobs exist in the deployment but are kept out of this main-path diagram so the boundary lesson stays readable on mobile.

Correction from the previous lesson

The previous version described web as “React/Vite :5173” and listed Postgres/Redis/logs-redis as in-stack containers. The latest reference says production web is a static Node server on :3000, while Postgres, Redis, Log Buffer Redis, and MongoDB are external managed services reached by env URLs.

Support services are still part of the system

Not every container deserves a new product boundary in the main diagram. The latest compose reference also includes these support services:

Boundary 1: Web ↔ API — presentation split, but same product surface

The Web service is a React SPA served by a Node static server. It talks to the API using relative routes like /api, /auth, /chat, /setup, and /health/*. The Web container joins the public routing network so Traefik/Dokploy can reach it, while it proxies application calls to http://api:3000 internally.

This boundary is conventional but still earned:

Design principle

Split presentation from capability execution, but keep URLs relative when the app and API are one product surface. That preserves portability across Dokploy, Caddy, and local dev without rebuilding the SPA for every domain.

Boundary 2: API ↔ Worker — the reasoning-engine wall

This is Bella's most important boundary. The Worker is the only process allowed to spawn or speak to a reasoning engine. The API can authenticate, persist, validate, and forward, but it must not shortcut into Anthropic/OpenAI/Claude SDK calls.

// Forbidden outside the Worker
❌ import Anthropic from '@anthropic-ai/sdk'
 anthropic.messages.create(...)
❌ import { query } from '@anthropic-ai/claude-agent-sdk'

The updated nuance: the default engine is no longer called “Bella brain.” The roster is now claude-code, hermes, pi, nullclaw, and nullclaw-lean. ADR 0063 removes remote-hermes; per-tenant engine containers are gone because no org used that path and it added provisioning complexity.

flowchart TB A["API /chat"] --> B["Worker"] B --> C["factory.getAgent(orgId)"] C --> D["claude-code"] C --> E["hermes"] C --> F["pi"] C --> G["nullclaw"] B --> H["Action Gateway\ntyped verbs"] H --> I["External systems"] style B fill:#fef9c3,stroke:#ca8a04 style C fill:#fef9c3,stroke:#ca8a04 style H fill:#f0fdf4,stroke:#16a34a

Engine selection is per-org, but every engine enters through the Worker seam and reaches tools through the Gateway surface.

Why this boundary exists:

  1. Credential isolation. Engines have different auth mechanisms. Centralizing in the Worker keeps provider credentials out of the web-facing API path.
  2. Auditability. Every turn needs a durable record. If API routes can call models directly, audit capture becomes optional and drifts.
  3. Engine distinctness. ADR 0021 says non-default engines must hard-fail when they cannot serve a turn; they must not silently borrow claude-code.
  4. Provider-neutral routing. ADR 0042 turns model configuration into base URL + key + model, so the selected engine path, not a parallel probe, owns execution.

Boundary 3: Worker ↔ WhatsApp Bridge — language and protocol reality

The WhatsApp Bridge remains a Go service built on whatsmeow. The rest of Bella is TypeScript. This split is not aesthetic; it is a protocol-library decision.

flowchart TB A["WhatsApp"] --> B["wa-bridge\nGo + whatsmeow"] B --> C["Worker\nmessage listener"] C --> D["Selected engine"] D --> C C --> B B --> A style B fill:#f0fdf4,stroke:#16a34a style C fill:#fef9c3,stroke:#ca8a04

ADR 0007 rejected a TypeScript/Baileys rewrite. The fix for protocol drift is updating whatsmeow, not rewriting the bridge.

The lesson: language mismatch is cheaper than protocol uncertainty. A rewrite would remove one boundary but replace it with new failure modes in the most brittle part of the product: WhatsApp connectivity.

Boundary 4: App Redis ↔ Log Buffer Redis — bulkhead, now external

The old lesson described two Redis containers. The current reference changes the deployment shape, not the design pattern. Bella still separates app queues/cache from diagnostic log buffering, but those Redis instances are now external env-only stores:

StoreEnv varPurposeFailure posture
App RedisREDIS_URLBullMQ queues, scheduled jobs, cacheRequired; API/Worker fail fast if missing
Log Buffer RedisLOGS_REDIS_URLRaw log stream for Superadmin Log ConsoleOptional; readers degrade when unset

This still implements the bulkhead pattern. The diagnostic firehose should not evict queue/session keys or OOM the app store. ADR 0019 originally framed this as a dedicated logs-redis container; the current compose comments say to provision equivalent isolation externally — a second instance, logical DB, or namespace with bounded eviction.

Boundary 5: Hindsight — memory as an application, not a silent sidecar

Hindsight is Bella's org-memory store. The older lesson called out a stale deployment bug: the Worker could not reach Hindsight because HINDSIGHT_URL was missing. In the current canonical compose files, both API and Worker pass HINDSIGHT_URL=http://hindsight:8888.

The latest problem moved up one level: Hindsight must work both in a single Compose project and in per-service Dokploy Applications. ADR 0059 turns it into a thin Bella-wrapped application that fetches LLM credentials from an internal API endpoint at boot instead of depending on a shared bella_config volume. That makes credential edits DB-backed and panel-driven.

Design principle

A service boundary is incomplete until configuration and credentials follow the same deployment model. A container that only works when a hidden shared volume happens to exist is not really portable across deployment modes.

Boundary 6: Deployment reconciliation — Compose guarantees vs per-service reality

This is the best system-design lesson in the updated reference. A single Docker Compose project gives Bella four guarantees “for free”:

  1. Service-name DNS: api, worker, wa-bridge, hindsight resolve on one network.
  2. Shared volumes: bella_config, Hindsight data, secrets, and tenant directories are the same volume.
  3. Startup ordering: API and Worker can depend on migration/reconciliation finishing first.
  4. One env surface: floor secrets and store URLs are identical across services.

ADR 0058 documents what broke when the operator split Bella into separate Dokploy Applications: service names stopped resolving, volumes with the same name were not actually the same volume, cross-app depends_on disappeared, and per-app env drift became possible. This caused real silent failures: Hindsight recorded zero memories, Redis queues could no-op, and encryption-key drift could make Worker config undecryptable.

flowchart TB A["One Compose project"] --> B["DNS + volumes + env\nprovided structurally"] B --> C["Services start with\nshared assumptions"] D["Per-service apps"] --> E["Guarantees vanish"] E --> F["Must recreate explicitly\nnetwork · env · stores"] F --> G["Boot-intrinsic\nreconciliation"] style A fill:#dbeafe,stroke:#2563eb style D fill:#fef2f2,stroke:#ef4444 style G fill:#f0fdf4,stroke:#16a34a

The newer ADRs shift the lesson from “use a migrate container” to “make reconciliation intrinsic to every deployment mode.”

ADR 0067 then refines the answer: do not rely on a fragile, hand-ordered migrate app for every reconciliation duty. Postgres DDL should ride boot-time runMigrations(); Mongo indexes should ride the first-connect hook; heavy backfills should run after health with advisory locks and version sentinels. The deployment should converge on both fresh installs and updates without a human SSHing in.

The updated boundary map

BoundaryCurrent shapeWhy it existsReference
Web ↔ APIStatic React server :3000 ↔ Hono :3000Presentation vs authenticated product APIREADME.md, TECHNICAL.md
API ↔ WorkerAPI forwards turns; Worker owns enginesCredential isolation, auditability, engine selectionCLAUDE.md, ADR 0021, ADR 0042
Worker ↔ BridgeTypeScript Worker ↔ Go whatsmeow bridgeProtocol library quality beats language uniformityADR 0007
App Redis ↔ Log RedisExternal REDIS_URL and LOGS_REDIS_URLBulkhead between queues and diagnostic firehoseADR 0019, compose comments
Worker ↔ HindsightOrg memory service :8888, host :8100Shared long-term memory and recall across enginesADR 0010, ADR 0059
Postgres ↔ MongoDBOperational state in Postgres; transcript + executions + outcomes in MongoConversation history/audit/metrics are document-shaped and org-standardADR 0048, ADR 0049, ADR 0053
Compose ↔ per-service appsSame services, different operator guaranteesPer-service deployments lose implicit DNS/volume/env/orderADR 0058, ADR 0067
Support serviceswiki-dashboard, caddy, otel-collector, migrate/reconciliation jobsOperational surfaces and deployment plumbing should not be confused with core product boundariesdocker-compose.yml, ADR 0067
Tenant isolationProposed app-layer seam plus Postgres RLS backstopForgotten org predicates must fail closedADR 0077

Decision framework: when should Bella split something?

Use this order. It is more useful than “microservice or monolith?”

  1. Different trust level? Split. Example: API vs Worker, because engines and provider keys live behind the Worker seam.
  2. Different protocol/runtime? Split. Example: Go WhatsApp Bridge because whatsmeow is the reliable protocol stack.
  3. Different failure budget? Split or isolate the store. Example: Log Buffer Redis must not starve App Redis.
  4. Different operator lifecycle? Split carefully. Example: Hindsight as a Dokploy Application only works after credentials/networking follow the same model.
  5. Only different code folder? Do not split. Keep it as a module/package unless runtime pain proves otherwise.
  6. Same data owner, same transaction boundary? Be suspicious of splitting. A distributed monolith is worse than a modular monolith.
The trap

The dangerous move is not “one service” or “many services.” The dangerous move is relying on hidden platform guarantees. Compose gave Bella DNS, ordering, shared volumes, and env identity structurally. Per-service Dokploy apps do not. If the architecture diagram still assumes those guarantees, the diagram is lying.

What's next

The next lesson should cover Bella's Action Gateway: why ADR 0010 pushed engines toward a shared typed action surface, how the catalog grew past 100 verbs, and why http.request remains a controlled escape hatch instead of giving every engine raw API access.

Read before the next lesson: ADR 0010 — Stateful invertibility and Gateway convergence.

Bella no longer runs app Redis and logs-redis as local containers in the current reference. What design idea still remains?
CQRS — command writes and query reads use separate models
Bulkhead isolation — log traffic must not evict app queue/cache keys
Event sourcing — every state change is replayed from an append-only log
Database sharding — tenants are evenly distributed across Redis nodes
What did ADR 0058 reveal about splitting Bella into per-service Dokploy Applications?
It removed the need for external Postgres and Redis
It made remote-hermes the default engine for all tenants
It lost Compose's implicit DNS, volume, ordering, and env guarantees
It forced the WhatsApp Bridge to be rewritten in TypeScript