Bassam Ismail
Engineering

Ports and Adapters, Earned Not Cargo-Culted

8 min read

Six months into the Slack agent, I tried to find the code that opened a pull request. The Octokit call was buried behind a GitProvider interface, a GitHub adapter, a fake adapter, and three DTO files: architecture that claimed to protect the codebase, but mostly protected the vendor call from being found.

That failure has a name: cargo-cult hexagonal architecture. You read about ports and adapters, wrap every dependency in an interface, and a year later you have forty interfaces with one implementation each, a layer of indirection that buys nothing, and a codebase where finding the actual behavior means chasing through three files. The pattern is sound. The way people apply it usually is not. Alistair Cockburn's original write-up on Hexagonal Architecture is still worth reading because it describes the boundary as a way to keep application logic independent of outside devices, not as a mandate to wrap every library.

TL;DR

Ports and adapters architecture works when each port earns its place with at least two adapters: a real one plus a fake, or two real implementations such as Postgres and in-memory. The useful boundary is not the interface by itself. It is the typed errors, plain DTOs, and parity tests that keep SDK details from leaking into the use case.

This is the last of four parts on building a Slack engineering agent. The previous three were the path, the queue, and the safety gate. This one is the spine that holds them together, and the single rule that keeps it from rotting. A similar bias toward delaying irreversible writes shows up in Submitting Timesheets from Slack Without Writing Too Early.

The rule that keeps ports and adapters architecture useful

The discipline fits in one sentence: a port is added only when it has at least two adapters. A real implementation plus a fake for tests, or a Postgres implementation plus an in-memory one. If a dependency has only one implementation and shows no sign of needing a second, it does not get a port; it gets called directly.

That rule is the main defense against speculative interfaces. Hexagonal codebases rot because people add the port first, on the theory that they might swap the implementation someday. Someday rarely comes, and you are left with abstraction you pay for and never use. Requiring two adapters before the port exists means the interface is justified by a second implementation that already exists, usually the fake you need to test the thing anyway.

One honest exception is worth naming: a boundary can earn its place before a second adapter exists when it is protecting a volatile external contract or enforcing a package boundary with a concrete near-term reason to isolate. That is a deliberate choice, not a default. Treat two adapters as the standard decision test, and treat single-adapter ports as something that needs a written justification in an architecture decision record.

ONE SEAMa port earns its place with two adaptersUSE CASErunAgent / worker / flowsPORT (INTERFACE)GitProviderplain DTOsADAPTERSrealoctokitfakein-memory

Typed errors at the seam, not raw exceptions

The boundary has a second discipline. SDKs throw their own error shapes: Octokit throws one thing on a 404, the AWS SDK another, the database driver a third. If those leak through the port, the seam is fake; callers end up matching on err.status === 404 and you have coupled the use case to the SDK after all.

Each adapter normalizes its SDK-specific failures into a typed union that the port owns:

type GitProviderError =
  | { kind: "not-found" }
  | { kind: "auth-failed" }
  | { kind: "rate-limited"; retryAfterSeconds?: number }
  | { kind: "conflict"; message: string };

A use case handles kind: "not-found", and it does not know or care whether that came from a GitHub 404, a GitLab 404, or the in-memory fake returning a miss. The error is a real part of the contract, expressed in the domain's vocabulary, not the SDK's. This is the difference between a seam and a thin coat of paint over a leaky dependency.

The DTOs follow the same principle. The git-provider port exposes plain shapes (RepoRef { owner, name, defaultBranch? }, PullRequest { number, url, title, state, draft, mergeable }) and deliberately does not re-export Octokit's types. The point of the seam is that callers do not see the SDK. Re-exporting its types defeats the boundary immediately.

How it got there: one seam at a time

This architecture was not designed up front. It was migrated into, and the order matters. Each seam was introduced as a phase, gated by an architecture decision record, replacing direct SDK usage where the pain was worst:

  • The Db port came first, replacing 35-plus scattered getDb() calls with one seam, so the data layer had a single, fakeable boundary.
  • The GitProvider port replaced direct Octokit calls, so the agent's "open a PR" did not reach into the GitHub SDK from a use case.
  • The LLMClient port replaced direct model-SDK calls (the Anthropic SDK and the Bedrock runtime), so the loop in Part 3 talks to a model through one interface and the provider is an adapter detail.

