DEV Community

Anton Brilliantov
Anton Brilliantov

Posted on

Every Service Should Be Copy-Paste

Sameness beats elegance when a machine has to reproduce the layout.


πŸ‘‹ I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This is the first part of a series about the platform, the service template and what can be generated instead of typed. It starts with the least glamorous decision I've made: every service is laid out identically, on purpose. Notes: github.com/brilliant-almazov.

Maybe it's useful to you. Maybe you look at this differently - that's a conversation I'd take.


The thesis

A service should read like a copy of the previous one. The value isn't that any single arrangement
is beautiful; it's that a reader knows where the next line lives without opening the file. Once
that's true, the layout stops being taste and becomes a property you can check - and, later, one a
program can reproduce. Variety has a price, and it isn't paid once: it's paid by every service
after the first, and by every domain inside them.

Everything below is from one service I keep the samples from, measured on 2026-08-16. Domains are
renamed to neutral entity / order; the shapes and the numbers are real.

Layout is a rule, not taste

Packages are laid out by component type first, with the domain as a nested package. This is
the actual tree, not a simplified excerpt of it:

internal/repository/entity           read only
internal/repository/entity/methods   one method = one file = one type with Execute
internal/manager/entity              write only, including find-or-create
internal/manager/entity/methods
internal/model/entity
internal/grpc/codec/entity           + /methods
internal/grpc/entity                 entity-first: one folder per entity…
internal/grpc/entity/create.go       …and one file per method inside it
internal/grpc/service/report         service-first: one folder per gRPC service…
internal/grpc/service/report/build.go   …and one file per method, same shape
internal/grpc/method                 20 generic RPCs - the method bodies live here
internal/daemon/server/entity        assembles the domain's Calls
internal/daemon/worker/...           relay, retention, derived, observe
internal/publisher/entity, internal/consumer/entity
internal/job/<name>, internal/recorder/<name>
internal/port                        cross-cutting ports, one interface per file
internal/testsupport/...             stands, no asserts
<package>/test/<concern>/            package <concern>test, black box only
Enter fullscreen mode Exit fullscreen mode

Substitute order for entity and that is the next entity - in this service and in the next one.
The inverse (internal/domain/<domain>/repository, internal/domain/<domain>/manager, one package
per business area holding every role) is not allowed in my codebase. Both layouts are
navigable; they differ in what stays the same when you open the next service. I put the
domain-first model on the table properly a few paragraphs down, with what it wins as well as what
it costs.

Two caveats before the picture, because the tree above is not four clean rows. The transport
layer has two shapes on purpose
- grpc/<entity>/<method>.go for operations on one entity,
grpc/service/<service>/<method>.go for compositions that aren't about one entity - and both live
in the same service. Some component types need a grouping level before the entity (grpc/codec,
daemon/server, daemon/worker), and one of them holds no entity at all: grpc/method is where
the 20 generic RPC bodies live. Type-first is the rule; these are the places the rule has more than
two levels, and pretending otherwise would make the rule look tidier than it is.

  type first  (what I use)          domain first  (also works)
  ──────────────────────────────    ──────────────────────────────
  internal/                         internal/
  β”œβ”€β”€ repository/entity             └── domain/ordering/
  β”œβ”€β”€ manager/entity                    β”œβ”€β”€ model
  β”œβ”€β”€ model/entity                      β”œβ”€β”€ repository
  β”œβ”€β”€ grpc/entity/create.go             β”œβ”€β”€ manager
  β”œβ”€β”€ grpc/service/report/build.go      └── grpc/create.go
  └── grpc/method                   internal/domain/billing/…

  + one shape in every service      + one business area, one folder
  - one feature, four directories   - the shape moves service to service
Enter fullscreen mode Exit fullscreen mode

Two directory trees side by side: on the left the type-first layout - repository, manager and model each holding an entity package, two transport shapes, and one transport type that holds no entity at all; on the right the domain-first alternative, where the top level is a business area such as ordering and the roles sit inside it, with a one-line trade-off under each

The path is a chain, not two levels

