Code structure & architecture

How I lay out a Go repo: three layers, one direction

6 min

Most Go services rot from the same place: the handler that grew a database call, then a bit of business logic, then a cache check, until the thing you cannot test is also the thing you cannot change. The layout below is how I keep that from happening. Three layers, and one rule about which way the arrows point.

The one rule

Dependencies flow one way. domain/ imports nothing else in the repo. internal/ imports domain/. Transports depend on domain/ interfaces and have their service injected in. Nothing ever points back up.

  • domain/ is the models and the interfaces, and that is genuinely all. Nothing in here talks to a database or knows what HTTP is.
  • internal/<thing>/ is where a domain area actually lives: the service, the data access next to it, and a cache layer if it earns one. Each package is a bounded context and they do not import each other.
  • transports/ is any way into the service from outside. Usually that is an HTTP handler, but a queue consumer reading off SQS, Rabbit or Kafka lives here too, and so would a cron entry point.
Dependency direction across the layerscmd wires everything at the top. transports, web and internal all depend downward on domain, which sits at the bottom and imports nothing. pkg is cross-cutting on the side.importscmd/entrypoint & wiring, the only place concrete types are builttransports/http · queue consumers · cronweb/templ pages & componentsinternal/bounded contexts: service · sql · cachedomain/models & interfaces, imports nothingpkg/cross-cutting

Every request runs the same path, and the discipline is in never short-cutting it:

text
1
handler -> domain.Service -> internal/<thing>/service.go -> domain.Reader / Writer -> sql.go (or inmem.go, or cachefacade.go)

The rest of this is that path, one layer at a time, with real code from a paddle-review site I run. The site is mine, but the layout is not specific to it. Near enough every backend I have worked on in the last ten years has been built this way, which is the only reason I trust it enough to write it down.

domain: types and interfaces, nothing else

The interfaces live in domain/ and they are deliberately small. I split reads from writes rather than shipping one fat repository, so a thing that only needs to read takes a reader and nothing else:

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package domain

type PaddleReader interface {
    PaddleModel(ctx context.Context, args PaddleModelArgs) (PaddleModel, error)
    PaddleModels(ctx context.Context, filter PaddleModelFilter) ([]PaddleModel, error)
}

type PaddleWriter interface {
    Save(ctx context.Context, p PaddleModel) error
}

The names skip the Get prefix on purpose. Go’s own convention is that a method returning a PaddleModel is called PaddleModel, not GetPaddleModel, and I hold to it. The single lookup takes an args struct rather than a bare slug, so the signature does not churn every time it needs one more thing to filter on.

A struct in here can have a method that works something out from what it already holds (a Label() off a shape enum, a slug from the name and brand), but it never reaches out and does anything. That is what lets a test for anything downstream fake this interface in about four lines with no database anywhere near it.

internal: where the logic lives

Each domain area gets a package under internal/. The service is the thing with the business rules, and it takes its data access as the domain interfaces, never the concrete store:

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package paddle

type service struct {
    paddles domain.PaddleReader
    brands  domain.BrandReader
}

func NewService(paddles domain.PaddleReader, brands domain.BrandReader) domain.PaddleService {
    return &service{paddles: paddles, brands: brands}
}

The real one takes a few more collaborators, but the shape is exactly this. Look at what it returns: domain.PaddleService, the interface, not the concrete *service. So the layer above programs against an interface too and never sees the innards.

Next door sits sql.go, which implements PaddleReader with real queries, and sometimes cachefacade.go, which wraps sql.go behind the very same interface and checks a cache first. The service cannot tell them apart, and that is the part I lean on hardest: I test the rules against an in-memory reader, and production runs the same code on Postgres behind a cache, with nothing in the service changing.

transports: one job, done the same way every time

A transport parses what came in, calls a service, and maps the result back. Here is a real HTML handler:

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package html

type HomeHandler struct {
    home domain.HomepageService
}

func NewHomeHandler(homeSvc domain.HomepageService) *HomeHandler {
    return &HomeHandler{home: homeSvc}
}

func (h *HomeHandler) Home(c echo.Context) error {
    view, err := h.home.Homepage(c.Request().Context())
    if err != nil {
        return err
    }
    return renderHTML(c, home.Page(home.HomeProps{View: view}))
}

The handler holds a domain.HomepageService, the interface, handed to it in its constructor. It never builds one. So in a test I pass a fake service, check Home called it and rendered what came back, and there is no HTTP server and no database to stand up.

The rule that keeps this honest: a handler with an if in it that is not about HTTP (content negotiation, a partial versus a full page for htmx) has business logic in the wrong layer, so move it down to the service. And because the transport is only ever “take input, call service, map output”, the same shape works when the input is a Kafka message instead of a request. The service never finds out where the call came from.

main: where the concrete types meet

Interfaces are grand right up until something has to build the real thing. That happens in exactly one place, the composition root, and it is the only spot in the codebase where concrete types meet:

go
1
2
3
4
5
6
7
8
// the store: a SQL repo, wrapped in a cache facade, both behind PaddleReader
paddleRepo := paddle.NewCacheFacade(paddle.NewSQLRepo(pool), caches)

// the service: gets the store as an interface, knows nothing about cache or SQL
paddleSvc := paddle.NewService(paddleRepo, brandRepo)

// the handler: gets the service as an interface
homeHandler := html.NewHomeHandler(homepageSvc)

Look at the first line. NewSQLRepo returns something that satisfies PaddleReader, NewCacheFacade wraps it and returns the same interface, and NewService takes that interface with no idea which it got. Swapping Postgres for something else, dropping the cache, or handing the service an in-memory fake in a test, is a change on that line and nowhere else.

What it buys you

The parts you change most, the business rules, sit in internal/ and test with fakes in milliseconds. The parts that are a pain to test, the SQL and the HTTP, hold no rules worth testing. And when two teams need to deploy on different days, the seams are already cut, because you drew them on day one instead of during the rewrite. None of it is exotic. It is the arrows, pointed one way, held there on every change.

One caveat, since this can read as dogma: it is for services that have to live. For a CLI or a throwaway you will bin next week, it is overkill, so put the lot in main and get on with it. The structure starts earning its keep the moment the thing has to outlive the afternoon you wrote it in, or land on someone else’s desk.