Other dependencies (the clock, the Slack client, config) are not yet ports. They are listed as candidates rather than done. That honesty is the system working as intended: a port is a commitment you make when the second adapter shows up, not a box you tick for each dependency on day one. That same restraint runs through Building Press, Part 3: The prompts are data, not code, where the boundary is useful because the prompt changes without turning into application logic.

Parity tests are what make the fake trustworthy

A fake adapter earns its place only if it behaves like the real one. The way you know is a parity test: the same contract suite run against both the real adapter and the fake, asserting they produce the same results for the same inputs.

Here is what that looks like for the git provider:

// parity.test.ts
import { describeGitProviderContract } from "./gitProviderContract";
import { RealGitProvider } from "./realGitProvider";
import { FakeGitProvider } from "./fakeGitProvider";
 
// Run the full contract suite against the real adapter (integration, gated by env)
if (process.env.RUN_INTEGRATION) {
  describeGitProviderContract("RealGitProvider", () => new RealGitProvider());
}
 
// Always run against the fake (fast, no network)
describeGitProviderContract("FakeGitProvider", () => new FakeGitProvider());
// gitProviderContract.ts
export function describeGitProviderContract(
  label: string,
  factory: () => GitProvider
) {
  describe(`${label} contract`, () => {
    let provider: GitProvider;
    beforeEach(() => { provider = factory(); });
 
    it("returns not-found for a missing repo", async () => {
      const result = await provider.getRepo({ owner: "x", name: "missing" });
      expect(result.ok).toBe(false);
      expect(result.error.kind).toBe("not-found");
    });
 
    it("creates and retrieves a pull request", async () => {
      const pr = await provider.openPullRequest({ ... });
      expect(pr.ok).toBe(true);
      expect(pr.value.state).toBe("open");
    });
  });
}

Parity tests have real limits worth naming. They verify contract behavior: given this input, the adapter returns this shape with this error kind. They do not verify provider internals, rate-limit timing, auth edge cases, or the full surface of GitHub-specific quirks. The fake is seeded with in-memory state you control; the real adapter hits actual network conditions you do not. That gap means parity tests catch drift in the happy path and the documented error cases, but you still need targeted integration tests for auth flows, retry behavior, and anything that depends on GitHub's actual enforcement of its API contract. The fake is trustworthy for what it promises to cover, not for everything the real adapter can do.

If the Postgres adapter and the in-memory adapter disagree, the parity test fails and you learn it in CI instead of in production when a test that passed against the fake breaks against the real database. Without parity tests, the fake drifts and your green test suite quietly stops meaning anything.

What it costs, and when not to do it

The indirection is real. There is a file for the port, a file for each adapter, and a DTO layer to maintain. For a dependency that genuinely will only ever have one implementation, all of that is pure overhead. That is why the two-adapter rule exists, and why three real dependencies here are deliberately not ports yet.

Ports and adapters architecture earns its keep when the boundary is doing work: when you have a real second adapter (almost always a test fake), when the SDK's errors and types would otherwise leak into your domain, and when parity tests keep the two implementations honest. Apply it because a seam is paying off, not because a diagram told you to draw boxes. Adding ports nobody asked for is the problem. The pattern itself is not.

FAQ

What's the two-adapter rule?

A port (interface) is added once it has at least two implementations, typically a real adapter plus a test fake, or Postgres plus in-memory. It blocks speculative single-implementation interfaces, which are the main way hexagonal codebases rot into ceremony.

Why normalize errors into a typed union at the seam?

So SDK-specific exceptions don't leak through the port. Each adapter maps its failures into a domain union like { kind: "not-found" | "auth-failed" | ... }, and use cases handle the kind without coupling to whether it came from Octokit, another provider, or the fake. Without that normalization, the abstraction is cosmetic at best.

What are parity tests and why do they matter?

The same test suite run against both the real adapter and the fake, asserting they agree. They keep the fast in-memory fake honest about the real implementation, so a green suite against the fake actually means the real thing works. They cover contract behavior, not provider internals or auth edge cases; those need separate integration tests.

When should you NOT add a port?

When a dependency has only one implementation and no concrete reason for a second. There, a port is pure indirection. Several real dependencies here (clock, Slack client, config) are intentionally left as direct calls until a second adapter justifies the seam.