~/posts/hexagonal-architecture-in-go

hexagonal architecture in go without the ceremony

ports and adapters + ddd in idiomatic go, and exactly where the discipline pays off

every go web app i wrote before this one had The Struct. you know the one. it starts life innocent — three fields, a name, an id — and then someone needs to persist it, so it grows a gorm:"primaryKey" tag. then it needs to go over the wire, so it grows a json:"id" tag. then a binding:"required" for the request validation, because why not. six months later that struct imports your orm, your web framework, and your validation library, and it has a method called BeforeSave that quietly does business logic inside a database hook.

that struct knows too much. it can’t be tested without a database. it can’t be reasoned about without opening four dependencies. and every time you change your database, your json api changes too, because they’re the same forty lines of code wearing three different hats.

in 2024 i built a little rest api called mojodojo — a martial-arts gym manager, dojos and fighters and belts — specifically to see if hexagonal architecture would kill The Struct for good. it’s a learning project, nothing shipped, no users, just me and a self-imposed rule: the domain is not allowed to know how it’s stored or how it’s served.

this is the follow-up to the testcontainers posts , where i tested the repository layer in isolation. that isolation wasn’t an accident — it’s what the architecture buys you. today i want to zoom out from that one test and show the whole shape: where the discipline pays off, and where it’s honestly just a tax.

ports and adapters, minus the diagrams

the pattern is alistair cockburn’s, and his canonical page ↗ states the intent in one sentence: “allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases.”

that’s it. that’s the whole idea. your business logic sits in the middle. everything else — http, postgres, the clock, the outside world — plugs into it through interfaces. the middle never imports the edges. the edges import the middle.

the trick that makes it click in go: a port is just an interface, and an adapter is just a struct that implements it. no framework, no annotations, no container. the language already has everything you need.

the domain doesn’t get to know about your database

here’s the smallest entity in the whole codebase. a dojo has a name and an owner. watch what it imports:

internal/domain/entities/dojo.go

go
package entities

import (
	"errors"
	"fmt"

	"mojodojo/internal/common/validator"
	"github.com/google/uuid"
)

var (
	ErrDojoValidation = errors.New("dojo validation failed")
)

type Dojo struct {
	ID      string
	Name    string
	OwnerID string
}

func NewDojo(
	name,
	ownerID string,
) (Dojo, error) {
	if err := validator.Validate(name, validator.StringNotEmpty); err != nil {
		return Dojo{}, fmt.Errorf("%v: %s %v", ErrDojoValidation, "Name", err)
	}

	if err := validator.Validate(ownerID, validator.ValidUUID); err != nil {
		return Dojo{}, fmt.Errorf("%v: %s %v", ErrDojoValidation, "OwnerID", err)
	}

	return Dojo{
		ID:      uuid.NewString(),
		Name:    name,
		OwnerID: ownerID,
	}, nil
}

errors, fmt, a uuid library, and my own validator. no gorm. no gin. no json. this struct could not tell you what a database is.

two things carry the weight here. first, NewDojo is the only way to make a valid dojo — the fields are exported but there is no scenario where you construct one with an empty name, because the constructor refuses. an invalid aggregate cannot exist in this program. second, when it refuses, it wraps a typed sentinel error, ErrDojoValidation, so a caller can errors.Is against it instead of string-matching my prose.

the payoff scales with complexity. a fighter isn’t a flat struct — it belongs to a dojo, trains several martial arts, and holds belts — and the constructor enforces every one of those invariants before it hands you an object:

internal/domain/aggregates/fighter.go

go
func NewFighter(
	dojoID string,
	martialArtIds []string,
	belts []*Belt,
	name string,
) (Fighter, error) {
	if err := validator.Validate(dojoID, validator.ValidUUID); err != nil {
		return Fighter{}, fmt.Errorf("%v: %s %v", ErrFighterValidation, "DojoID", err)
	}

	if err := validator.Validate(name, validator.StringNotEmpty); err != nil {
		return Fighter{}, fmt.Errorf("%v: %s %v", ErrFighterValidation, "Name", err)
	}

	var martialArts []*entities.MartialArt
	for _, martialArtID := range martialArtIds {
		if err := validator.Validate(martialArtID, validator.ValidUUID); err != nil {
			return Fighter{}, fmt.Errorf("%v: %s %v", ErrFighterValidation, "MartialArtID", err)
		}
		martialArts = append(martialArts, &entities.MartialArt{ID: martialArtID})
	}

	// ... same UUID check for every belt

	return Fighter{
		ID:          uuid.NewString(),
		Dojo:        &entities.Dojo{ID: dojoID},
		MartialArts: martialArts,
		Belts:       belts,
		Name:        name,
	}, nil
}

by the time you’re holding a Fighter, every id in it is a real uuid and the name is non-empty. no defensive checks scattered downstream. the boundary is the constructor, and there’s exactly one.

a port is just an interface

the domain needs to persist a dojo, but it must not know how. so it declares what it needs — a port — and walks away:

