If only the type parameter differs, it is one generic โ and the numbers show it.
๐ I'm Anton โ a software engineer working mostly in PHP/Symfony and Go, currently carving a live
PHP monolith into Go services. This part is about one rule I now apply before writing any new type,
and what happened to the line counts when I applied it to a service that was already working.
Notes: github.com/brilliant-almazov. None of it is advice
for your codebase โ one service, one audit, one set of measurements: possibly useful, possibly wrong
for your situation.
What the audit actually counted
I audited one of the Go services with a single question: where does it carry code that already exists
somewhere else in the same tree? The largest finding wasn't a bug โ it was a count. 44 copies of
four row-reading shapes, on 2026-08-13:
| Shape | Copies |
|---|---|
loop over many rows (for rows.Next() โ rows.Err() โ error wrapper) |
18 |
| read a single row | 6 |
COUNT(*) counter |
8 |
existence probe (one rows.Next()) |
12 |
| total | 44 |
Of those 44, 12 differed only in the text of the error wrapper โ not in behaviour, not in the
query. None of them was bad code: every one was correct, reviewed, tested and running. They were
identical, and being identical is not something a reviewer flags, because each arrives alone, in its
own change, next to its own domain. Lined up, they differ by the result type and the dependency.
That's a generic, written by copy-paste.
The rule, in one line
If only the type parameter differs, it is one generic.
The corollary is what actually holds the line: a domain-local copy next to an existing generic is
forbidden โ not discouraged, and enforced by tooling checks plus tests whose only job is to fail
when the copy comes back. For the read core the closing criterion is mechanical, so a test can own
it: for rows.Next(), rows.Err(), rows.Close() and QueryRow( occur in exactly one package
in the tree. Either the grep is clean or the build is red.
Why a test and not an agreement: the correct shape was already in the tree. A Reader[In, Out]
existed, with the right fields and the right method โ inside one domain package, where nobody outside
that domain found it, so everyone outside it wrote their own. A generic living in a domain package
is, for practical purposes, not a generic.
Four shapes, one core
loop over many rows 18 โโโ
single-row read 6 โโโค โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
COUNT(*) counter 8 โโโผโโโถ โ Reader[In, Out] โ
existence probe 12 โโโ โ executor โ
โ statement โ
44 copies ยท 12 differ only โ scanner โ
in an error string โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
What the core looks like
The core is deliberately small. Three fields and one method:
type Reader[In, Out any] struct {
executor Executor
statement Statement[In]
scanner Scanner[Out]
}
func (r Reader[In, Out]) Query(ctx context.Context, in In) (Cursor[Out], error)
executor runs the query โ a pool or a transaction-scoped handle. statement turns typed input into
SQL plus arguments. scanner turns one row into one Out. Around that sit the collectors: a set, a
counter, an existence probe. Before, each domain owned its own loop instead:
// one of eighteen
func (r *entityRepository) List(ctx context.Context, in ListInput) ([]Entity, error) {
rows, err := r.pool.Query(ctx, listEntitiesSQL, in.AccountID, in.Limit)
if err != nil {
return nil, fmt.Errorf("query entities: %w", err)
}
defer rows.Close()
out := make([]Entity, 0, in.Limit)
for rows.Next() {
var e Entity
if err := rows.Scan(&e.ID, &e.Name); err != nil {
return nil, fmt.Errorf("scan entity: %w", err)
}
out = append(out, e)
}
return out, rows.Err()
}
The order version was the same function with Order in place of Entity and a different string in
the error wrappers. After, both domains are two calls into one core:
entities := reading.NewSet(executor, listEntities, scanEntity)
orders := reading.NewSet(executor, listOrders, scanOrder)
es, err := entities.Query(ctx, ListInput{AccountID: id, Limit: 50})
ords, err := orders.Query(ctx, ListInput{AccountID: id, Limit: 50})
Each domain still owns what is genuinely its own โ the statement and the scanner. What it no longer
owns is the loop, the defer, the rows.Err() check and the error wrappers that were being retyped
by hand every time.
The cores that exist besides reading
Reading is one row in a longer list. The current set in that service โ twenty generic RPCs reads as
a lot until you notice that adding an entity means naming its spec, not writing twenty handlers:
| Core | What it gives |
|---|---|
| generic RPC (20 of them) |
archive, attach, close, correct, create, create_batch, detach, find_all_by_ids, get, get_by_key, list_revisions, lookup, lookup_pair, move, mutation, save, search, set, update, walk
|
| codecs |
Shape, Selector, NewUnary, NewCollection, NewPaging, NewPairing, Suite, Spec, BatchSuite
|
| entity repository |
Base, Spec[H, P, V], Calls, ScopedCalls, Search, FindAllByIds, Reader[In, Out], cursors |
| entity manager |
Base, Spec, Calls, ScopedCalls, Manager, methods/
|
| history |
Revisioned[T]: Current / AsOf / KnownAsOf / Append / Correct / Close โ one shape for the whole service |
| row reading | set, counter, existence probe, Reader with collectors |
| pagination | keyset cursor over int64 plus a clamp on page size |
| domain assembly |
Builder[Repo, Mgr], Domain[Repo, Mgr], New, Input, ManagerInput[Repo], HandlerInput[Repo, Mgr]
|
| task running |
Runner[T]: serial and parallel |
| checks |
Rule[In], Guard[In, Out], Each[In], Nullable[T]
|
| ports |
Scoped[T], Self[T], Creator, Updater, Corrector, Archiver, Viewer, Finder, Locator, Searcher, Historian
|
| test rig | scoped Rig, Spec, StubManager, StubRepository, Port, NewItem, NewPatch, KeepData
|
What the numbers did
I measure line counts at every tag by walking the tag history, generated code excluded. The move
onto generic cores lands on one tag, v1.0.0:
v0.4.0 |
v1.0.0 |
|
|---|---|---|
| lines of code | 28095 | 25097 |
| lines of tests | 31952 | 35368 |
| tests / code | 1.14 | 1.41 |
| code files ยท packages | 640 ยท 85 | 683 ยท 94 |
| average file | 43 lines | 36 lines |
Code went down while the service went up. Three thousand lines left, and both the file count and
the package count rose โ no feature dropped, the same behaviour in fewer lines over more files.
Tests went up โ the part I'd have got wrong if I'd guessed. Collapsing 44 copies onto one core
does not collapse 44 test files with them: the core earns a suite of its own, and each domain keeps
the tests describing its statement and scanner. The ratio went 1.14 โ 1.41, and across the whole tag
series it has moved from 0.45 at the first tag to 1.25 at head, never below 1.14 after v1.0.0.
Average file size dropped to 36 lines, and from there the service grows by files and packages
rather than by files getting longer. The most recent release added 1392 lines of code, 1767 lines of
tests and 20 packages โ the read core plus the SQL catalogue engine, with their tests. Nothing here
measures speed, latency or defect rate; I didn't measure those, so I'm not claiming them.
Telling real commonality from a shape that merely rhymes
The rule is easy to over-apply, and over-applying it is worse than the copies.
Real commonality: the difference reduces to a type parameter and a dependency, and the behaviour
matches literally โ not "roughly", not "with one flag". Line the two up and the only thing your eye catches
is the type name.
Accidental similarity: two places share a shape today and diverge on the first new requirement.
The tell is that you can already name the requirement that would split them โ if you can, leave them
alone, or you'll be adding a boolean to the core within a month, and a core with behaviour flags is
worse than the two copies it replaced. The 44 gave an unusually clean signal here: 12 differed only
in error-wrapper text. When the delta between two implementations is a string constant, no
requirement is waiting to split them.
What it costs
None of this is free, and the costs are all paid in readability:
-
Signatures get longer and read worse.
Reader[In, Out]with three typed dependencies is more to take in thanfunc List(ctx, in) ([]Entity, error). - Compiler errors on generics are verbose โ a mismatch three layers into a parameterised core produces a message you sit and parse.
- Tooling handles type parameters less well than concrete types, and some of it degrades quietly.
- Jump-to-definition lands in the core, not the domain. You can no longer understand one behaviour by reading one file โ a real, permanent loss. The copies were worse in aggregate, better in isolation.
I'd take that trade again on this service. On a service with three read sites instead of forty-four,
I wouldn't. That's my experience on one codebase and my price for it, and I'd like to hear the other
side: if you do this better than I do, if you've been through it already, or if you look at it
differently โ how is it solved in your codebase, and what broke when you tried?
Platform and generation โ Part 4.
Next: what happens when the universal code stops living in the service โ how it moves into the
shared platform library and comes back as a tag.



Top comments (0)