Hexagonal architecture from line one
Two days after the first commit, I had to make a choice… bash out features and refactor later, or pay the abstraction tax up front? Spoiler: I paid the tax. Mostly worth it.
Context
moitumi is a graph-based personal assistant. The core domain is
embarrassingly simple — two entities, Node and Edge, both with
metadata. That’s it. But everything around that domain is messy —
a filesystem-backed graph store, a Claude CLI for the LLM, a tmux
client for the TUI, an audit log on disk, a config file in TOML,
eventually an iPhone over a network socket.
If the domain bleeds into those concerns, the project dies. So moitumi adopted hexagonal architecture (ports and adapters) on day two, before the first feature shipped. This post is the recorded reasoning — what I bought with the up-front cost, and where the dogma started fighting back.
What hexagonal actually means
Skip this section if you’ve read Alistair Cockburn. The short version, the one that actually matters:
- The domain is pure. No I/O. No file paths, no network sockets, no process spawning. Just types, functions, and invariants.
- A port is an interface — a pure virtual class in C++ — describing what the domain needs from the outside world (read a node, write an event, run a Claude query).
- An adapter is a concrete implementation of a port, living
outside the domain (
FileSystemGraphStore,ClaudeCliProvider,TmuxControlMode). - The domain depends on ports, never on adapters. Adapters depend on the domain.
graph TB
subgraph Domain["Domain (pure, zero I/O)"]
N[Node]
E[Edge]
G[Graph]
SM[Self Model]
CA[Context Assembly]
end
subgraph Ports["Ports (interfaces)"]
P1[ICortexStore]
P2[IActionExecutor]
P3[IAuditLog]
P4[IPresenter]
end
subgraph Adapters["Adapters (I/O lives here)"]
A1[FileSystemCortexStore]
A2[ClaudeCodeExecutor]
A3[FileAuditLog]
A4[TmuxPresenter]
end
Domain --> Ports
A1 -.implements.-> P1
A2 -.implements.-> P2
A3 -.implements.-> P3
A4 -.implements.-> P4
style Domain fill:#e1f5ff,stroke:#0366d6
style Ports fill:#fff4e6,stroke:#d97706
style Adapters fill:#f3e8ff,stroke:#7c3aed
Effectively, the arrows point inward. The domain never knows there’s a filesystem.
Why pay the tax on day one
The standard advice — “extract ports when you need to” — is sound for most projects. To be honest, that’s what I’d say to most people asking. But moitumi has three things that flip the cost-benefit:
- Multiple swappable adapters are inevitable. The AI provider is a port — Claude CLI today, Claude Agent SDK tomorrow, OpenAI’s reasoning models the day after. The graph store is a port — in-memory JSON now, SQLite later, distributed sync eventually. Render surfaces are a port — TUI now, macOS Metal next, iOS after. If any of these aren’t ports from day one, the second implementation re-architects the whole codebase. And I know, without question, all three will get a second implementation.
- Testability is a security property, not a nice-to-have. moitumi handles personal data and executes LLM agents with shell access. Tests need to drive the domain through every state without spawning real Claude processes or touching the real vault. That requires the domain to be reachable through mocked ports. Needless to say, that’s not optional.
- The domain is so small that “pure” is cheap. Two entities. The domain is maybe 500 lines. Keeping it I/O-free isn’t a real constraint — there isn’t enough surface area to even be tempted.
What the seam bought, concretely
Within two weeks of starting, the seam paid off three times. Not hypothetically, not eventually — three actual wins in fourteen days.
1. Mocking out tmux to test the TUI controller
The Controller orchestrates pane creation, agent spawning, and
graph mutation. Its callbacks were originally inline lambdas in
main.cpp — 85 lines for “focus a node,” 55 for “run a Claude
query.” Zero tests. Two bugs shipped:
- Node IDs containing path separators (e.g.
topics/science/physics) broke temp file creation — intermediate directories weren’t created. - tmux panes vanished after Claude exited because
remain-on-exitwas never set.
Both were caught only after a user (…me) saw them. After
extracting FocusHandler and QueryHandler services behind an
ITmuxPaneManager port, the same logic became testable with a
MockTmuxPaneManager. Test count jumped from 56 cases / 290
assertions to 80 / 386 in one session. No more inline tmux logic in
main.cpp.
2. Audit log as test oracle
The IAuditLog port turned out to be the most useful abstraction in
the codebase, for a reason I didn’t anticipate. Production uses
FileAuditLog (a fast SPSC-queue logger). Tests use MockAuditLog
which keeps events in memory.
For multi-step flows — “user focuses a node, controller spawns a pane, agent emits a tool call, graph is mutated” — the test asserts the exact sequence of audit events. The audit log isn’t just for debugging — it’s the test oracle for integration flows. Without the port, this pattern would require either logging to stderr and grepping (brittle) or peppering production code with test hooks (a code smell). That being said, this was an accident. I built the port for a different reason and only later realized what it actually gave me.
3. Daemonization without rewriting the world
Eight days in, I decided the core should live in a long-running
daemon (moitumid) and the TUI should connect to it as a client.
(More on this in a later post.)
The TUI’s existing IPC contract was the IPresenter interface —
methods like sync_graph, focus_node, node_updated. To
daemonize, the protocol was the interface, serialized over a Unix
socket. I wrote one new adapter — DaemonPresenter — that takes
calls to IPresenter methods, serializes them to JSON, and writes
them to a socket. The domain didn’t change. The TUI didn’t change.
The daemon “ate” the existing contract.
That refactor took two days. If IPresenter hadn’t existed as a
port, it would have been two weeks. Maybe more.
However… where the dogma starts to bite back
Hexagonal architecture is not free. Three places it complicates moitumi:
Premature ports for single-implementation things
ControlProtocol and WindowRegistry started life as services
with tmux-specific concepts (window IDs, pane IDs, layout splits).
The “correct” hexagonal answer was to introduce an IPresenter
port immediately and hide tmux behind it.
I didn’t. The codebase had ~5,800 lines and only one surface implementation. Designing an abstraction against a single known implementation produces bad abstractions — you end up with the shape of tmux dressed in an interface mustache, and when SwiftUI arrives you rewrite anyway. To be honest, I’ve made that mistake on previous projects and the cost of “the wrong abstraction” is worse than “no abstraction yet.”
The decision (recorded as ADR-012) — keep the tmux concepts in services for now, mark the boundary, and refactor when the second surface forces the issue. The containment rules are:
- No new service-layer code references
ITmuxPaneManagerorWindowRegistrydirectly. - Domain commands use intent-based names (
Enter, notMakeCentral). - No new tmux concepts in
ControlProtocol.
This is a deliberate violation of the hexagonal ideal. The tradeoff is “less code now, planned refactor later” vs. “more code now, abstraction debt later.” For a system at this size with one surface, less code wins. Ah well, ideal is the enemy of done.
Port explosion
Every interface has a cost — a header, a virtual function table, an extra translation between domain types and adapter types. With ~20 ports today (graph store, audit log, tmux pane manager, action executor, presenter, transport, codec, …), the boilerplate is real.
The discipline I’ve kept — a port exists only if it has either multiple adapters today, or is the testability seam for a unit. No “future-proofing” ports. If something has one implementation and one test mock that mirrors it, that’s already two — port it. Otherwise, wait.
The temptation to abstract values, not just behavior
Hexagonal is about isolating behavior. But once you have ports
for behavior, the next instinct is to port everything — make every
domain value an interface, every enum a strategy. That direction is
ruinous. moitumi’s domain has plain structs and concrete value
types (NodeId, NodePath, Score, Depth). They aren’t ports.
They aren’t behind interfaces. They’re just types.
The CLAUDE.md rule: no raw primitives in port signatures. Use
domain-specific using aliases (NodeId = std::size_t,
NodePath = std::string). This makes intent explicit at the type
level without dragging in interface dogma.
The skeleton, after eight weeks
include/moitumi/
domain/ # Node, Edge, Graph, Self Model — pure, zero I/O
ports/ # ICortexStore, IActionExecutor, ITmuxGateway, ...
adapters/ # JSON, file system, tmux, socket, TOML, ...
services/ # Orchestration, layout, navigation
tui/ # TUI view components
src/
domain/ # Domain implementations
adapters/ # Adapter implementations
services/ # Service implementations
tui/ # TUI view implementations
The directory structure is the architecture. A file in domain/
that includes a header from adapters/ is a bug — the build won’t
even allow it. The tests live in a mirror tree under tests/ and
use the same ports, with mocks where production uses real adapters.
What I’d tell someone starting their own version
- Pay the tax once, where the seams will eventually appear. AI provider, render surface, persistence. These will get a second implementation. Port them on day one.
- Don’t port everything. A port without a second adapter (or a testability story) is just a virtual function for no reason.
- Use the audit log as a test oracle. Highest-leverage port in the codebase. Mock it in tests, assert the event sequence, sleep well.
- Mark the leakage you can’t fix yet. ADR-012 is a placeholder for a refactor I knew was needed but couldn’t justify until the second surface arrived. Naming the debt is half the cure.
Overall — pay the tax for the seams you know will split, and leave the rest alone until they ask to be split. Most “premature abstraction” arguments are really “wrong abstraction” arguments in disguise.
Source pointers
DEVLOG.md— entries of2026-03-14,2026-03-16PLAN.md— ADR-002 (Architecture), ADR-012 (Surface-specific containment)docs/architecture/hexagonal-architecture.md— full Mermaid diagram of ports and adapters in the live system
Cross-posted from the moitumi dev blog.