"Type first, domain nested" is the short version, and it undersells the rule. The path is a chain,
and only its two ends are fixed:

internal/ β†’ component type β†’ (whatever grouping that type needs) β†’ entity β†’ the entity's own pieces
Enter fullscreen mode Exit fullscreen mode

The type comes first, the entity comes last, and how many segments sit between them is
whatever that type needs. Some types need nothing in the middle. The transport types need one
level, for the entity, the service or the resource they serve. And the entity folder is not a leaf
either -
it holds the entity's own pieces, which sit next to each other because they belong to the same
thing:

internal/repository/entity/methods/search.go      read side: one method, one file
internal/manager/entity/methods/create.go         write side, the same shape
internal/manager/entity/processor/normalize.go    the entity's own processors
internal/manager/entity/mapper/row.go             and its own mappers, right beside them
internal/model/entity/patch.go
internal/grpc/entity/search.go                    entity-first: grpc β†’ entity β†’ method
internal/grpc/service/report/build.go             service-first: grpc β†’ service β†’ method
internal/http/entity/list.go                      http β†’ resource β†’ route
internal/http/entity/create.go                    handlers are not a special case
internal/publisher/entity, internal/consumer/entity
internal/daemon/server/entity                     the assembly of the same entity
Enter fullscreen mode Exit fullscreen mode

Two things follow from that, and both are worth saying plainly.

The list of component types is open. It's plus or minus the set above - repository,
manager, model, grpc, http, publisher, consumer, daemon, job, recorder, port
happen to be the ones my services have. A service that genuinely has another kind of component
adds it as a new top-level type rather than smuggling it into an existing one, and a service that
has no HTTP surface simply has no internal/http. Some types aren't per-entity at all (job,
recorder) and name their folder after the thing itself; that's fine - the chain says the last
segment is the unit, not that the unit is always an entity.

The invariant is the ends, not the depth. Nothing here says "exactly two segments". It says
the first segment answers what kind of component this is and the last answers what it is
about
- and everything in between exists because that particular type needed it. That's what
makes the shape describable in one sentence and still able to hold a transport layer, a processor
and a mapper without special cases.

On the gRPC side there are two nestings, and both are right

The transport types are where the middle of the chain earns its keep - and it's the one place where
I deliberately don't have a single answer. There are two shapes. They exist for two different
kinds of call, and they sit side by side in the same service.

Entity-first - one folder per entity, one file per method inside it - for the calls that are
about a thing: create it, read it, search it, update it.

internal/grpc/entity/create.go
internal/grpc/entity/search.go
internal/grpc/order/create.go        the next entity, same two levels
Enter fullscreen mode Exit fullscreen mode

Service-first - a folder named literally after the gRPC service, one file per method inside it -
for the calls that are about a composition: an operation that orchestrates several things and
doesn't belong to any single entity.

internal/grpc/service/report/build.go
internal/grpc/service/report/export.go
internal/grpc/service/transfer/run.go
Enter fullscreen mode Exit fullscreen mode
  entity-first                      service-first
  "I want to create an entity"      "I want that composite operation"
  ────────────────────────────      ────────────────────────────────
  internal/grpc/                    internal/grpc/service/
  └── entity/                       └── report/
      β”œβ”€β”€ create.go                     β”œβ”€β”€ build.go
      └── search.go                     └── export.go

  both shapes live in one service; the path says which one you are in
Enter fullscreen mode Exit fullscreen mode

Two directory trees side by side under the heading two gRPC nestings: on the left entity-first, internal slash grpc slash entity with create and search files, reason I want to create an entity; on the right service-first, internal slash grpc slash service slash report with build and export files, reason I want that composite operation; a line underneath saying both live in one service

What stays fixed across both is the pair of units underneath. A method is the unit that changes:
a change request is never "touch the handler package", it's "Search now takes a cursor" - so the
method gets its own file, and a diff that touches Search touches exactly search.go. A service
is the unit that shares a contract: the files in one folder are precisely the RPCs declared in one
.proto service block, so the folder listing and the contract can be read against each other line
by line, and a method that exists in one and not the other is visible without a tool. Anything
flatter - grpc/handler/<domain> with the methods mixed inside - loses both properties in either
shape: the file boundary no longer matches the change boundary, and the folder no longer matches the
contract.

