DEV Community

Anton Brilliantov
Anton Brilliantov

Posted on

Splitting Until It Fits

One task, one concern, one or two files, one test, one command. The rule I use to know when to stop splitting isn't a line count - it's whether anything still has to be looked up.


๐Ÿ‘‹ I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. Part 1 of this series was about what an executor must know and must not know. This part is about the other half of the same problem: how small the unit of work has to get before that's even possible. Notes: github.com/brilliant-almazov.

As before: these are my habits on one codebase, not advice for yours.


The unit

I split work in four steps, and the last one is the only one with a definition:

task            "the service should take its runtime from the platform"
  โ””โ”€โ”€ stage     "stop hand-rolling what the platform already gives us"
        โ””โ”€โ”€ sub-stage   "message consumption"
              โ””โ”€โ”€ iteration   one concern: 1โ€“2 files + their test
Enter fullscreen mode Exit fullscreen mode

The four levels of splitting work: task, stage, sub-stage, iteration, with the definition attached only to the last one

An iteration is one concern. In practice that is 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.

Real sizes from the last few stages: a stage of 13 iterations (moving off hand-written
runtime), a stage of 12 (a skeleton generator), and a stage of 40 - that one converted
forty-four copies of four read shapes onto a single generic core, one file per iteration.

Forty iterations sounds absurd until you look at what each one is: change one file, move its test,
run one package's tests. Fifteen to thirty-five minutes each.

   13 iterations          12 iterations          40 iterations
   โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€           โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€           โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
   moving off             a skeleton             44 copies of four
   hand-written           generator              read shapes onto
   runtime                                       one generic core
                                                 (one file each)
Enter fullscreen mode Exit fullscreen mode

Three real stage sizes side by side: 13, 12 and 40 iterations, with what each stage was

The tell

Here's the only reliable signal I've found that an iteration is still too big:

The executor had to look something up.

Not "it took long". Not "the diff was big". If anything had to be found - the type to embed, the
name of an error, which package the helper lives in, how the neighbour did it - then the iteration
was carrying an unstated dependency, and the split was wrong.

That reframes a whole class of complaints. When an iteration comes back with a clarifying
question, the question is not the executor's failure. It's a defect in how I cut the work. So the
fix is never "answer it" - the fix is add the fact and cut smaller.

NOT the tell                       the tell
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€       โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
it took long                       anything had to be found
the diff was large                   ยท the type to embed
                                     ยท the name of an error
                                     ยท which package a helper is in
                                     ยท how the neighbour did it
                                            โ”‚
                                            โ–ผ
                          the cut was wrong, not the executor
Enter fullscreen mode Exit fullscreen mode

The single signal that a task was cut too large: the executor had to look something up, set against the two things that are not the signal - it took long, the diff was large

What that rules out

Iterations that sound reasonable and are not:

  • "Walk the path and fix every step where the value is lost." This is a search with a fix attached. Do the walk first, then write one iteration per broken step.
  • "Find where it breaks." Investigation. Not an iteration.
  • "Figure out how it's done here and follow it." Reconnaissance delegated.
  • "By analogy with the neighbouring domain." Copying an unnamed template.

Each of those has the same shape: it makes the executor discover something before it can act. The
discovery is the expensive part, and it happens on every executor, every time.

Order

The order of iterations follows data dependency: whatever produces something comes before
whatever consumes it. Tables and schema go in the first iteration of a chain; the core goes before
its users.

Beyond that the order is free. Two iterations that touch neither the same files nor each other's
output can run at the same time. That's the whole reason for splitting by concern rather than by
"feature": concerns don't overlap in files, so they parallelise without merge pain.

The dependency table for a 13-iteration stage looked like this - a plain list, not a graph anyone
has to reconstruct:

# Iteration Depends on
01 register the consumer in the daemon 05, 07
02 message headers on publish โ€”
03 typed subscription 02
04 delivery options and terminal errors 03
05 dedup on the shared cache 04
06 entity cache on the shared core โ€”
09 bulk insert on the shared preparer โ€”

Everything with a dash starts immediately. Everything else waits for exactly one named thing.

Acceptance, per iteration

An iteration that can't be checked isn't finished, it's abandoned. So each one carries a command
that runs with no substitution and covers only its package:

go test ./internal/repository/order/... -race -count=1
Enter fullscreen mode Exit fullscreen mode

Not the whole suite. The full pass - formatting, vet, linter, everything with -race, structure
checks - runs once, at the end of the stage, by me. Running it per iteration is the single
most expensive habit I've had to break, and it feels responsible the entire time you're doing it.

What it costs

Forty iterations means forty written tasks. On a stage where the code itself is two days, the
writing is most of a day, and it happens before anything runs.

That trade only pays when the work is repetitive and the conventions are rigid - the same
mechanical change across many files, in a codebase where "the right shape" is already decided. On
exploratory work it's the wrong tool: you can't specify what you haven't found yet.

The other cost is discipline. A stage of forty iterations is forty chances to say "this one's
small, I'll just do it myself while I'm here" - and each time you do, the stage stops being
resumable, because the state is now partly in your head.

The one conclusion

Split until nothing has to be found. If the executor is looking for something, the iteration is
still too big - and that's a fact about the task I wrote, not about who executed it.


Working with agents - Part 2.

Next: what a task looks like when nothing is left to interpret - and the list of phrases I've
banned from my own writing because every one of them is a hole.

Top comments (0)