~/posts/go-boilerplate-tax

the boilerplate tax: fourteen entities, an 890-line router, and go

an honest retrospective on structural duplication in a hexagonal go codebase — and when copy-paste actually beats codegen

there’s a file in mojodojo called dojo.go that declares six sentinel errors. two of them are misspelled: "coulnd't create dojo" and "dojo not foud". i knew they were misspelled when i wrote them. and then i wrote the same six errors, with the same two typos, thirteen more times — "coulnd't create belt", "fighter not foud", "coulnd't count fees" — because every entity got its own file, and i was copy-pasting the last one.

nobody re-reads the ninth near-identical file. that’s the whole story of this post.

back in the hexagonal architecture post i promised i’d write this one. i even named it there: the boilerplate tax — the price you pay, in mojodojo, for handing fourteen domain entities each a full parallel fleet of files. that post argued the architecture was worth it. this one is the invoice.

the ground rules, quickly: mojodojo is a 2024 learning project — a martial-arts gym manager, dojos and fighters and belts. nothing shipped, no users, just me and a self-imposed rule that the domain never learns how it’s stored or served. good rule. it also means every entity needs a translator on each side, and translators are copy-paste-shaped.

one entity, eleven files

take the humblest entity in the codebase, Dojo. here’s the fleet it owns:

  • the entity (internal/domain/entities/dojo.go)
  • the repository interface (internal/domain/repositories/dojo.go)
  • the service interface (internal/domain/services/dojo.go)
  • the service implementation (internal/application/services/dojo.go)
  • the filter value object (internal/domain/valueobjects/dojo_filter.go)
  • the postgres model, adapter, and repo (internal/infra/postgre/dojo_*.go)
  • the http model, adapter, and handler (internal/ports/http/dojo_*.go)

eleven files. now multiply by fourteen. that’s the shape of the tax.

and most of those files barely differ from their neighbours. here’s the entire postgres mapper for a dojo:

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,
	}
}

func toEntityDojos(models []*Dojo) []*entities.Dojo {
	var entities []*entities.Dojo
	for _, model := range models {
		entities = append(entities, toEntityDojo(model))
	}
	return entities
}

and here’s the one for a martial art, sitting one file over:

internal/infra/postgre/martialart_adapter.go

go
func fromEntityMartialArt(entity *entities.MartialArt) *MartialArt {
	return &MartialArt{
		ID:        entity.ID,
		Name:      entity.Name,
		ShortName: entity.ShortName,
	}
}

func toEntityMartialArt(model *MartialArt) *entities.MartialArt {
	return &entities.MartialArt{
		ID:        model.ID,
		Name:      model.Name,
		ShortName: model.ShortName,
	}
}

// toEntityMartialArts, fromEntityMartialArts — same loop, different type

find the difference. it’s the type names and one extra field. the loop, the structure, the naming scheme — identical. i wrote thirteen more of these by hand. the repo files are worse: dojo_repo.go and martialart_repo.go are 150-odd lines each and diff to about six substituted identifiers.

the router that ate a file

the worst offender is internal/ports/http/server.go. it’s a single hand-written function, Run, that registers every route, and it is ~890 lines long. every route looks like this:

internal/ports/http/server.go

go
authenticated.GET(
	"/roles",
	PermissionsMiddleware(
		[]*valueobjects.Permission{
			{
				ID: valueobjects.RoleList,
			},
		}),
	instance.roleHandler.List,
)

authenticated.POST(
	"/roles",
	PermissionsMiddleware(
		[]*valueobjects.Permission{
			{
				ID: valueobjects.RoleCreate,
			},
		}),
	instance.roleHandler.Create,
)

// ... and Get, Update, Delete, for fourteen entities

as many as five routes per entity, each a nine-line incantation whose only moving parts are the http verb, the path, the permission id, and the handler method. some entities add an “owned” permission and swell to twelve:

internal/ports/http/server.go

go
authenticated.GET(
	"/dojos/:id",
	PermissionsMiddleware(
		[]*valueobjects.Permission{
			{
				ID: valueobjects.DojoGet,
			},
			{
				ID: valueobjects.DojoGetOwned,
			},
		}),
	instance.dojoHandler.Get,
)

that PermissionsMiddleware([]*valueobjects.Permission{{ID: ...}}) boilerplate appears — i counted — sixty-seven times in one file. it is four data points wearing eighty characters of ceremony.

when copy-paste is actually fine in go

before i pile on, the thing i have to say, because it’s true: a lot of this repetition is correct, and go pushes you toward it on purpose.

go is a deliberately verbose language. no map/filter/reduce on slices worth speaking of, if err != nil every three lines, and for most of its life no generics at all. that’s a choice. one of rob pike’s go proverbs ↗ is “a little copying is better than a little dependency” — the language would rather you paste twenty honest lines than import a clever abstraction that couples you to someone else’s idea of the shape.

and for the mappers, that instinct is right. fromEntityDojo is dumb, obvious, greppable, and impossible to get subtly wrong. if i want to know how a dojo becomes a database row, i open one file and read ten lines with no indirection — no reflection, no struct-tag magic, no mapstructure. when the http and postgres shapes genuinely diverge — as they do for Fighter, which the hexagonal post walked through — those hand-written mappers are the clearest code in the repo. copy-paste bought me clarity, and clarity isn’t nothing.

the explicit router has the same defense. you can grep DojoGet server.go and find, in one place, exactly which permission guards that route. no framework introspection, no decorator resolving at runtime. dumb and honest.

where the duplication bites back

but.

