Generic code is born in a service, lives in the platform, and comes back as a tag.
π 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 small piece of that: how code that turns out
to be generic gets from a single service into the shared platform library, and how it comes back.
Maybe it's useful to you, maybe you already do it better, maybe you'd cut it differently. Notes:
github.com/brilliant-almazov.
As usual: this is what I'm doing on one codebase right now, with the reasons and the price - not a
recommendation for yours.
The claim
You cannot design generic code in the platform up front. Sitting in the platform, you can't see
whether anyone needs it: there's no second caller, no second shape, nothing to generalise from. So
the generic thing is born where the need is obvious - inside a service, solving one concrete
problem - and only later moves out.
The move back is the part that surprised me. A service dropping its local copy and importing the
platform version is not the tail end of the move out. It is separate work, and the reason is
the tag: until the platform release exists, that work cannot start at all.
Phase A β prepared in the service
Nothing leaves the service yet. The package stays where it is and keeps working; what changes is
that it stops being local in every way except its address:
- not a single import of a domain package;
- the public API is fixed - the names it will keep after the move;
- tests moved into the concern's own subfolder;
- there is a context-cancellation test.
Phase A closes on a green run in the service repository. Nothing about the platform is touched, and
if the move never happens, the service is still better off.
Phase B β moved to the platform
The files move. The package name changes to the name of the target folder, and any leftover imports
of the service are cleaned out. That's the whole of it - a relocation, not a redesign, because the
design was already frozen in phase A.
One condition on top: this happens only on the direct instruction of the platform owner. The
platform is not a place anyone drops code into because it looked reusable.
Phase C β the service switches to the tag
The service now has two copies of the same thing - its own, and the one in the platform. Phase C
removes that:
- the module file gets exactly the tag that was given - not a nearby one, not a resolved one;
- pseudo-versions and
replacedirectives are forbidden; - the local package is deleted;
- the imports are swapped.
If the tag doesn't exist yet, the work doesn't begin. The answer is one line: waiting for the tag.
Not "let me pin a pseudo-version so the build is green in the meantime" - that turns a blocked task
into a silently wrong one, and the difference stops being visible in the diff.
Why C is a separate set, not the tail of B
Because C is executable only after the tag is cut, and tags are cut by one person.
That's a different kind of dependency from "this iteration needs that iteration". A set that stops
halfway and waits for someone else's action is in a bad state: half-applied, half-reviewable,
occupying attention. A set that hasn't started because its precondition doesn't exist is in a fine
state - it's just queued. Same waiting, very different cost, and the only way to get the second one
is to draw the boundary exactly where the external dependency sits.
βββ set 1 βββββββββββββββββββββββββββββββββΆ β βββ set 2 ββββββββββΆ
ββββββββββββββββββββ ββββββββββββββββββββ β ββββββββββββββββββββ
β A β prepared in β β B β moved to the β β β C β switches to β
β the service ββββΆβ platform β β β the tag β
ββββββββββββββββββββ ββββββββββββββββββββ β ββββββββββββββββββββ
no domain imports files relocate β exactly that tag
public API frozen package renamed β no pseudo-version
tests in own folder service imports out β no replace
cancellation test owner's go-ahead β local copy deleted
green run in repo β imports swapped
β
waits for a tag
What actually moves
Seven units. None of them were written to be generic; each one earned it by being needed twice.
| # | What moves | What it is |
|---|---|---|
| 01 | task running | serial and parallel Runner[T]
|
| 02 | pagination | keyset cursor + page-size clamp |
| 03 | row reading | generic reads of arbitrary projections + collectors |
| 04 | checks |
Rule[In] / Guard[In,Out] / Each[In] / Nullable[T]
|
| 05 | metric ports |
Factory / Gauge / Counter / Histogram / Spec
|
| 06 | migration source merging | several embedded filesystems into one source |
| 07 | publish-target selection | exchange or queue, by the suffix of the name |
Read the list from the other end and it's a decent description of what a platform library is for:
none of these are business decisions. Not one of them knows what the service does.
Estimates, not a report
To be explicit before the numbers: both sets - moving out, and switching over - are written and
queued. Neither has been executed. What follows is an estimate, not a measurement.
| Set | Estimated work | Calendar with two executors |
|---|---|---|
| phase A, 7 units | 2 h 50 min (20β30 min per unit) | β1 h 30 min |
| phase B, 7 units | 1 h 25 min | β |
Phase C has no estimate here, and that's the honest version: it can't be scheduled against a tag
that hasn't been cut.
What it costs
Three things, and I'd rather name them than discover them later.
The package lives in two places for a while. Between B and C the service still runs its local
copy while the platform carries the moved one. Any fix in that window has to be made with both in
mind, or made twice.
The switch depends on someone else's release. That's the whole point of the split, but it's
still a cost: the finish line of the work isn't mine to cross.
"Generic" is checked by hand. The rule I use - it has to have been written twice before it
counts - is a judgement call, not a test. Until a package has genuinely been needed by a second
caller, moving it is a guess, and a guess in the platform is more expensive than a guess in a
service.
The one conclusion
The platform gets only what has already proved useful in a service; everything else stays in the
service until it has.
That's my current setup and my current 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 did it.
Platform and generation - Part 5.
Next: the skeleton generator - why the boring parts of a new service should be produced by code
rather than written by hand each time.



Top comments (0)