Both in one codebase is a fit, not a compromise. The two shapes cover different kinds of
operation, so a service that carries both isn't being inconsistent - it's being specific. The
invariant that has to hold is the copy-paste one: whichever shape a given call belongs to, it looks
the same in every service, and a reader can tell from the path alone which of the two they're in.

The deciding criterion is human perception, not purity - and this is my reading of it rather
than a law. The split is binary in the reader's head: "I want to create an entity" is one thought,
"I want that composite operation" is a different one, and I'd rather the tree match that split than
force every call into one tree for the sake of a tidier rule. Picking a single shape and using it
everywhere is defensible in both directions - all-entity-first keeps the tree flat and the domain
obvious, all-service-first keeps the folder listing and the .proto in exact correspondence
everywhere. I find the two-shape version easier to navigate because the path answers a question I
actually ask, but I wouldn't argue anyone out of the other two.

The bodies aren't there, though. The RPC flow lives in one of the 20 generic methods, which has a
single execution method, Handle(ctx, req). What either folder contains is assembly only: a
type with a deps field, a NewXxx(deps) constructor, and a Call() that returns the lambda the
transport wires in. Five method files per service, and not one hand-written body among them.

The practical consequence is that reviewing a new domain's transport layer is not reading logic.
It's checking that five files were filled in with the right names - which is exactly the kind of
work worth taking away from a person.

One method, one file, one type

Under repository/<domain>/methods and manager/<domain>/methods the unit is not a function but a
type: one method per file, one type per method, with an Execute on it. So "where does this query
live" has a mechanical answer - a path - rather than a search.

A concern is a folder, never a filename prefix

This is the rule I'd keep if I had to drop all the others:

internal/manager/entity/entity_create.go       βœ— concern as a filename prefix
internal/manager/entity/create_helpers.go      βœ— helpers file
internal/manager/entity/methods/create.go      βœ“ concern as a folder
Enter fullscreen mode Exit fullscreen mode

In the root of a package there are only contracts and Base; the implementation of a concern lives
in that concern's subfolder, and in methods/ there are only methods. The moment a concern is
allowed to be a filename prefix, the tree stops being predictable and becomes a naming convention
people remember with varying accuracy.

The other model, and it's a real one

Type-first is not the only layout that works, and the honest way to argue for it is to describe the
main alternative the way someone who likes it would.

First, a distinction that gets lost every time this argument is had: a domain is not an entity.
order is an entity - a thing with a table, an id and a lifecycle. ordering is a domain - a
business area that owns order, order_line and discount, plus the rules that only make sense
across all three. Putting one entity at the top of the tree isn't domain-first; it's entity-first
with the roles turned inside out, and it inherits the costs of both. Domain-first means the tree's
top level is the business area, and the entity is a level underneath it:

internal/domain/ordering/                 the business area, not a table
internal/domain/ordering/model            order, order_line, discount
internal/domain/ordering/repository       reads for everything in the domain
internal/domain/ordering/manager          writes for everything in the domain
internal/domain/ordering/grpc/create.go   the domain's transport
internal/domain/billing/…                 the next area, same five rows
Enter fullscreen mode Exit fullscreen mode

That distinction is what makes the alternative worth taking seriously. A tree whose top level is
order, order_line, discount is just a flat list of tables with folders around them. A tree
whose top level is ordering, billing, catalog is a map of what the service is for, and
that's a real claim about readability, not a stylistic one.

