19 KiB
Architecture
This document records the bounded-context decomposition of the enkly-ai monolith, why each boundary is drawn where it is, the communication rules enforced throughout, and the handful of places where splitting a single process into eight forced a small, deliberate change in behavior.
1. Bounded contexts (the context map)
The monolith's packages were grouped by technical layer
(internal/handlers, internal/db, internal/llm, ...); this port
regroups them by business capability, each with its own data and
its own deployable:
┌─────────────┐
│ gateway │ (HTTP/HTMX edge — no DB)
└──────┬──────┘
┌──────────┬───────┼────────┬──────────┬──────────┐
▼ ▼ ▼ ▼ ▼ │
┌───────┐ ┌───────┐┌──────┐┌─────────┐┌─────────┐ │
│ iam │ │billing││ conv │ │workspace││knowledge│ │
└───┬───┘ └───┬───┘└──────┘└─────────┘└────┬────┘ │
│ │ │ │
│ events │ events │ ▼
▼ ▼ │ ┌───────┐
┌──────────────────┐ └──▶│ agent │
│ notification │ └───┬───┘
└──────────────────┘ │
(agent also calls billing,
conversation, workspace,
knowledge — see §3)
| Context | Aggregate(s) | Replaces (monolith) |
|---|---|---|
| IAM | User, Session, VerificationCode, PasswordResetToken | internal/handlers/auth.go, internal/db/users.go, internal/auth/* |
| Billing | Subscription, StripeEvent, DailyUsage | internal/handlers/billing.go, internal/billing/*, internal/db/subscriptions.go |
| Conversation | Conversation, Message, ToolCall | internal/db/conversations.go |
| Workspace | (the sandboxed filesystem itself) | internal/llm/tools.go + sandbox.go, internal/handlers/files.go |
| Knowledge | CodeChunk (pgvector document) | internal/rag/* |
| Agent | Stream (an in-flight run) — a saga coordinator, owns no table | internal/llm/agent.go + client.go, the run-orchestration half of internal/handlers/chat.go |
| Notification | (stateless consumer) | the email-sending half of internal/handlers/auth.go, internal/mail/* |
| Gateway (BFF) | (no domain state) | internal/handlers/{pages,ui,deliverables,middleware}.go, internal/render/* |
Why Agent is its own context, not folded into Workspace or Conversation: the agent run loop is a process that uses workspace (tool execution), knowledge (semantic search) and conversation (persistence) to do its job, but owns none of their data. That's a textbook application service / saga, not an aggregate of any of the three — giving it its own deployable keeps "what tools exist and how a turn is orchestrated" independently scalable and independently restartable from "where files live" or "what was said."
Why Workspace is separate from Agent, even though today only Agent calls it: the sandboxed filesystem is also directly reachable by a human through the gateway's upload/download/explorer panel. Coupling it to Agent would mean every file-browsing click goes through the LLM orchestration service for no reason, and would block scaling "concurrent agent runs" independently of "workspace storage."
Why rendering lives only in Gateway: pkg/renderkit (Markdown,
syntax highlighting, code-snippet extraction) and every html/template
file are a presentation concern, not a domain concept — no bounded
context's business rule depends on HTML. Agent streams plain text and
structured tool-call data; Gateway is the one place that turns that
into HTML. This is also what makes Agent's gRPC contract
(api/proto/agent/v1/agent.proto) reusable by a future non-HTML client
(a CLI, a different front end) without change.
2. Database-per-service
Every context that owns data gets its own Postgres database (not just its own schema/tables in a shared instance):
iam→enkly_iam(users, sessions, email_verification_codes, password_reset_tokens)billing→enkly_billing(subscriptions, stripe_events, usage_daily)conversation→enkly_conversation(conversations, messages, tool_calls)knowledge→enkly_knowledge(documents, pgvector)
workspace has no database — the sandboxed directory tree itself
is its data, on its own Docker volume.
Consequence worth naming explicitly: the monolith's schema.sql
had user_id BIGINT REFERENCES users(id) ON DELETE CASCADE on
conversations, subscriptions, and usage_daily. Postgres cannot
enforce a foreign key across two separate databases, so every one of
those is now a bare user_id BIGINT with no cascade delete. The
monolith never actually exposed an "delete my account" feature, so no
behavior is lost today — but if one is added later, deleting a user
must become a saga (iam deletes the user, then publishes a
user.deleted event for billing/conversation to react to and delete
their own rows), not a single SQL statement. This is the standard,
unavoidable cost of database-per-service and is called out here so it
isn't rediscovered the hard way.
3. Communication rules
"gRPC for synchronous communication, RabbitMQ for asynchronous communication" — applied throughout as: if the caller needs an answer (or needs to know the write succeeded) before it can proceed, it's a gRPC call; if the caller doesn't need to wait for the result, it's an event.
Synchronous (gRPC) — api/proto/*/v1/*.proto
Every cross-context call that's part of a request's critical path:
- Gateway → IAM: session validation on (almost) every request, login, signup, ...
- Gateway → every other service: composing a page from their data
- Agent → Billing: check-and-increment the daily quota before a run starts
- Agent → Conversation: persist each message/tool-call as a turn proceeds
- Agent → Workspace: every filesystem/bash tool call
- Agent → Knowledge: the
search_codebasetool dispatch_subagentmakes no new outbound call of its own kind: it runs a second, nestedagentrun.Agentin-process, sharing the Agent service's own vLLM-backed client and making the same Workspace/Knowledge gRPC calls an ordinary tool call would, just via atoolexec.restrictedExecutorthat only forwards the read-only subset (read_file/list_directory/glob_search/grep/search_codebase). No new service, no new proto — seeservices/agent/internal/infrastructure/toolexec/subagent.go.solve_ml_math(likewrite_todos) makes no outbound call at all: it's pure computation overpkg/mathkit, a dependency-free numerical engine (linear algebra, calculus/gradients, statistics, optimization, ML primitives) that runs entirely inside the agent process — seepkg/mathkit's package doc andservices/agent/internal/infrastructure/toolexec/mlmath.go.- Agent
StreamTurn: a server-streaming RPC — the gRPC replacement for the monolith's Server-Sent Events, carrying the same token/thinking/tool-call/done event sequence; the gateway re-emits each message as an actual SSE frame to the browser (seeservices/gateway/internal/handlers/chat.go).
Asynchronous (RabbitMQ) — pkg/eventbus
One topic exchange (enkly.events); routing keys of the form
<context>.<aggregate>.<event>. Today's only real consumer is
notification, bound to:
iam.user.verification_code_issued→ send the signup/resend emailiam.user.password_reset_requested→ send the reset-link email
Also published, with no consumer yet (kept so the pattern — every
aggregate commit gets an event — is consistent across every context,
and so a future service can bind without a producer-side change):
iam.user.registered, iam.user.email_verified,
billing.subscription.changed, conversation.message.created.
Trust boundary of the event bus: VerificationCodeIssued and
PasswordResetRequested carry the raw code/token on the bus, not just
an ID. This is a deliberate trade-off (documented in
pkg/eventbus/events/iam_events.go) rather than an oversight: it lets
notification send the exact email the monolith sent without needing
read access to iam's token tables (which would violate
database-per-service far more seriously than this does). It's safe
only because RabbitMQ is never exposed outside the private service
network — the same trust level the monolith implicitly gave its own
in-process function call. A production hardening pass should put this
exchange behind TLS + per-service credentials (RabbitMQ supports both)
even on a private network, as defense in depth.
4. One Go module, many deployables
Every service shares a single module enkly (one go.mod at the repo
root) rather than one go.mod per service. This is a deliberate,
pragmatic choice: true microservice independence comes from separate
processes, separate databases, separate containers, and a versioned
API contract between them (all of which this repo has) — not from
separate Go dependency graphs, which mainly adds replace-directive
and version-skew overhead in a monorepo without buying any of the
actual deployability benefits. Each service still has its own
cmd/server/main.go, its own Dockerfile that builds only that one
binary, and starts, scales, and fails independently of the others.
5. Layering within a service
internal/domain/ entities + repository interfaces (port). No sql, no net/http, no generated gRPC code.
internal/application/ use cases. Depends on domain only. Transport-agnostic — unit-testable without a server.
internal/infrastructure/ adapters: Postgres repos, the Stripe/vLLM HTTP clients, the sandboxed filesystem.
internal/interfaces/grpc/ the ONLY files that import this service's generated `gen/<ctx>/v1` package.
Dependency direction is inward: interfaces and infrastructure both
depend on domain (through its interfaces); domain depends on
nothing. This is what lets, e.g., services/iam/internal/application
be tested with a fake UserRepository and no database at all.
6. What's verified, what isn't
Every .proto file in api/proto/ was compiled for real with
protoc + the actual protoc-gen-go/protoc-gen-go-grpc plugins —
gen/ is genuine generated code, not hand-written stand-ins. Every
hand-written .go file in this repo passes gofmt -l (verified file
by file as it was written).
What this port could not do, for lack of outbound network access
to proxy.golang.org/google.golang.org in the authoring sandbox: run
go build/go mod tidy end-to-end across the whole dependency graph
(google.golang.org/grpc, github.com/lib/pq,
github.com/rabbitmq/amqp091-go, github.com/pgvector/pgvector-go,
github.com/gomarkdown/markdown), and so no go.sum is committed.
Every service's Dockerfile therefore runs go mod tidy at build time
(after copying in gen/, pkg/, and that service's own directory) to
resolve and lock the graph on first build — this needs ordinary
internet access, same as any other Go project, and is expected to
succeed on any normal developer machine or CI runner. make build
(plain go build, no Docker) needs a go.sum to exist first, so run
go mod tidy at the repo root once before using it locally. Once a
go.sum is committed, each Dockerfile's go mod tidy line should be
swapped back to go mod download (noted inline in
services/iam/Dockerfile) for faster, better-cached rebuilds.
The same constraint applied to the later Ollama→vLLM migration
(pkg/vllmkit, replacing pkg/ollamakit): pkg/vllmkit itself has no
dependency beyond the Go standard library, so it was verified for real
— go build/go test both pass in an isolated module — but every
file that imports it still depends on the same ungenerated go.sum,
so those files could only be checked with gofmt -l (a full syntax
parse; it does not resolve imports or type-check across packages), not
a full go build ./.... Run go build ./... (or make build) as the
first gate on that change in an environment with normal network
access, before trusting it beyond what gofmt already confirms. See
CHANGES.md's most recent entry for the full migration writeup.
7. Known gaps / simplifications
Called out here rather than silently shipped, each with why it's safe to defer:
- Session validation latency.
gateway'sRequireAuthnow makes oneIAM.ValidateSessiongRPC call per request, where the monolith did one local Postgres query in the same process. For most deployments this is low-single-digit-millisecond overhead on an internal network and is the correct place to start; if it becomes a bottleneck, the standard fix is a short-TTL cache in the gateway (or switching sessions to signed, self-contained tokens) — not re-merging the services. - Reattaching to a run after a page reload. The monolith could show
a still-in-flight reply immediately on reload
(
StreamManager.activeFor). The gateway doesn't re-wire this today (it would need a new "is there an active run for this conversation" Agent RPC with no other caller) — a reload mid-reply shows the completed answer on next load instead of resuming the live view. The reply itself is unaffected; this is UI polish, not correctness. - Deleting a conversation mid-run. The monolith proactively
cancelled any in-flight stream for a conversation being deleted.
Agent's API has no "stop by conversation" RPC (only by
stream_id), so a straggling run's writes will fail (logged) against the deleted conversation rather than being pre-empted. Low-impact and easy to close later by adding that RPC. - Verification/reset email confirmation. Because sending email is
now asynchronous (RabbitMQ),
iam'sSignUp/ForgotPasswordRPCs can no longer report back "the email failed to send" synchronously the way the monolith occasionally could — they report success once the code/token is persisted and the event is published. This is the expected, intentional cost of decoupling a side effect onto a message bus, not a bug. - Per-call bash timeout.
WorkspaceService.RunBashCommandaccepts atimeout_secondsfield for forward-compatibility, but (matching the monolith, which only ever had one process-wide timeout) does not yet apply it per call. - Chat attachments aren't a schema field. The composer's "Add
files or photos" (
services/gateway/internal/handlers/attachments.go) uploads into the workspace underattachments/and folds the resulting paths into the prompt string itself, as a trailing[Attached files]block —conversation.Messagehas no attachments field of its own, and adding one would be a schema change across the conversation/gateway boundary. The trade-off: attachments only ever round-trip as a specific text shape insidecontent, parsed back out byrenderUserMessage, instead of being structured data the conversation service understands natively. Also: removing a staged chip before sending, or never sending at all, leaves the uploaded file behind in the workspace — nothing currently reaps attachments that were uploaded but never attached to a sent message. - Zip attachments extract one level deep. A
.zipattached to the chat is unpacked intoattachments/<name>/(gateway-side, with its own zip-slip guard on top of workspace's existing path jail, plus caps on entry count and total uncompressed size) so the agent's existingread_file/list_directory/grep/glob_searchtools can read what's inside it — no new agent tool needed. A.zipnested inside that.zipis written out as-is rather than recursively extracted;unzipisn't inDefaultAllowedCommands(services/workspace/internal/infrastructure/sandboxfs/sandbox.go), so the agent can't shell out to unpack it further unless an operator adds it toENKLY_BASH_ALLOWLIST. - Language preference storage. The language switcher (
pkg/i18n,services/gateway/internal/handlers/i18n.go) persists a visitor's chosen UI locale in a year-longenkly_langcookie, not on the IAMUserrow. That was a deliberate choice, not an oversight: adding apreferred_languagefield means extendingiam.proto'sUsermessage and an RPC, which means regenerating every language's generated client/server code undergen/— this repo'sgen/directory is checked in and hand-maintained, and doing that regeneration safely needsprotocand the project'sbuf/codegen toolchain, which isn't always available in every environment this code is edited in. A cookie needs neither: it survives logins and browser restarts, just not a switch to a second device. The same reasoning is why the visitor's locale reaches the agent service as gRPC metadata (x-enkly-lang, set inchat.go'swithLangMetadata, read inservices/agent/internal/interfaces/grpc/server.go'slangFromIncomingContext) rather than as a newStartTurnRequestfield. Moving either onto the User row/proto later is a clean, backward-compatible addition —localeFrom/langFromIncomingContextare the only two places that would need a new "check the account first" branch — just not one to do withoutprotocon hand to verify the regenerated code. - UI translation coverage.
pkg/i18nships English and French complete; Spanish, German and Portuguese cover the nav/auth/composer chrome and fall back to English for the rest (quota/error copy, the subscription and files panels) — seepkg/i18n/locales/*.jsonandT's fallback behavior inpkg/i18n/i18n.go.subscription.htmlandfiles.htmlaren't wired to the switcher yet (noLangfield on their page-data structs). None of this affects how the agent itself talks to a user — seeagentrun.SystemPrompt's "## Language" section andagentrun.WithPreferredLanguage, which work from any locale regardless of how much of the surrounding chrome has a catalog entry for it.
8. Operational hardening before a real rollout
Deliberately out of scope for this restructuring (it's infrastructure polish orthogonal to the DDD boundary work), but worth a checklist:
- mTLS between services (today: plaintext gRPC on a private Docker network — fine inside a single trusted VPC, not fine across one)
- RabbitMQ publisher confirms + a dead-letter exchange for poison
messages (today: at-least-once with a bounded retry via
Nack) - Replacing the in-memory
authsupport.Limiter/domain.Manager(agent's stream registry) with something shared across replicas (Redis) once any service runs more than one instance — both are currently per-process, same as the monolith's own in-memory limiter was