DEV Community

Anton Brilliantov
Anton Brilliantov

Posted on

Life Before Generics

The type parameter didn't make my code clever. It moved a class of checking out of review and into the compiler - and it charged me for it.


๐Ÿ‘‹ I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This one is personal history plus the code it produced: where I learned to live without generics, what I built instead, and what a service looks like now that I don't have to. Notes: github.com/brilliant-almazov.

As always: this is what I do on one codebase, with the costs I actually pay. Not advice for yours.


Where I come from

PHP has no generics. Not "has them awkwardly" - has none. There is no syntax for a type parameter, and there is no runtime that could check one if there were.

So we suffered a little, and then we solved it differently. Four tools, in roughly the order you reach for them:

  • Interfaces. The thing you pass in is described by what it can do, and the concrete type is nobody's business.
  • Base classes. The shared behaviour goes into an abstract parent; each child fills in the parts that differ. Inheritance stands in for parameterisation.
  • Arrays of mixed things. A collection is array, and what's inside it is a matter of convention.
  • Duck typing at the edges. At the boundary you accept what arrives and check it yourself.

And then, holding all of it together: a lot of discipline. A repository looked like this - and I want to be fair to it, because it worked:

abstract class AbstractRepository
{
    /** @return object[] */
    public function findBy(array $criteria): array
    {
        // one implementation, shared by everything
    }
}

final class OrderRepository extends AbstractRepository
{
    /** @return Order[] */
    public function findBy(array $criteria): array
    {
        return parent::findBy($criteria);
    }
}
Enter fullscreen mode Exit fullscreen mode

Look at what is carrying the type in that snippet. It isn't the signature - the signature says array, which is to say "anything". It's the docblock. @return Order[] is the type parameter, written in a comment, enforced by a static analyser that runs in CI if someone set one up, and by nothing at all at runtime.

The ecosystem did eventually build the missing feature in comments. Psalm and PHPStan understand @template, and you can write something that reads almost like the real thing:

/**
 * @template T of object
 */
