The service writes input, processing and output - everything else is an import.
π 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 to every new service: where the line runs between what the shared platform gives me and what the service is allowed to write. Maybe it's useful to you; maybe you draw that line somewhere else. Notes: github.com/brilliant-almazov.
As always: this is what I'm doing right now on one codebase, with the reasons and the price - not advice for yours.
Where the line actually runs
For a long time I drew the line in the wrong place: our code on one side, third-party libraries on
the other. That split explains nothing, because "our code" ends up containing a logger, a cache, a
retry loop and a migration runner - none of which have anything to do with what the service is
for.
The line that holds is between runtime and domain. Process startup, connections,
transport, retries, observability - that's runtime, and it belongs to the platform. The service
writes three things: input (the contract, the codecs, validation), processing (domain
rules and invariants), and output (the storage model and domain events). Everything else is an
import.
What the platform hands over
Fourteen things arrive with the platform dependency, before a line of domain code exists:
| What | Where it lives |
|---|---|
application startup, graceful shutdown, /health, /ready, /metrics, platform info |
the app package |
| migrations as a declared resource | the migration resource |
| audit - written inside the same transaction, partitioned schema, build table | the audit package |
| the outbox runner for outgoing messages | the outbox package |
| workers and their initialisation state | the worker package and its init-state store |
| the broker and its driver | the messaging package plus the RabbitMQ driver |
| transactions - a registry, an executor taken from the context | the transaction package |
| listing and pagination | the listing package and its result type |
| cache with LRU eviction, TTL and metrics | the cache package |
bulk insert - multi-row VALUES, chunked to fit 65535 bind parameters |
the statement preparer |
| typed message consumption | typed dispatch plus a serializer registry |
| Snowflake identifiers | the id package |
| test infrastructure - one container per run, a fresh database | the test-infra package |
| metric registry, tracing, connection-pool instrumentation | the observability packages |
Two more things come from the same dependency without being packages the service imports: the
environment-variable and metric snapshots, taken by standalone snapshot binaries, and the build
version and commit, injected into platform symbols by the linker.
βββββββββββββββββββββββββββββββββββββββββββββββββ
SERVICE β input β processing β output β
βββββββββββββββββββββββββββββββββββββββββββββββββ
β
β everything below is an import
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββ
PLATFORM β startup Β· shutdown Β· health Β· ready Β· info β
β migrations Β· audit Β· outbox Β· workers β
β messaging + driver Β· transactions Β· cache β
β listing Β· bulk insert Β· ids Β· test infra β
β metrics Β· tracing Β· pool instrumentation β
βββββββββββββββββββββββββββββββββββββββββββββββββ
None of that is a suggestion the service may take or leave. It's what a service is made of before
it has a domain.
Three of them, spelled out
The table above reads like a list of conveniences. Three entries aren't conveniences, and they're
the ones that convinced me the line is in the right place.
Bulk insert, chunked to fit 65535 bind parameters. A multi-row INSERT ... VALUES is faster
than a loop of single-row inserts, and everyone knows that much. What everyone doesn't carry in
their head is that the wire protocol caps a statement at 65535 bind parameters, so the chunk size
isn't a tuning knob β it's 65535 / columns, recomputed per table. Hand-rolled bulk insert is
therefore correct on the table it was written for and quietly broken on a wider one, at a row
count nobody tests. That's not a performance detail; it's a correctness detail wearing a
performance costume.
Transactions as a registry plus an executor taken from the context. The consequence is a
shape, not a feature: a transaction never appears in a function signature. No tx pgx.Tx
threaded through five call layers, no repository method that only works if the caller remembered
to begin something first. The executor is a field, scoped when the transaction is opened, and the
code that reads and writes doesn't know whether it's inside one. Hand-rolling that is easy;
hand-rolling it consistently across a whole service is where it fails, and the audit of my own
service found exactly one repository that had gone its own way with a manual Begin and a
transaction held as a field, bypassing the registry.
Typed message consumption with a serializer registry. The consumer says which type it wants
and gets it; the codec is chosen from a message header. The property that matters is what happens
when the header is missing β the mechanism doesn't degrade gracefully, it simply cannot be used,
and the consumer falls back to parsing JSON by hand. So a publisher that ships empty headers
doesn't break loudly on its own side; it disables a platform capability on the far side of the
broker, in someone else's code. That's the kind of coupling that only becomes visible when both
ends are owned by the same platform contract.
What you don't write by hand
There is a list, and it's short enough to remember:
- cache
- retries
- typed message consumption
- the outbox
- configuration
- the logger
- metrics
- migrations
The rule sits in every control prompt I write, in those words: a hand-rolled equivalent of a
platform layer is forbidden; business code, the minimum.
Each line of that list has a specific thing it loses when it's written by hand, and the loss is
never the happy path:
- Cache β a map behind a mutex has no TTL, no capacity bound and no hit-rate metric. It works until the working set grows.
- Retries β a hand-written loop has no backoff and no redelivery limit, so a message that can't be decoded is retried forever. A decode error returned as an ordinary error is a poison loop with a friendly name.
- Typed consumption β replaced by hand-rolled JSON parsing at every consumer, which means the message shape is asserted in as many places as there are consumers.
- The outbox β a hand-rolled version usually publishes and deletes without a durable record in the same commit, which is the failure mode the outbox exists to prevent.
- Configuration β values become constants. The audit found a retention schedule compiled into the binary, which makes changing it a deploy.
- Logger β two different loggers in two packages of the same service, so half the events are shaped one way and half another, and neither half is complete.
- Metrics β no registry means no consistent names, and no consistent names means no dashboard that survives a rename.
- Migrations β as a declared resource they run in a known order at a known moment; by hand, they run wherever someone remembered to call them.
written by hand what is lost
βββββββββββββββββββ ββββββββββββββββββββββββββββββββββββββββββ
cache no TTL, no capacity, no hit rate
retries no backoff, no redelivery limit
typed consumption the shape asserted at every consumer
outbox publish and delete outside one commit
configuration the value becomes a constant
logger two shapes of event in one service
metrics no registry, so no name survives a rename
migrations they run wherever someone called them
Why it's a ban and not a recommendation
The interesting part is that a hand-rolled layer is usually not worse at its job. A map behind a
mutex caches things. A hand-written loop retries. Functionally you can't tell.
It's worse at two other things: observability, and the number of places one fix has to be applied.
The audit of my own service found three hand-rolled caches - a map plus a read-write mutex, no
TTL, no capacity bound, no metrics. Three places to change when eviction policy changes, and zero
answers to "what is the hit rate right now". The platform cache gives TTL, capacity and metrics on
the first line of use. Nobody would have argued that the hand-rolled ones were better - they were
written because writing one was faster than looking one up, and each was written by someone who
didn't know the other two existed.
You find out on production, which is the worst possible place to find out that a cache has no
upper bound.
Where the line isn't obvious
It's not a clean cut, and pretending otherwise would be dishonest.
Input, processing and output all lean on platform primitives themselves. Writing goes through the
transaction registry. Listing uses the platform's pagination. Every identifier comes from the
platform's id package. So the service isn't "platform-free" on its own side of the line either.
What stays in the service is the decision: what counts as a domain invariant, what a valid
transition is, which events matter to anyone else. The platform doesn't know the subject area, and
it shouldn't. The moment a platform package starts knowing what a valid order looks like, it stops
being a platform.
There's a second blurry edge, and it's the one that actually costs time: test infrastructure.
It looks like service code β it's in the service's repository, it knows the service's schema β but
the expensive part of it is generic. One container for the whole run rather than one per package,
a fresh database per test rather than a fresh container, explicit teardown rather than trust in a
reaper. Every one of those is a decision nobody wants to re-litigate per service, and every one of
them is invisible until a laptop is on its knees running the suite. So it lives on the platform
side, and what the service supplies is the fixtures.
The way I decide, when it isn't obvious: ask whether the next service would need the same thing
written again. If yes, it's runtime, even if it currently sits in a file that looks domain-shaped.
What it costs
Three prices, all of them real:
You depend on someone else's release cycle. If the primitive you need isn't in the platform
yet, the work stops until the tag exists. Not "we'll pin a commit and clean it up later" - the
service carries exactly the tag it was given, so the wait is a real wait.
The platform dictates the shape. Its interface decides how your code is arranged. Bending to
it is almost always cheaper than arguing with it, and "almost always" is doing some work in that
sentence.
There's a second place to look during an incident. The behaviour you're debugging may be in
your service or in the layer under it, and the first minutes go into deciding which.
I take those three over the alternative, which is a service that contains its own private
half-observable copy of everything.
The one conclusion
The only code that should be left in the service is the code you couldn't reuse in the next
service.
That's my line and my price. 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 it.
Platform and generation - Part 2.
Next: how to find the hand-rolled runtime still sitting in your own service - and what turned up
when I ran that audit on mine.








![[Go in Practice] Writing Modern Go with AI: Testing JetBrains go-modern-guidelines and Refactoring a 1,039-line main.go](https://media2.dev.to/dynamic/image/width=1200,height=627,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnaugad5rry7u00pyg8wh.png)








