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.

Ports and adapters: the core knows only interfaces it defines itself Core internal/domain Link, Code, validation rules internal/app Shortener use cases no net/http, no database/sql driving port app.Shortener (methods) HTTP adapter adapters/httpapi (net/http) CLI adapter cmd/shortctl Tests call the core directly driven ports (interfaces) app.LinkStore, app.Clock PostgreSQL adapter adapters/postgres (database/sql) In-memory adapter adapters/memory (map + mutex) System clock time.Now wrapped once Every arrow is a compile-time import. All of them point inward; the core imports nothing on the outside. Left: things that drive the application. Right: things the application drives, behind interfaces it owns.
Driving adapters on the left call into the core; driven adapters on the right implement interfaces the core defines. Every arrow is a compile-time import, and every one points inward.

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
Import graph of the module (an arrow means "imports") composition root adapters (replaceable) application domain cmd/server internal/adapters/httpapi internal/adapters/postgres internal/adapters/memory internal/app (Shortener, LinkStore) internal/domain (Link, Code, errors) adapters import domain too (those arrows are omitted) nothing imports upward: domain has zero internal imports
The import graph of the module. cmd/server is the only package that knows about every other package; nothing imports upward.

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. LinkStore with two methods is a port. Repository with 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 Logger interface over log/slog, or a JSONCodec port, 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/ and cmd/ 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/sql package documentation, which is all the PostgreSQL adapter needs.