abstract class AbstractRepository
{
    /**
     * @param class-string<T> $class
     * @return list<T>
     */
    public function findBy(string $class, array $criteria): array { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

I used this, I liked it, and I want to be precise about what it is and isn't. It is a real type system - it catches real mistakes, and a codebase at max analyser level is a genuinely different place to work. It is not the compiler:

  • it runs where someone installed it, at the level someone configured, with the baseline someone generated when the errors got inconvenient;
  • it describes the code rather than constraining it, so a @return list<Order> above a method that returns list<Entity> is a lie the runtime is perfectly happy to execute;
  • and it disappears the moment a value crosses a boundary the annotations don't cover - a decoded JSON body, a row out of the database, a mixed from a queue.

The failure mode is not "the analyser is wrong". It's that the type lives in a place a person can edit without the program changing, so it drifts, and it drifts silently.

  four substitutes for a type parameter, in a language that has none

  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚ interfaces     โ”‚ โ”‚ base classes   โ”‚ โ”‚ arrays of    โ”‚ โ”‚ duck typing  โ”‚
  โ”‚                โ”‚ โ”‚                โ”‚ โ”‚ mixed things โ”‚ โ”‚ at the edges โ”‚
  โ”‚ interface      โ”‚ โ”‚ abstract class โ”‚ โ”‚ array        โ”‚ โ”‚ is_a()       โ”‚
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

  and one thing holding all four up

  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚       DISCIPLINE       โ”‚
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
       โ”‚              โ”‚
       โ–ผ              โ–ผ
    review          tests

  the compiler is not on this diagram
Enter fullscreen mode Exit fullscreen mode

On the left, four substitutes for a type parameter in a language that has none - interfaces, base classes, arrays of mixed things, duck typing at the edges - each drawn as a small card; on the right, a single card labelled discipline, larger than the others, with two arrows leaving it towards review and tests

That is the whole cost, and it's worth stating plainly rather than as a complaint:

The compiler could not help, so the checking moved into review and into tests.

Both of those are real mechanisms and both of them work - they just cost a person's attention every time, forever, on every change. A wrong type in an array is caught by a test if someone wrote that test, or by a reviewer if someone was looking. Neither is a property of the code. Both are properties of the team's current level of care, which is not a stable quantity.

The second cost is subtler and I only noticed it after leaving: the substitutes are not free of their own gravity. Base classes accumulate. What starts as "shared read behaviour" becomes a parent with eleven protected methods, four of which two children override in incompatible ways. The inheritance chain becomes the place where the polymorphism lives, and it is much harder to delete than a type parameter is. You can remove a type parameter in an afternoon. Removing a base class is a project with a name.

Go before generics

Then I came to Go, which at the time didn't have generics either. Different language, same missing thing, and - this is the part that surprised me - a worse set of substitutes, because Go has neither inheritance nor a static-analyser culture that reads comments.

There were four honest options and everybody knew all four.

Option one: interface{} and a type assertion. You accept anything, and at the point of use you assert what you actually have.

func collect(rows pgx.Rows, scan func(pgx.Rows) (interface{}, error)) ([]interface{}, error)
Enter fullscreen mode Exit fullscreen mode

Every caller then unpacks the result:

items, err := collect(rows, scanOrder)
orders := make([]Order, 0, len(items))
for _, item := range items {
    order, ok := item.(Order)
    if !ok {
        return nil, ErrBadType
    }
    orders = append(orders, order)
}
Enter fullscreen mode Exit fullscreen mode

That is the PHP docblock again, except now the failure is at runtime and the unpacking is code you have to write, review and test. You didn't remove the copy - you moved it, and made it longer. There's a second bill people forget: every element goes through an interface box on the way in and a type assertion on the way out, so a hot read path pays allocation and indirection for the privilege of being untyped.

Option two: reflection. You can write the loop once and fill a *[]Order handed in as any, matching columns to struct fields at runtime. It works - several well-known libraries are built on it. It also moves every mistake to runtime, makes the code unreadable in exactly the place you most want to read it, and costs enough per row that you notice on a large result set.

Option three: code generation. Write the shape once as a template, generate a typed copy per entity, commit the output. It genuinely works, and the typed result is exactly what you wanted. What you buy it with: a build step, generated files in the repository, a DO NOT EDIT header that people edit anyway, and debugging that happens in a file nobody wrote.

Option four - the honest one: write it once per type. No cleverness, no runtime failure mode, no build step. Just the same function again, with Order where Entity used to be.

Most of my Go, and most of the Go I read, was option four. It reads fine. Here is one read path, written the honest way:

func (r *OrderRepository) Search(ctx context.Context, f OrderFilter) ([]Order, error) {
    rows, err := r.pool.Query(ctx, orderSearchSql, f.TenantId, f.Cursor, f.Limit)
    if err != nil {
        return nil, fmt.Errorf("query orders: %w", err)
    }
    defer rows.Close()

    out := make([]Order, 0, f.Limit)
    for rows.Next() {
        var o Order
        if err := rows.Scan(&o.ID, &o.TenantId, &o.Code, &o.CreatedAt); err != nil {
            return nil, fmt.Errorf("scan order: %w", err)
        }
        out = append(out, o)
    }
    if err := rows.Err(); err != nil {
        return nil, fmt.Errorf("iterate orders: %w", err)
    }
    return out, nil
}
Enter fullscreen mode Exit fullscreen mode

Nothing is wrong with that function. It is clear, it handles its errors, errors.Is will still match through the wraps. The problem is that there is another one exactly like it for Entity, and another for Account, and the fourth one is being written right now in a branch you haven't seen.

And the copies rot at different speeds. That is the part that costs money. One of them learns to call rows.Close() on the early-return path; the others don't. One of them starts checking rows.Err() after the loop; two of them still treat "zero rows" and "the connection died mid-iteration" as the same answer. You don't find that by reading - all four look right in isolation. You find it when a query returns eleven rows out of a thousand and nothing anywhere says why.

  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚ interface{} +        โ”‚ โ”‚ go:generate          โ”‚ โ”‚ one copy per type    โ”‚
  โ”‚ assertion            โ”‚ โ”‚                      โ”‚ โ”‚                      โ”‚
  โ”‚                      โ”‚ โ”‚                      โ”‚ โ”‚                      โ”‚
  โ”‚ GIVES                โ”‚ โ”‚ GIVES                โ”‚ โ”‚ GIVES                โ”‚
  โ”‚ one implementation   โ”‚ โ”‚ typed output         โ”‚ โ”‚ nothing to explain   โ”‚
  โ”‚                      โ”‚ โ”‚                      โ”‚ โ”‚                      โ”‚
  โ”‚ CHARGES              โ”‚ โ”‚ CHARGES              โ”‚ โ”‚ CHARGES              โ”‚
  โ”‚ runtime failure,     โ”‚ โ”‚ a build step and     โ”‚ โ”‚ the same function    โ”‚
  โ”‚ unpacking at every   โ”‚ โ”‚ files nobody wrote   โ”‚ โ”‚ again, and again     โ”‚
  โ”‚ caller               โ”‚ โ”‚                      โ”‚ โ”‚                      โ”‚
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                                     the one most Go
                                                     actually picked
Enter fullscreen mode Exit fullscreen mode

Three columns headed interface braces plus assertion, code generation, and one copy per type; under each, two lines - what it gives you and what it charges - with the third column marked as the one most people actually pick

What the copies actually differ in

This isn't a hypothetical for me. In August 2026 I ran an audit over my own service looking for hand-written ways to read rows out of Postgres. It found 44 copies of four forms:

Form Copies
many-row loop (for rows.Next() โ†’ rows.Err() โ†’ wrap the error) 18
single row 6
COUNT(*) counter 8
existence probe (one rows.Next()) 12

Then I lined them up next to each other and looked at what was actually different between any two of them. Two things:

  1. the result type;
  2. the wording of the error wrapper.

Twelve of the forty-four differed only in the second one. Twelve functions, identical down to the loop structure, whose entire distinguishing content was that one said scan order and another said scanning entity row.

That detail sounds like a joke about naming. It isn't. Error text is an interface: something upstream matches on it, a dashboard groups by it, a person searches the logs for it. Twelve spellings of one failure means the log query that finds the incident finds a third of it.

A thing that differs from its neighbour only in a type - that is not a family of functions. That is one function and a parameter, typed out by hand forty-four times. The copies existed because at the time there was no way to say it, and then they kept existing because by the time there was, nobody went back.

  44 copies of four forms

  many-row loop     โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ  18
  single row        โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ              6
  COUNT(*) counter  โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ            8
  existence probe   โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ        12
                                        โ”€โ”€
                                        44

  of those 44, TWELVE differ only in the wording of the error wrapper

  the result type and the error text are the whole difference
Enter fullscreen mode Exit fullscreen mode

Four bars sized 18, 6, 8 and 12 under the heading forty-four copies of four forms; a bracket over the twelve-bar labelled differ only in the wording of the error wrapper; below, one line - the result type and the error text are the whole difference

How I write a service now

So here is the current shape. It's the point of this piece, and every part of it is a place where the type parameter sits.

A domain in my service is six things. All names below are neutral (entity, order) - the shapes are real.

1. The entity is the typed unit

Everything below is parameterised over it. It's a plain struct plus its identifiers - no framework, no base type, no marker interface, no embedded Model. It's the thing the type parameter is.

package entity

type Row struct {
    ID        int64
    TenantId  int64
    Code      string
    Revision  int32
    CreatedAt time.Time
}
Enter fullscreen mode Exit fullscreen mode

That it's a plain struct matters more than it looks. The moment an entity has to implement something to participate, the constraint stops describing data and starts describing a framework, and every new domain pays an initiation fee.

2. The repository holds the read operations, generic over the entity

The read side is where the copy-paste lived, so it's where the cores are. Three small interfaces carry the whole thing:

// what can execute a query โ€” a pool, or a transaction, and nothing else knows which
type Executor interface {
    Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
}

// how a typed input becomes SQL
type Statement[In any] interface {
    Build(in In) (sql string, args []any, err error)
}

// how one row becomes a typed value
type Scanner[Out any] interface {
    Scan(rows pgx.Rows) (Out, error)
}
Enter fullscreen mode Exit fullscreen mode

And the core those three make possible - this is the one that forty-four functions collapsed onto:

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) ([]Out, error) {
    sql, args, err := r.statement.Build(in)
    if err != nil {
        return nil, fmt.Errorf("%w: %w", ErrStatement, err)
    }

    rows, err := r.executor.Query(ctx, sql, args...)
    if err != nil {
        return nil, fmt.Errorf("%w: %w", ErrQuery, err)
    }
    defer rows.Close()

    out := make([]Out, 0)
    for rows.Next() {
        item, err := r.scanner.Scan(rows)
        if err != nil {
            return nil, fmt.Errorf("%w: %w", ErrScan, err)
        }
        out = append(out, item)
    }
    if err := rows.Err(); err != nil {
        return nil, fmt.Errorf("%w: %w", ErrIterate, err)
    }
    return out, nil
}
Enter fullscreen mode Exit fullscreen mode

Four sentinels - ErrStatement, ErrQuery, ErrScan, ErrIterate - and every caller everywhere matches them with errors.Is through however many wraps sit on top. That's the twelve spellings problem closed by construction rather than by a naming convention.

The other three forms are the same core with a different tail: one row, a count, an existence probe. Each is ten lines, each exists once.

A domain's read path becomes a declaration:

var searchOrders = rows.NewReader[order.Filter, order.Row](
    executor,
    order.SearchStatement{},
    order.RowScanner{},
)
Enter fullscreen mode Exit fullscreen mode

Three lines instead of twenty-two, and - this matters more - the loop, the close, the rows.Err() check and the error wording are now in one place, so twelve functions can no longer disagree about how to phrase the same failure or about whether to check the iteration error at all.

Above the reader sits the part a domain actually configures. Spec is the description of a table: what the header row is, what the payload is, how the version is carried.

type Spec[H, P, V any] struct {
    Table   string
    Header  Columns[H]   // identity: id, tenant, code
    Payload Columns[P]   // the changing part
    Version Columns[V]   // revision, valid-from, valid-to
}
Enter fullscreen mode Exit fullscreen mode

Three parameters look like a lot until you notice they're the three groups every table in this service actually has, and that separating them is what lets one core do keyset pagination, optimistic locking and partial updates without a domain writing any of them.

Paging is generic too, and it's the sort of type that must exist exactly once in a codebase:

type Page[T any] struct {
    Items         []T
    NextPageToken string
}
Enter fullscreen mode Exit fullscreen mode

I've seen the entity-shaped version of this three times in one repository under three names. A page is not about orders; it is about pages.

3. The manager holds the write operations, generic over the entity

One layer, one job: writes, including find-or-create. Repository reads, manager writes, and there is no third thing - no store, no service, no dao. If a manager needs to read, it calls the repository rather than growing its own SQL.

type Manager[E, C, U any] struct {
    executor Executor
    spec     Spec[E, C, U]
    reader   Reader[Key[E], E]
}

func (m Manager[E, C, U]) FindOrCreate(ctx context.Context, key Key[E], create C) (E, error)
func (m Manager[E, C, U]) Update(ctx context.Context, key Key[E], patch U) (E, error)
func (m Manager[E, C, U]) Archive(ctx context.Context, key Key[E]) error
Enter fullscreen mode Exit fullscreen mode

FindOrCreate is the one worth pausing on, because it's where a copy-per-domain hurts most. It is a select, then a conditional insert, then a re-read on conflict - and every hand-written instance of it gets the conflict branch subtly differently. Written once, the race is thought about once.

Note what is not in these signatures: no tx pgx.Tx. The executor is a field, and a transaction-scoped instance is a new value, not a new argument:

func (m Manager[E, C, U]) WithTx(tx pgx.Tx) Manager[E, C, U] {
    m.executor = tx
    return m
}
Enter fullscreen mode Exit fullscreen mode

4. The query builder is declarative

This is the piece that actually made the read side stop being copy-paste, and it's easy to under-sell. All SQL in the service goes through a builder; raw SQL in Go is forbidden, with migration DDL and test fixtures as the only exemptions.

The point of the builder isn't string safety. It's that you describe what you want, not how to assemble it:

spec := query.
    Select[order.Row](order.Columns...).
    From(order.Table).
    Where(order.TenantEq, order.NotArchived).
    OrderBy(order.CreatedAtDesc).
    Page(cursor)
Enter fullscreen mode Exit fullscreen mode

Compare that with the alternative, which is a function that concatenates a WHERE clause conditionally and gets the placeholder numbering right by hand. Written once, that function is fine. Written once per domain, it is the same bug eight times - and the bug is always the same bug, some variation of a filter that silently didn't apply. Silently is the operative word: a filter that doesn't apply returns more rows, and more rows look like data, not like a failure.

Because the builder is declarative, the reader above can be generic at all. Statement[In] doesn't need to know how a filter becomes SQL; it needs a description it can turn into one.

5. Processors plug into the builder instead of being baked into each query

A read is rarely just "select these columns". It's "select these columns, apply whatever filters the caller sent, sort by whatever they asked for, clamp the page size". Every one of those is a transformation of the query, and every one of them used to be an if inside a hand-written method.

Now they're values:

type Processor[Q any] interface {
    Apply(q Q) Q
}

type Chain[Q any] []Processor[Q]

func (c Chain[Q]) Apply(q Q) Q {
    for _, p := range c {
        q = p.Apply(q)
    }
    return q
}
Enter fullscreen mode Exit fullscreen mode

A read declares which processors it runs and in what order; the builder applies them. Adding a new filter to a domain is adding a processor to a list, not editing a query. Adding a sort that four domains need is writing one processor, not four ORDER BY branches.

The rule that keeps this from becoming its own mess: a processor is generic over the query, not over the domain. The moment I see OrderStatusProcessor and EntityStatusProcessor with the same body, that's the same forty-four-copies smell in a new costume.

6. The gRPC handler may or may not be transactional - and that's declared, not implemented

Transactions sit outside the read/write split entirely: a decorator applied from the outside, only on mutations, with the executor taken from the context. tx pgx.Tx in a signature is forbidden, and the domain code never learns it is running inside a transaction.

func Transactional[Req, Res any](pool *pgxpool.Pool, next Handler[Req, Res]) Handler[Req, Res] {
    return HandlerFunc[Req, Res](func(ctx context.Context, req Req) (Res, error) {
        var zero Res
        tx, err := pool.Begin(ctx)
        if err != nil {
            return zero, fmt.Errorf("%w: %w", ErrBegin, err)
        }
        defer tx.Rollback(ctx)

        res, err := next.Handle(WithExecutor(ctx, tx), req)
        if err != nil {
            return zero, err
        }
        if err := tx.Commit(ctx); err != nil {
            return zero, fmt.Errorf("%w: %w", ErrCommit, err)
        }
        return res, nil
    })
}
Enter fullscreen mode Exit fullscreen mode

So "is this call transactional?" is a property declared where the domain's calls are assembled, alongside the handler, not a thing re-implemented per method:

func (d Domain) Calls() Calls {
    return Calls{
        Search: method.Search(d.repo),                          // read: no decorator
        Create: Transactional(d.pool, method.Create(d.manager)), // write: wrapped here
    }
}
Enter fullscreen mode Exit fullscreen mode

The method body is the same code either way. That's the whole benefit: the decision is visible in one file per domain, and a method cannot quietly acquire or lose transactionality by someone editing its body.

The bodies themselves aren't in the domain at all. The RPC flow lives in one of 20 generic methods, each with a single Handle(ctx, req). What the domain's handler package contains is assembly: a type with a deps field, a constructor, and a Call() that returns the lambda the transport wires in.