internal/domain/repositories/dojo.go

go
type DojoRepository interface {
	Create(entity *entities.Dojo) (*entities.Dojo, error)
	Update(ID string, entity *entities.Dojo) (*entities.Dojo, error)
	Delete(ID string) error
	Get(ID string) (*entities.Dojo, error)
	List(filter *valueobjects.DojoFilter) ([]*entities.Dojo, error)
	Count(filter *valueobjects.DojoFilter) (int64, error)
}

this interface lives in the domain and speaks only domain: entities.Dojo, valueobjects.DojoFilter. it has no idea postgres exists. the errors it can return are declared right next to it as sentinels — ErrDojoGet, ErrDojoCreate, and friends — so the layers above catch a typed value, never a raw driver error leaking pq: noise up the stack.

this is the seam the testcontainers post was poking at. the whole reason you can spin up a real postgres and test the repository against it without touching http is that the port doesn’t drag the rest of the app along.

the driven side: postgres

now the adapter. this is where it gets opinionated, because the postgres side gets its own struct — separate from the domain entity — and a pair of mappers between them.

internal/infra/postgre/fighter_model.go

go
package postgre

type Fighter struct {
	ID          string
	DojoID      string
	Dojo        *Dojo
	MartialArts []*MartialArt `gorm:"many2many:fighter_martial_arts;"`
	Belts       []*Belt       `gorm:"many2many:fighter_belts;"`
	Name        string
}

this is where the gorm: tags live — and only here. the gorm docs ↗ drive the join tables through those many2many tags, which is pure persistence concern and has no business in the domain. notice the shape is genuinely different from the aggregate: the db model carries a flat DojoID foreign key and a nested *Dojo, because that’s what the orm wants. the domain doesn’t care about foreign keys.

so you need a translator. two of them, actually — one each way:

internal/infra/postgre/dojo_adapter.go

go
func fromEntityDojo(entity *entities.Dojo) *Dojo {
	return &Dojo{
		ID:      entity.ID,
		Name:    entity.Name,
		OwnerID: entity.OwnerID,
	}
}

func toEntityDojo(model *Dojo) *entities.Dojo {
	return &entities.Dojo{
		ID:      model.ID,
		Name:    model.Name,
		OwnerID: model.OwnerID,
	}
}

and the repository implementation is the adapter proper — it satisfies repositories.DojoRepository, maps at the edges, and keeps gorm sealed inside:

internal/infra/postgre/dojo_repo.go

go
func NewDojoRepository(db *gorm.DB) repositories.DojoRepository {
	return &DojoRepository{db: db}
}

func (repo *DojoRepository) Create(entity *entities.Dojo) (*entities.Dojo, error) {
	model := fromEntityDojo(entity)

	if err := repo.db.Create(&model).Error; err != nil {
		logger.Error("[DojoRepository] - [CreateDojo] %s: %s",
			repositories.ErrDojoCreate, err)
		return nil, repositories.ErrDojoCreate
	}

	return toEntityDojo(model), nil
}

Create takes a domain entity in, hands a domain entity back, and the gorm *Dojo never escapes the function. the raw driver error gets swallowed and replaced with the sentinel. the constructor return type repositories.DojoRepository — an interface — means callers literally cannot reach into the gorm internals even if they wanted to.

the driving side: http

here’s the symmetry that i think is the actual point of the whole exercise. the http side does the exact same thing as the postgres side, mirrored. its own model, with json tags instead of gorm tags:

internal/ports/http/fighter_model.go

go
type Fighter struct {
	ID          string        `json:"id"`
	Dojo        *Dojo         `json:"dojo,omitempty"`
	MartialArts []*MartialArt `json:"martialArts,omitempty"`
	Belts       []*Belt       `json:"belts,omitempty"`
	Name        string        `json:"name"`
}

and its own mapper from the domain out to the wire:

internal/ports/http/dojo_adapter.go

go
func fromEntityDojo(entity *entities.Dojo) *Dojo {
	return &Dojo{
		ID:      entity.ID,
		Name:    entity.Name,
		OwnerID: entity.OwnerID,
	}
}

so the domain sits in the middle with a pure struct, and on both sides there’s an adapter that owns its own tagged model and its own mapper. the postgres model has gorm tags. the http model has json tags. the domain has neither. change your json field names and nothing downstream of the domain moves; change your table layout and the api is untouched. that decoupling is the entire promise, and it’s mechanical, not magical.

the http handler is where an inbound request becomes a domain object — and note it goes through the constructor, so validation happens at the boundary and bad input never becomes a half-built entity:

internal/ports/http/fighter_handler.go

go
var payload Fighter
if err := ctx.ShouldBindJSON(&payload); err != nil {
	ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
	return
}

// ... collect martialArtIDs and belts from the payload

entity, err := aggregates.NewFighter(
	payload.Dojo.ID,
	martialArtIDs,
	belts,
	payload.Name,
)
if err != nil {
	ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
	return
}

gin lives in this file and nowhere deeper. the domain never sees a *gin.Context.

