The code you can change today is worth more than the code you cannot.
๐ I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving Go
services out of a live PHP monolith. This is the last part of a block about the platform, the
service template and generated skeletons, and I want to close it with the one idea that decided
most of the calls in it. Maybe it is useful to you; maybe you look at this the other way round and
I'd like to hear that. Notes: github.com/brilliant-almazov.
As with every part of this series: this is what I do on one codebase, not advice for yours.
The thesis
Between a decision I can change today and a decision that is better but changes on someone else's
release, I take the first one. Not because it is written better - it usually isn't - but because
the cost of changing it is known in advance. "Foreign" here doesn't mean bad. It means not moved
by me: a shared library, a platform package, a dependency with its own release cadence. Those are
often the better piece of code. They are also the piece whose change window I don't own.
Where the rule shows up in the layout
Universal code is born in a service, because that's where you can see it is needed, and it lives
in the platform. That gives three phases, and they are deliberately two different bodies of work:
- Phase A - preparation, inside the service. No imports of any domain package, the public API frozen, tests moved into the concern's own subfolder, a context-cancellation test present. Closed by a green run in the service's own repository.
- Phase B - the move into the platform. Files relocate, the package name becomes the target folder's name, service imports are cleaned out. Only on a direct instruction from the platform's owner.
-
Phase C - the service switches to the platform version. Exactly the given tag goes into the
modules, the local package is deleted, imports are replaced. No pseudo-versions, no
replace. No tag - the work doesn't start.
Phase C is a separate set for one reason: it is only executable after the tag exists, and tags are
placed by one person. So the rule falls out of the layout on its own. While the package is still
ours, a fix is minutes: edit the file, run that package's tests. After the move, the same fix waits
for a tag.
That is the whole trade, and it is why a package stays in the service until it has proven it is
universal - not until it looks universal.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ own package in the service โ โ moved to the platform โ
โ โ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโค โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโค โ
โ change: minutes โ โ change: waits for a tag โ
โ โ โ โ โ
โ โ โ โผ โ
โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ โ tag โ one person โ โ
โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
same code, different change window
How the control is actually held
Control at that scale is not a principle, it's a unit of work. The unit is an iteration: one
concern, which in practice means one code file - sometimes two of the same shape - plus the test
that covers it. If it doesn't fit in one pass, it becomes two iterations. It does not become one
long iteration.
The tell that an iteration is still too big is not its size:
The executor had to go looking for something.
Which rules out a whole family of tasks that read as perfectly reasonable: "walk the path and fix
every step where the value is lost", "find where it breaks", "work out how it's done here and
follow it", "by analogy with the neighbouring domain". Each one makes the executor discover
something first. So the path gets walked before the task is written, and what goes into the
task is the finished list - files, values, the command to run.
Batches, and stopping at any point
A batch is one executor, one pass, and the iterations inside it run strictly in order. Batches
inside a wave touch no shared files, so they run at the same time. The set that moved one service
onto the platform runtime was 13 iterations:
| Batch | Iterations | Wave |
|---|---|---|
| A | 02 โ 03 โ 04 โ 05 | 1 |
| B | 06 โ 07 โ 08 | 1 |
| C | 09 โ 11 โ 12 | 2 |
| D | 10 โ 13 | 2 |
| E | 01 | 3 |
Wave 2 starts once both batches of wave 1 are closed; wave 3 after wave 2, because iteration 01
leans on the results of 05 and 07.
The commit goes in at the batch boundary. No batch is left uncommitted - which is the part that
matters here: the work can be cut off at any boundary without losses, and nothing important is
sitting in my head.
WAVE 1 โโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโ
โ A 02 โ 03 โ 04 โ 05 โ โ B 06 โ 07 โ 08 โ
โโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ
โผ โผ
WAVE 2 โโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโ
โ C 09 โ 11 โ 12 โ โ D 10 โ 13 โ
โโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
WAVE 3 โโโโโโโโโโโโโโโโโโโโโโโโโ
โ E 01 โ
โโโโโโโโโโโโโโโโโโโโโโโโโ
commit at the batch boundary
What it looks like in numbers
Real set sizes from the last few stages: a set of 13 iterations (moving off hand-written
runtime), a set of 12 (a skeleton generator), and a set of 40 - that one converts
forty-four copies onto a single generic core, one file per iteration.
A typical iteration is 15 to 35 minutes of executor work: 15 minutes for a schedule read from
config, 35 for a typed subscription. That's the whole reason the boundary is cheap. Losing an
iteration costs half an hour, not a day.
The honest boundary
Part of what I just described is a plan, not a result, and control includes being able to say which
part:
- The skeleton generator is not written. There is a written set of 12 iterations for it and a rule not to start without a direct instruction.
- The sets "move to the platform" and "apply the platform" are written and queued, not executed. Their phases are a plan; their times are estimates.
- The coverage gate is at
0. The ratchet exists and can only go up, but the bar has not been raised yet. Actual coverage is 86.7%.
DONE PLANNED
โโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
the layout โ skeleton generator โ
the generic cores โ move to platform โ
the audit โ apply platform โ
the snapshots โ coverage gate โ
โ threshold 0 ยท actual 86.7% โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
What it costs
More writing. Forty iterations means forty written tasks, and on a simple stage writing the set
takes longer than the code it produces. All of that happens before anything runs.
A slower start. The first visible result arrives later than it would if I just went at the code in
sweeps - and for a while that feels like the wrong call, right up to the first interruption.
And it doesn't pay at all on a one-off fix. One file, one change, one afternoon: writing a
specification for that is pure overhead, and I don't.
The one conclusion
Choose by the cost of changing a decision, not by the quality of the decision.
That's my experience and my price for it, on one codebase, over a few months. If you do this
better, if you've already been through it, or if you look at it the other way round - I'd like to
hear how it's solved on your side, and what broke when you tried.
Platform and generation - Part 7. That closes this block: the layout, what the platform owns,
the audit, the generic cores, code moving both ways, the generator, and now the rule underneath all
of them.
Next block - operations out of the box: what changes when the manifest is the only place a service
declares itself.



Top comments (0)