1. Home
  2. Writing
2 min readGoArchitecture

The Go starter kit I keep rebuilding

Three leadership roles, three starter kits. The layout, the ports and the boring defaults that survive every rewrite.

Blue network cables plugged into a switch
Photo: @tvick / Unsplash

At Armo Group, at ChainMind and at Speax.ai, one of the first things I wrote was a Go starter kit. Not a framework — a repository a new service is copied from, so the first two weeks of a product are spent on the product and not on logging.

Each version was a rewrite. Each rewrite kept less. This is what survived.

The layout

cmd/
  api/            main.go: wire everything, nothing else
internal/
  domain/         entities, value objects, domain events
  app/            use cases; depend only on domain and ports
  ports/          interfaces the use cases need
  adapters/
    http/         handlers and DTOs
    postgres/     repositories
    broker/       publishers and consumers
    storage/      S3 files
  platform/       config, logging, health, shutdown
migrations/
deploy/

It is hexagonal architecture without the ceremony. The rule fits in one sentence: dependencies point inward. The domain imports nothing from the project. Use cases import the domain and the ports. Adapters import whatever they like, and nobody imports adapters except main.go.

Ports are small and owned by the caller

A port is an interface written from the point of view of the use case that needs it, not a mirror of the library behind it:

// internal/ports/files.go
type FileStore interface {
    Put(ctx context.Context, key string, r io.Reader, size int64) error
    SignedURL(ctx context.Context, key string, ttl time.Duration) (string, error)
}

Two methods, not the dozens the S3 SDK offers. That keeps the fake for tests ten lines long, and it means switching storage providers is an adapter change, not a migration project.

The boring defaults

  • Config from the environment, validated at boot. A missing variable should crash the process on start, not at 3 a.m. on the first request that needs it.
  • Structured logs with a request ID carried through the context, from the HTTP handler to the broker message.
  • Liveness and readiness that mean different things: alive is “the process runs”, ready is “the database and the broker answer”.
  • Graceful shutdown. Stop accepting, finish in-flight work, close the connections, exit.
  • Migrations in the repository, run as their own step before the new version takes traffic.
  • A fake for every port, so use-case tests run in milliseconds without Docker.

What got deleted

Every rewrite removed something I had been proud of. A generic repository that fit no real query. A dependency-injection container that made main.go shorter and every bug harder to find. A layer of “services” between use cases and adapters that only forwarded calls.

The test for keeping something is simple: would the second service in the project write it the same way? If yes, it belongs in the kit. If it is a guess about the future, it goes.

A kit is a starting point you delete from

The best compliment a starter kit can get is that a team copied it, deleted a third of it in the first week and never thought about it again. If a kit needs a maintainer, it has become a framework — and Go already ships with a good one, called the standard library.

More writing

All writing
tahersoft.com

Case studies, retros and the podcast live on TaherSoft.

Does your product need a CTO-shaped person?

Right now: writing case studies, recording the first podcast season, open to CTO / staff-engineer roles.