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:

  1. 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.
  2. 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.
  3. 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-exit was 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:

  1. No new service-layer code references ITmuxPaneManager or WindowRegistry directly.
  2. Domain commands use intent-based names (Enter, not MakeCentral).
  3. 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

  1. 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.
  2. Don’t port everything. A port without a second adapter (or a testability story) is just a virtual function for no reason.
  3. Use the audit log as a test oracle. Highest-leverage port in the codebase. Mock it in tests, assert the event sequence, sleep well.
  4. 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 of 2026-03-14, 2026-03-16
  • PLAN.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.