What that layout is genuinely good at:

  • Everything about one domain sits in one place. Adding a field is one directory, one listing, one mental context. Nothing about ordering is anywhere except under ordering - including the three entities it owns.
  • A domain lifts out cleanly. If ordering later has to become its own service, the seam is already cut - the move is close to a directory copy instead of an archaeology exercise.
  • It matches the way people talk. Nobody says "I'm in the manager layer today"; they say "I'm on ordering". A tree that agrees with that sentence is easier to hold in your head, and easier to explain to somebody on their first day - and it names areas of the business, not tables.
  • It scales down well. In a service with two domains and a handful of files each, type-first is ceremony wrapped around almost nothing, and domain-first is just the shorter path.
  • It keeps a change local. One feature usually lands inside one folder rather than across four.

What it costs, in the situation I'm actually in:

  • The same file lands in a different place in each service. Nothing in domain-first says where the write path goes, so one service grows internal/domain/ordering/manager, the next one internal/domain/ordering/service, the third puts the writes in the repository. Each is defensible on its own; together they're a fleet with no shared map.
  • The shape stops being checkable. With roles at the top I can state a rule a program can verify - "repository reads, manager writes, for rows.Next() appears in exactly one package". With the domain at the top the same rule has to be restated per domain per service, which in practice means it's restated nowhere.
  • A reader has to learn each service, not just the first. The cost isn't paid once; it's paid by every service after the first, and by every new person on every one of them.
  • There's nothing to generate against. A generator places files by rule. If the rule is "the way this service's author preferred", there's no rule to place by and no baseline to diff drift against.

Neither column is a knockout. The choice comes down to which cost you actually pay: if you have one
service and a small team, the top list is real and the bottom list is theoretical, and domain-first
is the better answer. I have services that are supposed to be copies of each other and a generator
that has to place files by rule, so for me the bottom list is the one that bites - and I pay the
top one, including the part where following one feature means opening four directories.

This is my structure, not a rule for anyone else

I want to be blunt about the status of all of this: it's the layout I picked, for my services,
and the reason is readability in one narrow sense - I can find the next line without opening the
file. That's the whole justification. It isn't a standard, I'm not proposing it as one, and a tree
that looks nothing like mine can be the better tree in your codebase.

So lay your services out however you like. The property I'd actually argue for is the one
underneath the shape rather than the shape itself: a service should be a copy of the previous
one.
Choose type-first, choose domain-first, choose something I haven't thought of - as long as
the second service is the first one with the names changed, you get the thing that matters here.
The tree above is my answer; the sameness is the point.

Exactly two layers over the database

There are two layers that touch the database, and there is no third:

  • repository - read only.
  • manager - write only, including find-or-create.

Types and packages named store, writer, dao or service for database access do not exist. If
a Store shows up in a diff, that's the error, not a naming preference - it gets split into a
repository and a manager. When a manager needs to read, it calls the repository instead of
duplicating the SQL, which is what keeps "where is this query" answerable at all.

  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚ manager                  β”‚  needs to read  β”‚ repository           β”‚
  β”‚ write only               β”‚ ──────────────▢ β”‚ read only            β”‚
  β”‚ incl. find-or-create     β”‚                 β”‚                      β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

  there is no third layer:  store Β· writer Β· dao Β· service
Enter fullscreen mode Exit fullscreen mode

Two boxes, repository read only and manager write only, with a one-way arrow from manager to repository labelled needs to read, calls it; beside them a struck-through list of store, writer, dao, service

Transactions are a decorator, so the domain never knows

Transactions sit outside that split: 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.

One package in my own service didn't follow it: a repository running its own Begin by hand with
pgx.Tx as a struct field, straight past the platform's transaction registry. Nothing broke and no
test went red. It was just a different shape from everything around it - which is the exact failure
mode the rule exists to make visible. In a codebase where every repository is allowed to look
different, that one is invisible; in this one, it stands out in a directory listing.

The rest of the mechanical rules

A layout rule that runs on good intentions decays. These are the constraints that keep it
mechanical, and each one exists because its opposite produces variety:

