Pular para o conteúdo
DuranteDurante
ALL SYSTEMSGet Access

27 semanas · 54 textos · Escritos durante a construção

Notas de campo de um SO de IA pessoal em voo

Toda terça-feira, um ensaio perene sobre o que aprendi enquanto envio o DuranteOS. Toda sexta, um boletim da semana. Cerca de 108 mil palavras e contando — para construtores que preferem ver a fundação ser lançada a ler o press release.

Assinar · Ensaio de terça

Cerca de 3.800 construtores leem isso toda semana.

Hexagonal in Practice: The Ports and Adapters I'm Pulling Studio Toward

Studio has been live for twenty-five days. The first three weeks of the codebase had route handlers calling Prisma directly with provider calls inline. The migration to a Hexagonal architecture started this past Monday and is the load-bearing decision for everything Studio is going to do across the next twelve providers, three storage backends, and unknown number of auth surfaces. This is the design essay I am writing as the migration is underway, with Cockburn on the principle and Fowler on the strangler-fig path that gets there without a rewrite.

I am writing this on a Tuesday in the first full week of February, twenty-one weeks and roughly two hundred and forty-five commits into building DuranteOS. Studio has been live for twenty-five days. The Hexagonal migration of the gateway started this past Monday morning and will run for the next four to six weeks of focused work.

The first three weeks of Studio's codebase, before launch, had route handlers calling Prisma directly with provider calls inline. The OpenAI logic lived inside the route handler. The Anthropic logic was a different route handler with its own inline client. Each new provider I added in the last two weeks of pre-launch — Replicate, ElevenLabs, Mistral — repeated the same shape. By launch day Studio had four providers wired and the four route handlers had begun to look enough like each other that the cost of not extracting a shared abstraction was visible to me by the day after Studio shipped.

The pattern that prevents the kind of pain I am about to be in is Hexagonal Architecture, also called Ports and Adapters. Alistair Cockburn named it in 2005. It has been re-discovered under a dozen names since. I am writing the design essay before I finish the migration on purpose — if I do not commit the shape to the page now, I will discover the wrong shape under deadline pressure when the seventh provider needs to ship.

This is the concrete port/adapter map I am pulling Studio toward, with the trade-offs named and Cockburn's twenty-year-old article on the desk beside the editor.

Hexagonal in one sentence

The application sits in the middle. Ports define what the application offers (driving) and what it needs (driven). Adapters are concrete implementations of those ports — HTTP, CLI, DB, third-party API. Swap any adapter without touching the application.

The Cockburn angle: the original 2005 framing

Alistair Cockburn's original article opens with the problem statement that still defines the pattern:

"Allow an application to equally be driven by users, programs, automated tests or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases."

The keyword is equally. The application should not know — and should not care — whether it is being driven by an HTTP request, a Vitest unit test, a CLI invocation, or a scheduled cron job. All four are driving adapters talking through the same port. The application's port-facing API is identical regardless.

The mirror principle applies on the other side. The application should not know whether it is talking to PostgreSQL, an in-memory mock, or a stub during tests. Those are driven adapters and the application sees only the port.

Cockburn's diagram famously uses a hexagon with no architectural significance — the six sides are purely visual, conveying that there can be many ports without privileging top-bottom or left-right. Most production "hex" architectures end up with two driving ports and four-to-eight driven ports. Studio is going to land somewhere around four driving and nine driven if I follow my own sketch.

The Fowler angle: how I want to migrate without a rewrite

Martin Fowler's relevant contribution here is not the Hexagonal pattern itself (which he did not invent) but the Strangler Fig migration pattern that lets a tightly-coupled system grow into a Hexagonal one without a rewrite.

Studio did not start Hexagonal. The first three weeks of the codebase had route handlers calling Prisma directly, with provider calls inline. The migration to Hexagonal is being applied incrementally over the next six weeks, one boundary at a time, using exactly Fowler's Strangler Fig recipe.

