Most Go services start the same way: a handler function receives an *http.Request, opens a transaction on a package-level *sql.DB, runs a query, and writes JSON. It works, and for a weekend project it is the right call. The trouble starts when the second entry point appears (a CLI, a queue consumer, a gRPC server) and when the first test that needs "a user with two orders" requires a running PostgreSQL.
Ports and adapters, also called the hexagonal architecture, is the smallest set of rules I know that fixes both problems without turning a 2,000-line service into a framework. This post walks through the layout I use for Go services, with the code of a tiny URL shortener as the running example.
The idea in one paragraph
The application core (domain types plus use cases) sits in the middle. Everything that talks to the outside world is an adapter, and every adapter talks to the core through a port, which in Go is just an interface. The only rule that matters: dependencies point inward. The core imports nothing from the adapters; adapters import the core. Whether something is a "driving" adapter (an HTTP handler that calls the core) or a "driven" adapter (a PostgreSQL store the core calls) only changes who owns the interface.
Package layout
Go gives us one tool for enforcing "points inward": the import graph. If internal/domain never imports internal/adapters/postgres, the compiler guarantees the domain cannot depend on the database. So the layout is designed around what each package is allowed to import.
short/
├── cmd/
│ └── server/ # main: wires adapters to the core, reads config
│ └── main.go
└── internal/
├── domain/ # Link, Code, validation, sentinel errors
│ └── link.go
├── app/ # use cases + the ports they need (LinkStore, Clock)
│ ├── shortener.go
│ └── shortener_test.go
└── adapters/
├── httpapi/ # net/http handlers → app.Shortener
├── postgres/ # app.LinkStore backed by database/sql
└── memory/ # app.LinkStore backed by a map (tests, dry runs)
| Package | May import | Must not import |
|---|---|---|
internal/domain | standard library only | anything else in the module |
internal/app | domain | net/http, database/sql, any adapter |
internal/adapters/* | app, domain, libraries | other adapters |
cmd/server | everything | nothing is off limits; it is the wiring layer |
The internal/ directory is doing real work here: the Go toolchain refuses imports of internal packages from outside the module, so the adapters are not accidentally reusable by some other service that would then pin their shape.
The domain: types and rules, no I/O
The domain package owns the vocabulary and the invariants. It has no idea where a link comes from or where it is stored.
package domain
import (
"errors"
"net/url"
"strings"
"time"
)
var (
ErrInvalidURL = errors.New("target must be an absolute http(s) URL")
ErrCodeTaken = errors.New("code already taken")
ErrNotFound = errors.New("link not found")
)
// Code is the short identifier that appears in the path, e.g. "k3Zp9Qa".
type Code string
type Link struct {
Code Code
Target *url.URL
CreatedAt time.Time
}
// ParseTarget is the only place that decides what a valid target is.
func ParseTarget(raw string) (*url.URL, error) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
return nil, ErrInvalidURL
}
return u, nil
}
Sentinel errors live here on purpose. The HTTP adapter will later map ErrInvalidURL to a 422 and ErrNotFound to a 404; the PostgreSQL adapter will map a unique-violation to ErrCodeTaken. Both directions of translation happen in adapters, never in the core.
The application: use cases and the ports they need
The app package holds the use cases and, crucially, declares the interfaces it depends on. This is the Go idiom of defining interfaces at the consumer: the thing that needs a LinkStore says what a LinkStore is.
package app
import (
"context"
"time"
"example.com/short/internal/domain"
)
// LinkStore is a driven port. The application defines it; adapters implement it.
type LinkStore interface {
Save(ctx context.Context, l domain.Link) error
Find(ctx context.Context, code domain.Code) (domain.Link, error)
}
// Clock and CodeGen are tiny ports too: anything the core cannot control
// deterministically is injected, so tests can pin it down.
type Clock interface{ Now() time.Time }
type CodeGen interface{ Next() domain.Code }
type Shortener struct {
store LinkStore
clock Clock
codes CodeGen
}
func NewShortener(store LinkStore, clock Clock, codes CodeGen) *Shortener {
return &Shortener{store: store, clock: clock, codes: codes}
}
func (s *Shortener) Shorten(ctx context.Context, raw string) (domain.Link, error) {
target, err := domain.ParseTarget(raw)
if err != nil {
return domain.Link{}, err
}
l := domain.Link{Code: s.codes.Next(), Target: target, CreatedAt: s.clock.Now()}
if err := s.store.Save(ctx, l); err != nil {
return domain.Link{}, err
}
return l, nil
}
func (s *Shortener) Resolve(ctx context.Context, code domain.Code) (domain.Link, error) {
return s.store.Find(ctx, code)
}
Two interfaces with one method each might look like ceremony. They pay for themselves the first time a test needs to assert on CreatedAt, or the first time you want to run the whole service in a deterministic replay. time.Now() is a hidden global dependency; naming it makes it visible.
TIP
Keep ports small and named after what the core needs, not after the technology that will implement them.
LinkStorewith two methods is a port.Repositorywith fourteen methods mirroring an ORM is an adapter that leaked inward.
Adapters: one per technology
The in-memory adapter is a real implementation, not a mock. It is what the core's tests use, and it is small enough to read in a minute.
package memory
type Store struct {
mu sync.RWMutex
links map[domain.Code]domain.Link
}
func New() *Store { return &Store{links: map[domain.Code]domain.Link{}} }
func (s *Store) Save(_ context.Context, l domain.Link) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.links[l.Code]; exists {
return domain.ErrCodeTaken
}
s.links[l.Code] = l
return nil
}
func (s *Store) Find(_ context.Context, code domain.Code) (domain.Link, error) {
s.mu.RLock()
defer s.mu.RUnlock()
l, ok := s.links[code]
if !ok {
return domain.Link{}, domain.ErrNotFound
}
return l, nil
}
The PostgreSQL adapter has the same shape and is the only package in the module that imports database/sql:
func (s *Store) Find(ctx context.Context, code domain.Code) (domain.Link, error) {
var raw string
l := domain.Link{Code: code}
err := s.db.QueryRowContext(ctx,
`SELECT target, created_at FROM links WHERE code = $1`, string(code),
).Scan(&raw, &l.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return domain.Link{}, domain.ErrNotFound
}
if err != nil {
return domain.Link{}, err
}
l.Target, err = url.Parse(raw)
return l, err
}
On the driving side, the HTTP adapter declares its own small interface for what it needs from the core. That keeps httpapi compilable against a fake service in handler tests, and it is the same consumer-defines-the-interface rule applied in the other direction:
package httpapi
// Service is what the HTTP adapter needs from the core.
type Service interface {
Shorten(ctx context.Context, raw string) (domain.Link, error)
Resolve(ctx context.Context, code domain.Code) (domain.Link, error)
}
func NewHandler(svc Service) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /{code}", func(w http.ResponseWriter, r *http.Request) {
l, err := svc.Resolve(r.Context(), domain.Code(r.PathValue("code")))
if errors.Is(err, domain.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, l.Target.String(), http.StatusFound)
})
return mux
}
Testing the core without a database
Because the core only sees LinkStore, Clock and CodeGen, its tests inject the in-memory store and two three-line fakes. No containers, no fixtures, no TRUNCATE between tests.
package app_test
type fixedClock struct{ t time.Time }
func (c fixedClock) Now() time.Time { return c.t }
type seqCodes struct{ n int }
func (s *seqCodes) Next() domain.Code { s.n++; return domain.Code(fmt.Sprintf("c%d", s.n)) }
func TestShortenThenResolve(t *testing.T) {
when := time.Date(2025, 3, 25, 10, 0, 0, 0, time.UTC)
svc := app.NewShortener(memory.New(), fixedClock{when}, &seqCodes{})
l, err := svc.Shorten(context.Background(), " https://example.com/docs?page=2 ")
if err != nil {
t.Fatal(err)
}
if l.Code != "c1" || !l.CreatedAt.Equal(when) {
t.Fatalf("unexpected link %+v", l)
}
got, err := svc.Resolve(context.Background(), l.Code)
if err != nil {
t.Fatal(err)
}
if got.Target.String() != "https://example.com/docs?page=2" {
t.Fatalf("got %s", got.Target)
}
}
func TestRejectsNonHTTPTargets(t *testing.T) {
svc := app.NewShortener(memory.New(), fixedClock{}, &seqCodes{})
for _, raw := range []string{"javascript:alert(1)", "ftp://x/y", "/relative", ""} {
if _, err := svc.Shorten(context.Background(), raw); !errors.Is(err, domain.ErrInvalidURL) {
t.Errorf("%q: want ErrInvalidURL, got %v", raw, err)
}
}
}
The whole module above vets cleanly and the suite finishes in a few milliseconds:
$ go vet ./... && go test ./...
ok example.com/short/internal/app 0.006s
The database is not untested; it is tested separately, by a contract test that runs the same assertions against every LinkStore implementation.
The contract test shared by every adapter
// LinkStoreContract is shared by every adapter's test package: the same
// behavioural checks run against the in-memory store and, behind a build tag
// that needs DATABASE_URL, against the PostgreSQL store.
func LinkStoreContract(t *testing.T, newStore func(t *testing.T) app.LinkStore) {
ctx := context.Background()
s := newStore(t)
target, _ := url.Parse("https://example.com/a?b=c")
l := domain.Link{Code: "abc", Target: target, CreatedAt: time.Now().UTC().Truncate(time.Microsecond)}
if err := s.Save(ctx, l); err != nil {
t.Fatalf("first save: %v", err)
}
if err := s.Save(ctx, l); !errors.Is(err, domain.ErrCodeTaken) {
t.Fatalf("second save: want ErrCodeTaken, got %v", err)
}
got, err := s.Find(ctx, "abc")
if err != nil || got.Target.String() != target.String() || !got.CreatedAt.Equal(l.CreatedAt) {
t.Fatalf("find: got %+v, %v", got, err)
}
if _, err := s.Find(ctx, "missing"); !errors.Is(err, domain.ErrNotFound) {
t.Fatalf("find missing: want ErrNotFound, got %v", err)
}
}
func TestMemoryStoreContract(t *testing.T) {
LinkStoreContract(t, func(*testing.T) app.LinkStore { return memory.New() })
}
The Truncate(time.Microsecond) is there because timestamptz stores microseconds; a contract test is exactly where that kind of detail should surface, once, instead of in a flaky integration test somewhere else.
Wiring it together
cmd/server/main.go is the one place that knows about everything. It reads configuration, constructs adapters, and hands them to the core. There is no dependency-injection framework; a constructor call is enough.
func main() {
db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
svc := app.NewShortener(postgres.New(db), app.SystemClock{}, &base62Codes{})
log.Fatal(http.ListenAndServe(":8080", httpapi.NewHandler(svc)))
}
Swapping PostgreSQL for the in-memory store to get a --dry-run mode is a one-line change in this file and nowhere else.
When not to do this
Hexagonal structure costs something: more packages, an interface per external system, and a small amount of plumbing in main. For a service that will only ever have one entry point and one database, it is probably not worth it, and a flat package with handlers calling SQL is honest about that. The moment you add a second adapter on either side, or the moment "run the tests" means "start Docker", the structure pays for itself.
WARNING
Resist the urge to write an adapter for things that will never be swapped. A
Loggerinterface overlog/slog, or aJSONCodecport, is abstraction without a second implementation in sight. Ports are for boundaries you actually cross.
Further reading
- Alistair Cockburn, Hexagonal architecture: the original description of ports and adapters.
- Organizing a Go module on go.dev, including when
internal/andcmd/earn their place. - Package names on the Go blog: short, lower-case, and named for what they provide.
- Effective Go: the section on interfaces explains why small, consumer-defined interfaces are idiomatic.
- The
database/sqlpackage documentation, which is all the PostgreSQL adapter needs.