DEV Community

Anton Brilliantov
Anton Brilliantov

Posted on

Controlled and Imperfect Beats Perfect and Foreign

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

Two panels comparing the cost of a change: an own package inside the service changes in minutes; the same package moved to the platform waits for a tag, which one person places.

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

Three stacked waves of batches: wave one holds batches A (02 to 05) and B (06 to 08), wave two holds C (09, 11, 12) and D (10, 13), wave three holds E (01); vertical arrows run between waves and a side note reads 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% โ”‚
                                 โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
Enter fullscreen mode Exit fullscreen mode

Two columns: done - the layout, the generic cores, the audit, the snapshots; planned - skeleton generator, move to platform, apply platform, coverage gate at threshold zero against actual coverage 86.7 percent; the planned column is boxed in a vermilion hairline.

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)