The Strangler Fig migration into Hexagonal

  1. Identify the seam. The first seam is provider calls — they are the worst-coupled, with each provider's quirks inline in the route handler. Extract a Provider interface (the port). I started this on Monday.
  2. Build one adapter behind the port. Take the existing inline OpenAI logic, move it into OpenAIAdapter implements Provider. The route handler now calls provider.chat(...) instead of openai.completions.create(...). Target by end of this week.
  3. Migrate calls one at a time. Each route handler that calls OpenAI directly is updated to receive a Provider via dependency injection. Tests are updated to inject a mock Provider. Target by end of next week.
  4. Build sibling adapters incrementally. AnthropicAdapter, GoogleAdapter, ReplicateAdapter — each one shaped by the Provider port that the OpenAI adapter shaped first. Target through end of February.
  5. Repeat for the next seam. Storage. Email. Stripe. Each becomes a port; each grows adapters incrementally. Target through end of March.
  6. Stop when the boundaries pay back. Not every dependency needs a port. Pure utility libraries (date manipulation, validation) live as direct imports. Hexagonal is for replaceable dependencies, not all dependencies.

The estimated migration cost is around 18 hours of focused work spread across six weeks. The expected benefit is that by the time I am ready to add the seventh provider, the cost of doing so should drop to about 90 minutes — write the adapter, register it, ship.

The port map I'm pulling Studio toward

Here is the inventory I am committing to in the next six weeks. The "current" column is what exists today; the "target" column is the Hexagonal end state.

PortDirectionPurposeAdapter target
HttpInboundDrivingREST API at /api/v1/...1 (Next.js route handlers)
ServerActionDrivingForm submissions and direct mutations1 (next-safe-action)
SchedulerDrivingCron jobs, scheduled work1 (Railway native cron)
TestRunnerDrivingVitest invocation in tests1 (test fixtures)
ProviderDrivenAI/media inference calls12 (Anthropic, OpenAI, Google, Replicate, ElevenLabs, Mistral, xAI, Perplexity, Groq, DeepSeek, Meta, Cohere)
StorageDrivenObject storage for generated artifacts2 (Tigris/S3-compatible production, in-memory test)
DatabaseDrivenPersistent state2 (Prisma+PostgreSQL production, PgLite test)
AuthDrivenIdentity and session management1 (Better Auth)
BillingDrivenSubscription and payment processing1 (Stripe)
EmailDrivenTransactional email2 (Resend production, in-memory test)
LoggerDrivenStructured logging output2 (Pino production, console test)
TelemetryDrivenMetrics and traces1 (PostHog)
CacheDrivenCross-request memoization2 (Upstash Redis production, in-memory test)

Four driving ports, nine driven ports, twenty-eight total adapters across them when the migration completes. The math: by the time you have a moderately complex backend, the adapter count is the dominant complexity, not the application core. Hexagonal is what keeps the core small while the adapter perimeter expands.

Today, the only ports I have actually defined are HttpInbound (implicit in Next.js) and the half-finished Provider. By the end of February I want all four driving ports and at least four driven ports defined and migrated. By end of March I want the remaining five driven ports completed.

What the Provider port will look like

This is the most-replicated port in the system, so it is worth showing in detail.

export interface ProviderPort {
  chat(req: ChatRequest, ctx: RequestContext): Promise<ChatResult>;
  image(req: ImageRequest, ctx: RequestContext): Promise<ImageResult>;
  audio(req: AudioRequest, ctx: RequestContext): Promise<AudioResult>;
  embedding(req: EmbeddingRequest, ctx: RequestContext): Promise<EmbeddingResult>;
}

Four methods. Each takes a request and a context and returns a result. The request and result types are common across providers; the adapter translates from these common types to the provider's specific schema.

Each adapter is a class that implements all four methods (or throws NotSupported for ones it cannot serve — Cohere does not yet have an audio API, so CohereAdapter.audio() will throw).

