Stage 7 · Master
SOLID in Go, No Framework
Interfaces & Dependency Inversion
Define tiny interfaces where they are consumed, so domain logic depends on behavior instead of concrete storage packages.
We Started With Concrete Types and Regretted It
The naive version of a Go service often starts by importing the Postgres repository straight into the service layer. That feels efficient because it removes an extra type declaration. Fieldwork began that way too. The task service knew about a pgx-backed task repository struct, the membership logic knew about a specific HTTP client implementation, and tests either instantiated real dependencies or built awkward mocks against large concrete packages. It worked until the moment we wanted to change one of those implementations or test business logic without a database.
The concrete cost was coupling in the wrong direction. The task domain should decide what task behavior it needs: load a project, check authorization, insert a task, emit an outbox record. Instead, the domain layer was implicitly shaped by whatever methods the storage package happened to expose. That is upside down. The service that consumes behavior should define the contract. Otherwise infrastructure details become the source of truth and every new dependency choice leaks inward.
| Question | Implementation-owned interface | Consumer-owned interface |
|---|---|---|
| Who defines the methods? | The repository package exporting what it happens to do | The service package declaring only what it needs |
| What changes when storage changes? | Service code often rewrites around storage details | Only the implementing package usually changes |
| How easy is testing? | Mocks mimic a broad infrastructure API | Fakes implement a narrow domain contract |
| What we chose | Rejected | Used throughout Fieldwork services |
The important reversal is not academic UML. It is that the task service package no longer imports postgres/taskrepo to describe its own business rules. Infrastructure imports the domain contract instead.
Consumer-Owned Interfaces
Once we made that decision, the interfaces became much smaller. The task service does not need a general-purpose repository with fifteen methods because the Postgres implementation happened to support them. It needs exactly the operations required by the use case in front of it. That subtle shift keeps interfaces honest. If a method appears in the interface, it is there because the consumer needs it today, not because we are predicting a future abstraction. In Go, most oversized interfaces are failed predictions.
Fieldwork's task creation path is a good example. The service needs a TaskRepository that can create a task inside tenant and workspace scope, a ProjectRepository that can confirm the project exists, and a MembershipAuthorizer that can answer one role question. None of those interfaces needs to know about pgx rows, HTTP status codes, or Kafka producer structs. The implementations still do, but that knowledge stays on the outer edge of the application where it belongs.
Repository Interfaces Small Enough to Fake
A useful test of interface quality is whether you can write a fake in five minutes. If the answer is no, the interface is probably describing too much implementation detail. Fieldwork's earlier repository packages exposed list, get, create, update, delete, transactional helpers, and query builders through the same surface. That made tests noisy because each fake had to stub irrelevant methods just to compile. After inversion, a test for task creation only needs a fake authorizer, a fake project repository, and a fake task repository with one Create method.
We rejected code generation for every test double at this layer because it hid the real design problem. Generated mocks are useful when the interface is already right. They are not a substitute for keeping the interface small. In this course project, simple hand-written fakes were faster to read and made the service contract visible in the test itself. That mattered more than shaving a few lines of setup.
If a service only needs Create today, do not add Update because it feels symmetrical. Symmetry is an aesthetic preference; coupling is a maintenance cost.
What Dependency Direction Looks Like in Fieldwork
The final shape in Fieldwork is simple: the domain service packages define the interfaces they consume; infrastructure packages implement those interfaces; main wires the concrete implementations into the services. That means the dependency arrow points inward toward business behavior, not outward toward storage or transport details. It also means adding a second implementation, such as a cached membership client or a background task repository fake for tests, does not require rewriting the domain package that already declared what it needed.
That is the decision to keep. We did not adopt interfaces everywhere out of habit. We used them at boundaries where one package depends on behavior from another package and where preserving domain independence buys us testability and flexibility. For small private helpers inside one package, concrete types stayed concrete. The point of dependency inversion in Go is not maximal abstraction. It is putting the abstraction at the seam where the domain would otherwise become hostage to infrastructure.
- Define interfaces in the consuming package, next to the use case that depends on them.
- Keep interfaces as small as the current business behavior allows; do not predict future methods.
- Prefer hand-written fakes for tiny interfaces because they expose the contract clearly.
- Let Postgres, HTTP, and Kafka packages import domain contracts, not the reverse.
- Keep concrete types for internal helpers where no package boundary exists.
Once the task service stopped depending on storage packages directly, review conversations shifted from pgx details back to task rules, membership semantics, and tenant scoping. That was the real payoff.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.