one place where everything is wired

no framework does the wiring. there is a single composition root — one constructor that news up every repository, hands each into its service, hands each service into its handler, and bolts the lot onto the server:

cmd/server/mojodojocasahouse/mojodojocasahouse.go

go
func NewMojoDojoCasaHouse(config *config.Config) *MojoDojoCasaHouse {
	database, err := postgre.NewClient(config.Database)
	if err != nil {
		logger.Fatal("[MojoDojoCasaHouse] ... %v", err)
	}

	dojoRepo := postgre.NewDojoRepository(database)
	fighterRepo := postgre.NewFighterRepository(database)
	// ... twelve more repositories

	dojoService := services.NewDojoService(dojoRepo)
	fighterService := services.NewFighterService(fighterRepo, dojoService)
	// ... twelve more services

	dojoHandler := http.NewDojoHandler(dojoService)
	fighterHandler := http.NewFighterHandler(fighterService)
	// ... twelve more handlers

	httpServer := http.NewServer(config.Port, /* every handler */)

	return &MojoDojoCasaHouse{config: config, httpServer: httpServer}
}

this is manual dependency injection, and i’d defend it. NewDojoRepository returns the interface repositories.DojoRepository, so this is the exact and only line where the interface meets its implementation. want an in-memory repo for a test? swap one constructor call. there’s no reflection, no wire, no magic — just a function you can read top to bottom, and if you forget to wire something the compiler tells you before the program runs.

it helps that all of this lives under internal/, which in go isn’t a naming convention — it’s a compiler-enforced boundary ↗ : nothing outside the module can import these packages, so the layering can’t be smuggled around by an eager consumer.

the honest part: the boilerplate tax

now the bill.

for a trivial entity, the split is pure overhead. go back and look at Dojo. the domain struct, the postgres model, and the http model are field-for-field identical — three copies of {ID, Name, OwnerID} — and fromEntityDojo/toEntityDojo exist on both sides just to copy three fields from one struct to an identical struct. that’s real code i have to write, read, and keep in sync, and for a dojo it buys me almost nothing. the pattern only earns its keep on Fighter, where the three shapes genuinely diverge. i paid the same tax on all fourteen entities to get the benefit on maybe four of them.

and it is fourteen entities, each with a parallel fleet of files. entity, repository interface, service interface, service impl, filter value object, postgres model, postgres adapter, postgres repo, http model, http adapter, http handler. that’s a directory that only grows, and a lot of it is copy-paste-rename. i’ll write a whole post someday about how much of this a code generator should have been producing — call it the boilerplate tax — because doing it by hand is how you end up with sentinel errors that read "coulnd't create dojo" and "dojo not foud" in production, typos frozen forever because nobody re-reads the ninth near-identical file. (those are real. they’re in the repo. i left them.)

the copies drift. the postgres toEntityDojos returns a nil slice on empty; the http fromEntityDojos returns an empty non-nil slice. minor, but it’s the exact class of inconsistency that hand-maintained duplication invites. and not every db model even carries its tags — dojo_model.go has zero gorm tags and leans entirely on gorm’s naming conventions, while fighter_model.go is explicit. same layer, two conventions, because i wrote them on different days.

manual di grows linearly and it’s all in one function. NewMojoDojoCasaHouse is already a hundred lines and every new entity adds three more. order matters — fighterService needs dojoService, so it has to come after — and you’re the one keeping that straight. it’s honest and greppable, which i prefer to a magic container, but let’s not pretend it stays elegant at fifty entities.

none of these are bugs. they’re the cost of the boundary. the question is only whether the boundary is worth the cost.

so is it worth it?

for mojodojo, mostly yes — but not uniformly, and that’s the honest answer.

  • the discipline pays off exactly where the shapes diverge. Fighter has a json representation, a relational representation, and a domain representation that are all genuinely different, and keeping them apart is the difference between clean code and a struct with nine tags fighting each other. that’s most of the value, concentrated in a minority of the entities.
  • testability is not a nice-to-have you bolt on later, it’s a property of the shape. because the port is an interface, the repository test in the testcontainers series could exercise real postgres with zero http in the room. you don’t get that for free from The Struct.
  • for a flat crud entity, this is over-engineering, and i should be able to admit that. the dojo would have been fine as one struct in a smaller app. the pattern is insurance, and like all insurance it’s a waste right up until it isn’t.

the meta-lesson: hexagonal in go needs almost no ceremony — interfaces and constructors, both already in the language, no framework, no annotations. the cost isn’t ceremony, it’s repetition. and repetition is a tooling problem, which is a much better problem to have than an architecture problem.

next up: i want mojodojo to boot with zero external files — no config.yaml sitting next to the binary, no migrations directory to ship. embedded migrations via go:embed and a config that writes its own defaults on first run, so the whole service is one self-contained binary. turns out go’s embed package makes that almost too easy.

it’s a learning project — warts, typos, and all — which, around here, is the point.

letters to the editor

no letters yet. the editor is patient.