DEV Community

Anton Brilliantov
Anton Brilliantov

Posted on

Code Moves Both Ways

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

Three phases left to right - prepared in the service, moved to the platform, service switches to the tag - with a dashed boundary between the second and third marked

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

Table of the seven packages that move to the platform, numbered 01 to 07, each with a short description

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 β€”

Table of estimates - phase A two hours fifty minutes across seven units, phase B one hour twenty-five minutes, phase A calendar with two executors about one hour thirty minutes - marked

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)