  ONE DOMAIN, SIX PLACES โ€” and the column that says what varies

  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚ entity          Order                                    โ”‚ โ”‚  [T]  โ”‚
  โ”‚ manager         Manager[E, C, U]      writes             โ”‚ โ”‚  [T]  โ”‚
  โ”‚ repository      Reader[In, Out]       reads              โ”‚ โ”‚  [T]  โ”‚
  โ”‚ query builder   Select[Row]()         declarative        โ”‚ โ”‚  [Q]  โ”‚
  โ”‚ processors      Processor[Q]          plugged in         โ”‚ โ”‚  [Q]  โ”‚
  โ”‚ handler         Call()                transactionality   โ”‚ โ”‚  [T]  โ”‚
  โ”‚                                       declared, not      โ”‚ โ”‚       โ”‚
  โ”‚                                       implemented        โ”‚ โ”‚       โ”‚
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

  [T]  the domain's entity โ€” the type parameter
  [Q]  the query โ€” generic over the query, not over the domain
Enter fullscreen mode Exit fullscreen mode

A vertical stack of six labelled bands for one domain - entity, manager, repository, query builder, processors, handler - with a narrow column down the right-hand side marked type parameter, filled at every band except the query builder and processors, which are marked generic over the query instead

How I test a core that everything depends on

A generic core is load-bearing by definition, so the test shape changes with it. Three things I do that I didn't do when the code was forty-four copies.

Test the core once, across instantiations. The reader's test doesn't test orders. It instantiates the reader over two deliberately different types - one with a nullable column, one without - and runs the same table of cases: zero rows, one row, many rows, a scan failure mid-iteration, an iteration error after the last row, a cancelled context. Each of those cases used to be untested in forty-three places and tested in one.

Assert the failure paths that the copies used to get wrong, and assert them by identity rather than by string:

_, err := reader.Query(ctx, filter)
require.ErrorIs(t, err, rows.ErrIterate)
Enter fullscreen mode Exit fullscreen mode

Hold the invariant with a test, not with a review comment. The closing condition for the whole forty-four migration is mechanical: for rows.Next(), rows.Err(), rows.Close() and QueryRow( may appear in exactly one package in the tree. That's a test that walks the source and fails the build if a forty-fifth copy appears - which is the only form of "we agreed not to do that" that survives a busy week.

And because the core is on every read path, it carries the benchmark weight too: allocations per row are a property of one function now, so measuring it is worth doing and comparing it release to release is meaningful.

  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚ 1  one table of cases, run across TWO instantiations           โ”‚
  โ”‚    zero rows ยท one row ยท many rows ยท a scan failure mid-loop ยท โ”‚
  โ”‚    an iteration error after the last row ยท a cancelled context โ”‚
  โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
  โ”‚ 2  assert the failure by IDENTITY, not by string               โ”‚
  โ”‚    require.ErrorIs(t, err, rows.ErrIterate)                    โ”‚
  โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
  โ”‚ 3  a guard test walks the source and fails the build           โ”‚
  โ”‚    for rows.Next() ยท rows.Err() ยท rows.Close() ยท QueryRow(     โ”‚
  โ”‚    allowed in EXACTLY ONE package                              โ”‚
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                 โ”‚
                                 โ–ผ
                        one generic core

  the same table used to be untested in forty-three places
Enter fullscreen mode Exit fullscreen mode

Three test bands over one generic core - a table of cases run across two instantiations, error identity assertions against sentinels, and a guard test that allows the row-loop primitives in exactly one package - with a note that the same table used to be untested in forty-three places

Why the type parameter is the right seam

Everything above only works because of a layout decision I made earlier, and the two are the same idea seen twice.

Packages are laid out by component type first, with the domain as a nested package:

internal/repository/entity
internal/repository/order
internal/manager/entity
internal/manager/order
internal/model/entity
internal/model/order
Enter fullscreen mode Exit fullscreen mode

Not the inverse - internal/domain/<area>/repository, one package per business area holding every role.

Put the two layouts next to each other and ask what stays the same as you move from entity to order. In the type-first layout, the answer is: everything except the type. The read path for orders is the read path for entities with Order substituted. There is a single thing that varies, it has a name, and the language now has a way to spell it.

Between two domains' read paths Differs?
the loop that walks the rows no
the error wrapping and its wording no
cursor handling and page clamping no
how a filter becomes SQL no
the table, columns and their scan targets yes - the spec
the entity type yes - the type parameter

Two rows on the right. One of them is data - a spec the domain hands over. The other is the parameter. Everything else is one core with N instantiations.

The rule I hold myself to is one line: if two things differ only in a type parameter, they are one generic. A domain-local copy sitting next to an existing generic is forbidden, and that ban is held by the guard test above rather than by review etiquette.

The practical consequence is the one I care about: adding a domain is declaring a type, not writing a package. It's a spec, a handful of files that carry genuinely domain-specific rules, and instantiations of everything else.

The effect is visible in the numbers

None of this is a claim about elegance. Over the tag history of the service, generated code excluded:

Tag Code Tests Tests/code Files Packages Average file
v0.4.0 28095 31952 1.14 640 85 43
v1.0.0 25097 35368 1.41 683 94 36
v1.1.0 29443 36272 1.23 804 109 36

At v1.0.0, lines of code dropped by roughly three thousand while test lines grew - that step is repeated domain files going into generic cores. The tests-to-code ratio went 1.14 โ†’ 1.41 and hasn't been below 1.14 since.

The other number worth reading: the service is 2733 files across 252 packages, averaging 39 lines. It grows by files and packages, not by files getting fatter - which is what you'd expect when the shared behaviour has somewhere to live.

  each โ–ˆ = 1000 lines above a 20000 baseline

            v0.4.0            v1.0.0            v1.1.0
  tests     โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ      โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ   โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ
            31952             35368             36272
  code      โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ          โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ             โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ
            28095             25097             29443
                              โ–ฒ
                              โ””โ”€โ”€ the dip: 28095 โ”€โ”€โ–ถ 25097

  tests/code   1.14   โ”€โ”€โ–ถ   1.41   โ”€โ”€โ–ถ   1.23

  the dip is repeated domain files going into generic cores
Enter fullscreen mode Exit fullscreen mode

A small line chart of lines of code across four tags with a visible dip at the third point, annotated twenty-eight thousand down to twenty-five thousand, and a second line for tests continuing upward through the dip

The honest cost

Generics are not free, and I'd rather list the bill than pretend the migration was a clean win.

Signatures get harder to read. Reader[In, Out] is fine. Spec[H, P, V] is three single letters that mean header, payload and version, and you have to know that. A generic core's signature is a compressed description of a design - which is efficient once you hold the design, and opaque before you do. New readers hit it before they hit anything friendly.

Error messages get worse. When an instantiation doesn't satisfy a constraint, the compiler points at the core, not at the line you wrote. You get told something about a type set, in terms of the core's parameter names, and you have to walk back to your own call site yourself. In the concrete, copy-per-type world, the error was on the line with the mistake.

Constraints leak into every caller. This is the one I underestimated. The moment a core takes [In, Out any], every wrapper around it takes them too, and every wrapper around that. Type parameters propagate outward through signatures until something concrete stops them. Pick the stopping point deliberately - mine is the domain's assembly file, where Domain[Repo, Mgr] becomes a plain Domain - or you'll find the parameters have reached the transport layer, and a gRPC handler is arguing about type sets.

  type parameters propagate OUTWARD until something concrete stops them

  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚  transport โ€” concrete: no type parameter reaches here        โ”‚
  โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚
  โ”‚  โ”‚  domain assembly   Domain[Repo, Mgr]  โ”€โ”€โ–ถ  Domain      โ”‚  โ”‚
  โ”‚  โ”‚  the deliberate stop โ€” the parameters end here         โ”‚  โ”‚
  โ”‚  โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚  โ”‚
  โ”‚  โ”‚  โ”‚  wrapper   Manager[E, C, U]                      โ”‚  โ”‚  โ”‚
  โ”‚  โ”‚  โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚  โ”‚  โ”‚
  โ”‚  โ”‚  โ”‚  โ”‚  core      Reader[In, Out]                 โ”‚  โ”‚  โ”‚  โ”‚
  โ”‚  โ”‚  โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚  โ”‚  โ”‚
  โ”‚  โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚  โ”‚
  โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
Enter fullscreen mode Exit fullscreen mode

Type parameters spreading outward through four rings of signatures - core, wrapper, domain assembly, transport - with a deliberate stop marked at the domain assembly ring and the transport ring left concrete

The core becomes load-bearing, and therefore risky. Forty-four call sites depending on one Query method is exactly the deduplication I wanted, and it also means one careless change there is a service-wide change. The copies had a property the core doesn't: a bug in one of them was a bug in one place. What I trade that for is that a fix is also in one place - but the blast radius is real and it's the reason the core carries disproportionate test weight.

Instantiation isn't free at build time. The compiler does real work per instantiation, and a core with three parameters used across a few dozen domains is measurably more to compile than the concrete code it replaced. It hasn't been a problem at my size. It's a line in the bill, not a footnote.

And the temptation. The failure mode of generics isn't writing too few, it's making one abstraction serve two things that were never the same. The tell is specific and I've learned to watch for it: a type parameter plus a boolean flag, or a type parameter plus a switch inside the core. That combination means the two instantiations wanted different behaviour and I forced them through one door. Two clear functions would have been better than one parameterised one with a mode.

When I don't reach for a type parameter

The list I actually use, in the order the questions come up:

  • The things differ in behaviour, not only in type. That's an interface. A constraint whose methods each implementation fills in differently is polymorphism wearing a type parameter's clothes.
  • There is exactly one instantiation and no second one in sight. A concrete type reads better, and the generic can be extracted the day the second one arrives - it's a five-minute refactor, not an architectural decision.
  • The constraint would have to enumerate half the language. If pinning down what the parameter can do takes longer than writing both versions, the abstraction isn't there yet.
  • It exists to save typing rather than to remove a decision. Deduplicating twelve identical error strings removes a decision - which wording is right. Deduplicating two four-line functions saves keystrokes and costs a reader.
  • The parameter is at the wrong level. Generic over one operation is usually right. Generic over an entire service is usually someone building a framework by accident.
  • It's the second abstraction over the same thing. A new generic next to an existing generic that nearly fits is the copy-paste problem again, one level up.
  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚ REACH FOR IT                     โ”‚ โ”‚ DON'T                            โ”‚
  โ”‚                                  โ”‚ โ”‚                                  โ”‚
  โ”‚ differs only in the type         โ”‚ โ”‚ differs in behaviour             โ”‚
  โ”‚ the same shape three or more     โ”‚ โ”‚ exactly one instantiation        โ”‚
  โ”‚ times                            โ”‚ โ”‚ a constraint longer than the     โ”‚
  โ”‚ one wording for one failure      โ”‚ โ”‚ code                             โ”‚
  โ”‚                                  โ”‚ โ”‚ it saves typing, not a decision  โ”‚
  โ”‚                                  โ”‚ โ”‚ the parameter is at the wrong    โ”‚
  โ”‚                                  โ”‚ โ”‚ level                            โ”‚
  โ”‚                                  โ”‚ โ”‚ a second abstraction over the    โ”‚
  โ”‚                                  โ”‚ โ”‚ same thing                       โ”‚
  โ”‚                                  โ”‚ โ”‚                                  โ”‚
  โ”‚                                  โ”‚ โ”‚ [T] + bool                       โ”‚
  โ”‚                                  โ”‚ โ”‚ two instantiations wanted        โ”‚
  โ”‚                                  โ”‚ โ”‚ different behaviour, forced      โ”‚
  โ”‚                                  โ”‚ โ”‚ through one door                 โ”‚
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
Enter fullscreen mode Exit fullscreen mode

Two columns headed reach for it and do not; the left lists differs only in the type, the same shape three or more times, one wording for one failure; the right lists differs in behaviour, one instantiation, a constraint longer than the code, a type parameter next to a boolean flag - the last item marked in the broken accent colour

The one conclusion

The type parameter didn't make me a better designer. It moved a specific, narrow class of checking - is this the type I said it was - out of review and tests and into the compiler, and it charged me harder-to-read signatures and a load-bearing core for it.

That's a good trade on repetitive, shape-heavy code with a rigid layout, which is exactly the code I write now. It's a bad trade the moment the things I'm unifying differ in anything other than the type - and the years in PHP, where I had to solve this without the tool at all, are what taught me to tell those two cases apart.


That's my experience and my price for it. If you do this better, if you've already been through it, or if you look at it differently - I'd like to hear how it's solved on your side, and what broke when you tried.

Top comments (0)