Rule What it removes
a file is at most 100 lines, a line at most 80 characters files that grow into their own little architectures
no comments in .go files prose drifting away from the code it describes
helpers.go, utils.go, common.go, funcs.go are forbidden the junk drawer every service otherwise grows
every operation is a method on a struct package-level procedures nobody can locate
all SQL goes through a query builder; raw SQL in Go is forbidden (only migration DDL and test fixtures are exempt) the same query hand-written twice, differently
errors are matched with errors.Is / errors.As only direct comparisons that silently stop matching once a wrap is added
no switch on types in shared code - polymorphism instead a routing table that grows a case per domain
initialisms read as ordinary words (HttpClient, JsonBody); only ID is uppercase two spellings of the same field name

None of these are interesting on their own. Together they're the difference between a layout you
can describe and a layout a program can reproduce.

Two binaries, one image - and the layout mirrors it

The service ships as one image with two binaries selected at build time
(--build-arg BINARY=): server, which serves gRPC, and worker, which relays outgoing events,
runs background calculations and drops old audit partitions. The tree says so out loud:

internal/daemon/server/entity      assembly of the domain's Calls
internal/daemon/worker/relay       outgoing events
internal/daemon/worker/retention   audit partitions
internal/daemon/worker/derived     background calculations
internal/daemon/worker/observe     gauges
Enter fullscreen mode Exit fullscreen mode