export class AnthropicAdapter implements ProviderPort {
  async chat(req: ChatRequest, ctx: RequestContext): Promise<ChatResult> {
    const messages = this.toAnthropicMessages(req);
    const response = await this.client.messages.create({
      model: req.model,
      messages,
      max_tokens: req.maxTokens,
      // ...
    });
    return this.fromAnthropicResponse(response);
  }
  // image, audio, embedding similarly
}

The toAnthropic... and fromAnthropic... translation methods are where the Anti-Corruption Layer lives — the part of Fowler's vocabulary that pairs with Cockburn's Hexagonal. Each adapter is a small ACL between the application's clean abstraction and the provider's actual API.

What this should enable that the alternative does not

Three concrete capabilities that the Hexagonal commitment is supposed to deliver.

What the cost actually is

Hexagonal is not free. Three honest costs I am committing to in advance.

The trade-offs Cockburn was honest about

Cockburn's 2005 article explicitly notes that the pattern adds layers and is not appropriate for every application. The break-even point is somewhere around: the application has multiple delivery mechanisms (HTTP + CLI + tests), the application has multiple replaceable infrastructure dependencies, and the application has a meaningful test suite that benefits from isolation. Below all three thresholds, the inline-everything approach is faster and adequate.

The three costs I expect to live with:

One. The port definitions are fixed cost. Designing a port well is hard — it has to be coherent across multiple adapters that you have not built yet. About 30% of port designs need revision after the second adapter exposes a hidden assumption. This cost is real and front-loaded. I am committing to revising the Provider port at least once after the second adapter is implemented; pretending I will get it right on the first draft would be intellectually dishonest given my prior experience with similar work.

Two. The adapter count grows with adapter variety, not with code. Each new test variant of an adapter adds files. This is not bad — the tests it enables justify the count — but a quick find . -name "*Adapter*" in the codebase will end up showing roughly 60 files where a non-Hexagonal codebase would have 8.

Three. New contributors take longer to get oriented. Hexagonal codebases require understanding the port/adapter convention before any individual file makes sense. The first day is slower; the second week is faster (because the convention generalizes). I am committing to writing the convention into Studio's CLAUDE.md and AGENTS.md documents so the agent — and any future human contributor — has the convention in front of them by default.

NameTypeRequiredDefaultDescription
port_definitioninterfaceyesTypeScript interface declaring what the application offers/needs. No implementation logic.
adapterclass implements portyesConcrete implementation of a port. Translates between application types and external types (ACL).
injection_mechanismDI container | factory | paramyesfactoryHow adapters get into the application core. Studio uses simple factories per request.
test_adapterclass implements portyesin-memoryLightweight test variant injected during tests. Same port; different (faster, deterministic) implementation.
adapter_registrymap provider→adapternosrc/lib/providers/registry.tsSingle place where the adapter for each provider is registered. Adding a provider is a one-line registry edit + a new adapter class.

The Hexagonal pattern is older than most of the things people are building with it. It survives because the underlying problem — wanting to test and evolve application logic independently of delivery and infrastructure — is also older than most of the things people are building with it. The names change every five years; the pattern does not.

Studio is twenty-eight adapters across thirteen ports when the migration is done. Tests should run in 200ms. Adding a provider should take an afternoon. New contributors should get oriented in two weeks. None of these capabilities are accidental. They come from one decision early — put the boundary in before the pain shows up — and from following Cockburn's twenty-year-old article more literally than most who cite it ever do.

I am committing to the eighteen hours. I will write the retrospective at the end of March, after the migration finishes and the first non-trivial provider gets added under the new architecture. If the 90-minute target holds, the bet paid back. If it does not, I will name the part I got wrong.

That is the kind of public commitment that, on a Tuesday twenty-one weeks into a new system, is more useful than any private plan.

Was this page helpful?

O arco de 27 semanas · Um corpo de trabalho

Vinte e sete semanas. Dois textos por semana. Seis meses de escrita durante a construção.

Semana

Ensaio de terça

Boletim de sexta