the reason i opened with those typos is that they’re the exact failure mode copy-paste invites, and mojodojo has them frozen in fourteen times over:

internal/domain/repositories/dojo.go

go
var (
	ErrDojoCreate = errors.New("coulnd't create dojo")
	ErrDojoUpdate = errors.New("coulnd't update dojo")
	ErrDojoDelete = errors.New("coulnd't delete dojo")
	ErrDojoGet    = errors.New("dojo not foud")
	ErrDojoList   = errors.New("coulnd't list dojos")
	ErrDojoCount  = errors.New("coulnd't count dojos")
)

coulnd't. foud. i typed those once, they looked fine at a glance, and my own copy-paste carried them into belt.go, fee.go, fighter.go, all of them. there was never a moment where i sat and re-read the ninth copy, because it was “the same file as the last one.” that’s precisely how a typo becomes load-bearing.

and typos are the harmless version. here’s the one that actually scared me — the permission gate in the dojo service:

internal/application/services/dojo.go

go
var (
	DojoAll = []*valueobjects.Permission{
		{ID: valueobjects.ClassGet},
		{ID: valueobjects.ClassUpdate},
		{ID: valueobjects.ClassDelete},
	}

	DojoOwned = []*valueobjects.Permission{
		{ID: valueobjects.ClassGetOwned},
		{ID: valueobjects.ClassUpdateOwned},
		{ID: valueobjects.ClassDeleteOwned},
	}
)

read the permission ids. they’re ClassGet, ClassUpdate, ClassDeleteclass permissions, in the dojo service. i built this file by copying class.go and renaming, and i renamed the variables but not their contents. so a user’s access to a dojo gets checked against whether they can touch a class. in a real system that’s a privilege bug, and it exists because the file was near-identical to another one and i skimmed it. the compiler was perfectly happy: ClassGet is a valid Permission, the types line up, nothing to complain about. copy-paste turned a semantic error into a silent one.

that’s the tax’s real teeth. not the extra keystrokes — the fact that the human reviewer (me) pattern-matches “oh, another adapter” and stops actually reading.

could tooling have saved me?

three candidates, and they are not interchangeable.

generics — no, not for the fleet. go has had them since 1.18 and mojodojo is on 1.22, so this isn’t a version problem, it’s a shape problem. the official when to use generics ↗ post is blunt: “if you find yourself writing the exact same code multiple times, where the only difference between the copies is that the code uses different types, consider whether you can use a type parameter.” the operative words are only difference. a generic toEntities[M, E any](models []M, f func(M) E) []E would happily collapse all fourteen of those slice-mapping loops — that part is pure type variation. but the fleet as a whole isn’t. dojo_repo.go and belt_repo.go differ by table name, by six distinct sentinel errors, by log-tag strings, by the filter struct’s fields. that’s not “different types,” that’s different data — and the same post’s headline advice is “write go programs by writing code, not by defining types.” generics can’t stamp out a CRUD fleet, because a CRUD fleet isn’t a type. it’s a template.

go generate — yes, this is the actual answer. the thing i kept hand-copying is a template with fourteen substitutions, and go ships a first-class tool for exactly that. rob pike’s 2014 generating code ↗ post introduced go generate, and the cmd/go docs ↗ spell out the mechanics: a //go:generate directive, a text/template, one entity spec, and the adapter/model/repo fleet falls out of a generator instead of my clipboard. the typos would live in one template and get fixed in one place. the ClassGet bug could not have happened, because the permission ids would be derived from the entity name, not pasted. this is what the promised post was always going to conclude: the repetition was never an architecture problem, it was a tooling problem — and i solved a tooling problem by hand.

a table-driven route registry — yes for the router, with a caveat. those ~890 lines are begging to be data. a []route{{method, path, permissions, handler}} slice and a six-line loop calling authenticated.Handle would collapse the whole file into a table you read at a glance — every route, every permission, lined up in one scannable column. i’d catch a missing guard instantly. the caveat is what i’d lose: the explicit version is trivially greppable and stack traces point at a real line per route. a table trades legibility-per-route for legibility-of-the-whole. for fourteen entities the table wins; at three entities it’d be over-engineering. i didn’t do it, and ~890 lines is the receipt.

so was the copy-paste a mistake?

partly. the honest split:

  • the mappers: fine. hand-written, dumb, clear. i’d paste them again. this is where “a little copying is better than a little dependency” holds.
  • the sentinel errors and permission slices: not fine. these are the files nobody re-reads, and they’re exactly where a generator earns its keep — one template, zero drift, no frozen typos, no ClassGet guarding a dojo. the cost of copy-paste here wasn’t lines, it was two real bugs.
  • the router: should’ve been a table. ~890 lines of nine-line incantations is data cosplaying as code.

the meta-lesson is the one the hexagonal post gestured at and this one gets to say plainly: the cost of the architecture wasn’t the abstraction, it was the repetition, and repetition is a tooling problem. that’s a good problem to have — it has a boring, well-documented answer (go generate) that i simply didn’t reach for, because pasting felt faster in the moment. it was faster in the moment. it was slower by the ninth file, and it shipped a permission typo i didn’t notice for months.

and — this is the part i keep relearning — copy-paste doesn’t just duplicate code. it duplicates the assumption that you already read this. the ninth file gets the least attention and needs the most.

next up: the permission model i just showed you pasting wrong — owner-scoped rbac, “you can only see your own,” and why the right layer to enforce it is emphatically not the one where a stray ClassGet can hide.

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

letters to the editor

no letters yet. the editor is patient.