DEV Community

Anton Brilliantov
Anton Brilliantov

Posted on

One Generic Core Instead of Many Copies

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                โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
Enter fullscreen mode Exit fullscreen mode

Four boxes labelled 18, 6, 8 and 12, summing to 44, with four arrows converging into a single box labelled Reader of In and Out

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)
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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})
Enter fullscreen mode Exit fullscreen mode

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

Table of twelve generic cores with a short description of what each one provides

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

Two panels comparing tag v0.4.0 with tag v1.0.0: code bars 28095 falling to 25097, test bars 31952 rising to 35368, and the ratio 1.14 rising to 1.41

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 than func 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)