The count of binaries is a decision of the service owner and nothing else: new background work goes
into an existing daemon, not into a new cmd/*. That's another sameness rule that reads as
bureaucracy until you picture the alternative - a fleet where every service has a different number
of things to deploy.

Generic cores: why there's so little left to copy

Sameness is what makes deduplication possible, and deduplication is what keeps the sameness cheap.
Most behaviour lives in generic cores that every domain instantiates rather than reimplements.

The RPC flow, for instance, is 20 generic methods - and that list is the service's write and read
surface:

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

Around them:

Core What it gives every domain
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 form for the whole service
row reading set, counter, existence probe, Reader with collectors
pagination keyset cursor over int64 plus a page-size clamp
domain assembly Builder[Repo, Mgr], Domain[Repo, Mgr], New, Input, ManagerInput[Repo], HandlerInput[Repo, Mgr]
task runs Runner[T], serial and parallel
validation 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 TenantRig, Spec, StubManager, StubRepository, Port, NewItem, NewPatch, KeepData

The rule in 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 tooling
checks and forbidding tests rather than by review etiquette.

What variety actually costs: 44 copies of four loops

The best argument for sameness I have isn't the tree - it's an audit I ran over my own service on
2026-08-13, hunting 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

They differed in exactly two things: the result type and the wording of the error wrapper - and 12
of them differed only in the wording. A type parameter and a dependency: that is a generic, typed
out by hand 44 times.

The part that stings is that the correct form was already in the tree: Reader[In, Out], with
executor, statement and scanner fields and a Query(ctx, in) method. It was just sitting
inside one domain's package instead of a shared one, so nobody reusing it was possible. Collapsing
the 44 copies onto it took a set of 40 iterations, one file each, and the closing condition is a
forbidding test rather than a promise: for rows.Next(), rows.Err(), rows.Close() and
QueryRow( may appear in exactly one package in the whole tree.

The same audit found the rest of the drift

Once you go looking for hand-written versions of things that already exist, they come in a batch.
From the same pass, each one a small deviation in shape that nothing was failing on:

What was there What it should have been
three hand-rolled caches: map + RWMutex, no TTL, no capacity, no metrics one cache with TTL, capacity and metrics
message dedup as a hand-written ring of 1024 ids the same cache, with a TTL
bulk insert as one INSERT per row a multi-row VALUES
two different loggers in two packages of one service one
the retention schedule hard-wired as a constant a config value
a lazy scheduler init on sync.Once with captured variables the platform's init-state storage
a cache-invalidation consumer written but registered in no daemon registered, or deleted

That last one is the sharpest. The package compiled, its unit tests were green, and invalidation
simply did not run in production, because nothing anywhere said "a consumer must be mounted
somewhere". Sameness is not an aesthetic in that story - it's the only reason the gap is findable
by looking rather than by an incident.

The numbers

None of this is a declaration. As of 2026-08-16, in the service I take these samples from:

.go files (excluding generated) 2733
packages 252
average file 39 lines
files over 100 lines 44 - of which 2 are non-test (105 and 102 lines)
migration files / lines 66 / 1990
line coverage 86.7%

Four rows of numbers: files 2733, packages 252, average file 39 lines, files over 100 lines 44 of which 2 non-test

The interesting one is the average, and it's clearer over the tag history (generated code excluded
throughout):

Tag Code Tests Tests/code Files Packages Average file
v0.1.0 1751 781 0.45 35 13 50
v0.2.0 5054 2817 0.56 101 20 50
v0.3.0 22765 26924 1.18 520 74 43
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
v1.2.0 34523 39861 1.15 933 119 37
v1.3.0 42461 49564 1.17 1116 135 38
v1.4.0 50755 62986 1.24 1300 175 39
v1.5.0 55979 68437 1.22 1403 187 39
v1.8.0 58372 72758 1.25 1476 205 39
v1.10.0 60019 74701 1.24 1514 232 39
HEAD 61411 76468 1.25 1540 252 39

Three things I read off that table:

  • The service grows by files and packages, not by files getting fatter. 13 β†’ 252 packages while the average file went 50 β†’ 39 lines and then stopped moving. 2733 files averaging 39 lines is a different codebase from 700 files averaging 150, even at the same total size.
  • Deduplication is visible as a dip. At v1.0.0 lines of code dropped from 28095 to 25097 while test lines grew: repeated domain files going into generic cores. The tests-to-code ratio went 1.14 β†’ 1.41 in that step, and hasn't gone below 1.14 since.
  • New cores arrive as packages, not as sprawl. The latest release increment - the row-reading core above, together with an SQL catalog engine - was +1392 lines of code, +1767 lines of tests and +20 packages.

What this is actually for

The honest reason isn't readability. It's that a formalised layout is the only thing that makes
generation possible.

A generator doesn't guess style; it places files by rule. As long as the layout is "whatever the
author preferred", there is nothing to generate - and nothing to check drift against. You can't
diff a tree against a convention that exists only in people's heads.

The census: how much of a domain is shape

Writing the spec for a skeleton generator (12 iterations, specified and not built yet - I'm
describing designed work, not a shipped tool) forced me to inventory a domain file by file. That
inventory is the most concrete answer to "how copy-paste is it, really":

Layer Fully mechanical Hand-written body
domain gRPC methods (internal/grpc/<entity>/, or internal/grpc/service/<service>/) all 5 files none
domain codecs codec.go, mapping.go, request.go, request_patch.go, suite.go, base.go payload.go, filter.go, errors.go
domain repository calls.go, repository.go, revision.go, deps.go, methods/search.go, methods/find_all_by_ids.go spec.go, methods/header.go, methods/code_taken.go, methods/slug_taken.go
domain manager calls.go, deps.go, manager.go, methods/{create,update,correct,archive}.go spec.go
domain model 9 files 6 files
domain assembly in the daemon all 4 files none
domain test stand all 3 files none
migrations all none

Read the right-hand column top to bottom and you have the whole of what a new domain actually
writes itself: two spec.go files, three codec files, three repository methods carrying its own key
rules, and 6 of its 15 model files. That list is the domain. Everything else is the shape -
which is another way of saying the copy-paste was already happening, and formalising the layout
only makes it honest.

The generator's form follows from the same idea

Two things about the design are worth naming, because both are consequences of sameness rather than
preferences:

  • It's a library, not a script: Generate(cfg) (Result, error) and CheckDrift(cfg) ([]string, error), with a thin CLI over them. Drift-checking is the same code path as generating, so the two can't disagree.
  • It renders by concatenating strings - prepared constants, a string builder, token substitution ($domain$, $Domain$, $plural$) - and templating engines are explicitly forbidden. Every .go output goes through the formatter, so import grouping and alignment are not the renderer's problem.

The input is deliberately double, because neither source describes a domain on its own: a YAML
description (names and plural, Go types, tenant-scope flag, slug and batch flags, header and
revision tables, payload columns with SQL type, nullability and comment, event types, error
sentinels, first migration number) plus the .proto descriptor (service name, RPCs with request
and response types, entity and batch messages).

Files that need a hand-written body still get generated - correct package, imports and signatures,
bodies marked with a panic stub - and a second run without -force won't overwrite them. Generated
files carry a Code generated by … DO NOT EDIT. header, which is a contract: editing one by hand is
forbidden, and a check mode compares what would be generated against what's in the repository and
exits non-zero on a difference.

And what the generator deliberately doesn't touch: the generic cores above, and one-off pieces like
publishers or the invalidation consumer. They aren't "per domain", so they aren't a unit of
generation.

Even the migrations have one shape

Schema work is where "each author their own way" usually wins, so it gets the same treatment: goose,
embedded, forward-only (-- +goose Up, no Down), file names as NNN_description.sql, one
table per migration, a rollback expressed as a new migration forward. COMMENT ON COLUMN on
every column. VARCHAR rather than TEXT. A migration file that has already shipped is
immutable.

66 files, 1990 lines, and the reason they're the one layer the generator can emit in full: there is
nothing in them that is a matter of judgement about form.

Where the tests live

The layout rule applies to tests too, and this is the part that surprises people most: tests are not
next to the code. They live in <package>/test/<concern>/ as package <concern>test, black-box
only - the package under test is imported from outside. Stands and fixtures live in a shared
support tree with no asserts, take testing.TB as the first parameter, call t.Helper() on the
first line, and release resources through t.Cleanup. Golden files go in testdata/ next to the
test that reads them.

The price is real: longer paths, no access to unexported identifiers, and some tooling expects tests
to sit beside the code. What it buys is that a test file has exactly one possible location, and a
"test helper" can't quietly become a second implementation of the thing it helps.

What makes it stick: checks, not promises

Every rule above is either enforced by something mechanical or slowly decaying. The escalation I've
converged on is reminder β†’ rule β†’ check, and only the last one holds:

Instead of a rule The check that replaces it
"don't hand-write row loops" a forbidding test: for rows.Next() appears in exactly one package
"keep the generated tree in sync" a check mode that diffs generated output against the repository and exits non-zero
"don't let coverage slide" a ratchet: the threshold can only go up, and an attempt to lower it fails the PR
"don't use an empty context in tests" a tool hook that blocks the write
"don't leave open questions in a task spec" a tool hook that blocks the write when a forbidden phrase appears

Two honest footnotes. The coverage ratchet currently sits at 0 - the mechanism is in place, the
bar hasn't been raised yet, while actual coverage is 86.7%. And the empty-context rule exists
because the scale of that particular drift was 421 empty-context calls across 184 test files;
that is what a convention nobody checks looks like after a few months.

What it costs

  • It's unfamiliar: people arrive looking for internal/domain/<area>/ and it isn't there.
  • It's boring. Nothing to argue about, and an author's taste never lands in the layout. Some engineers experience that as a loss.
  • Following one feature means jumping between directories - repository, manager, model, the gRPC folder, codec, plus the daemon assembly, instead of one folder. That is exactly the thing domain-first gets right, and I give it up on purpose.
  • There are a lot of files (2733), and search-by-name matters more than scrolling one.
  • Tests are not next to the code, which costs unexported access and some tooling friction.
  • The rules only hold while the checks do. Every one of them is work to build and work to keep green.

Those are real costs, and they're the price of a choice rather than the proof of a law. I pay them
because in my situation the other bill - a fleet of services each with its own shape - is larger,
and it comes again on every service and every domain inside it. Your bill may be arranged
differently.

The one conclusion

Predictability beats elegance exactly when the code has to be reproduced by a machine rather than
rewritten by a person. Which shape you make predictable is yours to pick - mine is type-first
because I can find the next line without opening the file. The part I'd defend in anyone's
codebase is only this: the next service should be a copy of the last one.


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.


Platform and generation - Part 1.

Next: what the platform hands you for free, and the list of things you're not allowed to write by
hand because of it.

Top comments (0)