Back to posts

I mistook moving interfaces for dependency inversion

I was refactoring the backend of a public transport app. One Edge Function had to merge scheduled departures with a realtime feed from an external transport API.

The flow is short: serve the cache if it’s fresh, call the transport API if it isn’t, and fall back to the published schedule when there’s no key or the call fails.

Departures requestFresh cache entry?API key configured?Fetch transport APIUpdate cacheServe cached feedSchedule-only dataLive departureshitno keymissyesokerror

The code worked. What I actually spent time on was a different question: which module gets to own the contracts this flow depends on?

The dependency pointed the wrong way

The policy imported its types straight from the concrete infrastructure modules:

import type { NtaRealtimeClient } from './ntaRealtimeClient'; import type { RealtimeCacheRepository } from './realtimeCacheRepository'; export const loadRealtimeFeed = async ( cache: RealtimeCacheRepository, provider: NtaRealtimeClient, apiKey: string | undefined ) => { // cache -> upstream -> schedule-only fallback };

Nothing breaks at runtime here. Both are type-only imports and they vanish from the compiled JavaScript. The problem is who owns what: the adapters own the contract, and the policy borrows it.

It shows up when you replace something. Say I swap the Supabase cache for Redis. The Redis implementation still has to import its interface from a file named after the Supabase repository. Delete or reorganise that old adapter and the policy breaks, even though what the policy needs hasn’t changed at all.

BeforeloadRealtimeFeedSupabaseNTA clientAfterRealtimeCacheRealtimeProviderSupabaseNTA clientpolicy depends on detailsdetails depend on the contract

What the port looks like

A port says what the application needs, not how it’s done:

export type RealtimeCache = { read: () => Promise<CachedRealtimeFeed | null>; write: (payload: RealtimePayload) => Promise<void>; }; export type RealtimeProvider = { fetch: (apiKey: string) => Promise<RealtimeFetchResult>; };

The Supabase repository and the HTTP client become adapters. Their job is translation — a database row into CachedRealtimeFeed, an HTTP response into RealtimeFetchResult:

export const createSupabaseRealtimeCache = (database: DatabaseClient): RealtimeCache => ({ read: async () => { /* row -> CachedRealtimeFeed */ }, write: async (payload) => { /* payload -> database write */ }, }); export const createNtaRealtimeClient = (request: typeof fetch = fetch): RealtimeProvider => ({ fetch: async (apiKey) => { /* HTTP response -> RealtimeFetchResult */ }, });

The composition root is then the only place that knows about both sides. It builds the adapters and hands them to the policy.

No inheritance needed. A class can write implements, but a plain object out of a factory satisfies the type just as well, because TypeScript checks structure rather than declarations.

Testability came from injection, not the port file

“We might move to Redis someday” is a weak reason to add an abstraction. Someday may not come, and if it does, the interface I designed today probably won’t fit what I need then.

The policy was already testable before I touched anything, because its dependencies were injected:

const cache: RealtimeCache = { read: async () => null, write: async () => undefined, }; const provider: RealtimeProvider = { fetch: async () => ({ ok: false, status: 429 }), }; const result = await loadRealtimeFeed(cache, provider, 'test-key'); expect(result.realtimeReason).toBe('upstream_rate_limited');

That test is about behaviour: what the function decides when upstream rate-limits us. It says nothing about fetch, Supabase query syntax, or deployment config.

Moving the interfaces didn’t unlock it. Thanks to structural typing, those two fakes satisfy the parameter types either way — whether the parameters are named after the Supabase repository or after a port. The decisions that made the test possible had already been made: keep the contracts small and inject the dependencies. My refactor only changed who owned the contracts.

Where I went too far

Having extracted the interfaces, I moved every contract into its own realtimeSource.ts. The graph came out clean and symmetrical with the rest of the backend, and I felt good about it for roughly a day.

Symmetry isn’t a requirement, though. It was one more file to open and keep in sync. And the two ports had exactly one consumer — the policy — plus the adapters that implement them and the test that fakes them. They could have lived next to it:

// realtimeFeed.ts export type RealtimeCache = { read: () => Promise<CachedRealtimeFeed | null>; write: (payload: RealtimePayload) => Promise<void>; }; export type RealtimeProvider = { fetch: (apiKey: string) => Promise<RealtimeFetchResult>; }; export const loadRealtimeFeed = async ( cache: RealtimeCache, provider: RealtimeProvider, apiKey: string | undefined ) => { // application policy };

The adapters would import those types from the high-level module. The dependency still points inward, the tests stay the same, and the project has one file less.

I haven’t done it. realtimeSource.ts is still there, because it holds more than the two ports: RealtimeFeedResult and RealtimeFeedDiagnostic live in it too, and the service layer above reads both. That part is a contract with several consumers, and it earns a module of its own — the neighbouring scheduleSource.ts and serviceCalendarSource.ts are the same shape.

So the thing I’d change is narrower than deleting a file. The two contracts belong beside the policy that uses them. The result types belong in a module the whole feature can import. One file was doing both jobs, and only one of them needed doing.

How I decide now

Thinking about a portPolicy to isolate from I/O?A reason today?Smallest useful contractShared or meaningful alone?Keep it beside the policyUse it directlyWait for the needIts own modulenonoyesyesyesno

I add a port when at least one of these is true today:

  • There’s real policy to isolate from I/O — branching, fallbacks, decisions worth testing on their own.
  • A second adapter already exists, or a test double counts as one.
  • The dependency is volatile or untrusted — a third-party API that changes under me.
  • Several scenarios share the same stable contract.
  • Infrastructure types are leaking into business decisions — a database error shape deciding what the user sees.

I skip it when:

  • The code is a one-line wrapper with no decisions in it.
  • There’s one stable implementation and no test that needs a substitute.
  • The interface just copies every method of a third-party SDK, which buys nothing.
  • The only argument is a hypothetical future provider.

What I take from it

Dependency inversion isn’t scored by how many interfaces a repo contains. YAGNI isn’t permission to pile every responsibility into one module either. The question that settles it for me is narrower: what change or test gets cheaper because this boundary exists?

Here the answer was small and specific. Injecting the cache and the provider is what keeps the fallback policy testable, and small contracts are what keep the injection honest. A dedicated file to own those contracts changed nothing I could name — and a plugin system for cache providers I don’t have would have changed even less.

Discussion

Comments

Loading comments...

Leave a comment

Your comment will be visible on the site only after moderation.