Tuesday, 4 August 2026

Architecture as the independent variable

Preprint · Empirical software engineering Spring @RestController OfficeFloor functions

Architecture as the independent variable

Hold the AI coding agent fixed for 400 sessions. Vary only the architecture it works in. Does the codebase degrade because of the agent? Or because of the shape it is asked to grow?

Abstract

AI coding agents now take on long, iterative work. A worry has followed. Codebases seem to degrade under sustained agentic change. That degradation is usually studied as a property of the agent. We ask a different question. Is it a property of the architecture the agent is asked to extend?

We built a harness that holds the agent fixed. Each step gets a fresh, context-free claude-opus-4-8 session. Architecture is the only thing that varies. The same twenty owner-creation rules were added, checkpoint by checkpoint, to two forks of one REST application. One fork is a conventional Spring @RestController. The other is an OfficeFloor pipeline of YAML-composed functions. Ten chains per arm, times twenty checkpoints, gives 400 agent sessions. Every metric is re-derived from the commit history. Each slope carries a 95% bootstrap confidence interval over chains.

The result is sharp in one direction and null in another. How change is absorbed separates the two architectures cleanly. In the Spring arm, per-change blast radius, create-endpoint complexity, and temporal coupling all rise. Their confidence intervals exclude zero. In the OfficeFloor arm they stay flat, and it keeps adding small isolated units. Yet the benchmark's nominal "erosion" ratio does not separate the arms at all. It was meant to be the decisive statistic. It was not. And neither architecture produced a single test regression across 200 checkpoints each. Architecture governs the mechanism of degradation. At this horizon it has not yet changed the observed correctness. We report what held, what washed out, and why the deciding statistic was not the one we set out to measure.

1

The question

A coding agent asked to add one small feature usually does fine. The concern is the hundredth small feature. Iterative, agent-driven extension may leave a codebase quietly worse. More tangled. More concentrated. More expensive to keep changing. Anecdotes are common. Controlled measurement is rare.

Most attempts treat the agent as the variable. Different models. Different prompts. Different scaffolds. The task stays fixed. That tells you which agent degrades a codebase least. It cannot tell you something a system designer also needs to know. Does the architecture itself change how much damage accumulates?

So we invert the design. We hold the agent constant. Architecture becomes the independent variable. We take two implementations of one application. We feed both the same feature requirements, one checkpoint at a time. We watch how each absorbs twenty rounds of accumulating change. If degradation is purely the agent's fault, the arms should degrade alike. If architecture is a lever, they should diverge. The shape of the divergence then tells us which architectural properties matter.

Contribution A reproducible harness that isolates architecture as the cause of AI-driven code degradation. The agent, the task, the tests, and the leveling documentation are held fixed across both arms. It ships with a full result set, with confidence intervals, on which structural properties separate the architectures and which do not.
2

Results at a glance

We track thirteen metrics. The arms separate on the ones that describe how a change touches the codebase. They refuse to separate on the aggregate "erosion" ratio and on correctness. Here is the verdict, one dimension at a time.

◆ Separates cleanly

Blast radius

Spring's per-change edits to existing functions grow. The slope CI excludes zero. OfficeFloor's is flat. Spring disturbs twice the existing surface.

◆ Separates cleanly

Create-endpoint complexity

Spring's single addOwner handler bloats to CC 6.7. OfficeFloor's create-entry stays at 1.9. The CIs do not overlap.

◆ Separates cleanly

Temporal coupling

Spring keeps re-opening earlier rules' code. Its slope is positive. OfficeFloor's re-edit rate falls. The CIs do not overlap.

◆ Separates cleanly

Add vs. mutate

OfficeFloor creates about 19 new files per chain. Spring creates about 1. The two architectures absorb a new rule in opposite ways.

≈ Washes out

Erosion ratio

The nominal decisive metric. Both arms end near 0.012. Both CIs touch zero. The whole-app ratio cannot see the difference.

✕ No difference

Correctness & safety

Zero regressions. Full strict-pass. EvoScore 1.0. In both arms. Erosion stayed latent. It never broke anything at this horizon.

How to read the rest of this paper. Section 3 is the method. It covers the arms, the task, the fixed agent, the metrics, and the statistics. Section 4 is the evidence. It follows the six verdicts above. One figure anchors it. That figure is a coefficient plot of every degradation slope with its confidence interval. Sections 5 to 7 discuss why the designated statistic failed, the threats to validity, and how to reproduce every number.

3

Method

3.1  Two arms, one application

Both arms fork the same REST implementation of the Spring PetClinic sample. They share a data model, a database schema, and one black-box HTTP contract. They differ only in how request-handling logic is structured.

  • Spring. The conventional layered style. New behavior for POST /api/owners is added by editing a Spring @RestController method and the service beneath it.
  • OfficeFloor. A graph-of-functions style. The create-owner endpoint is a pipeline wired in YAML. New behavior is added by writing a new small function class and wiring it in.

The two base branches are matched. The first checkpoint starts each arm from an equivalent, working application. Everything downstream of the architecture is held identical. That means the requirements, the acceptance tests, the agent, the model, and a fixed project guide.

3.2  The task: twenty accumulating rules

Each checkpoint adds one requirement to the owner-creation endpoint. Every requirement stays in force for all later checkpoints. The rules are ordinary business logic. Duplicate detection by household, telephone, and email. Telephone and city-name normalization. Derived fields such as initials, customer code, membership number and tier, namesake count, and locality. A default registration date. A shared-household flag. City-capacity limits. An audit trail with a bulk-signup warning. By checkpoint twenty, one POST endpoint must enforce twenty interacting rules. That is exactly the kind of accreting hotspot where architecture should start to matter.

3.3  The fixed agent

At every checkpoint the harness launches a fresh, headless claude-opus-4-8 session. It gets only that checkpoint's specification. No conversation history. No memory of earlier checkpoints. No resume. The condition is deliberate. The agent must reason from the code's current structure alone. That is the setting in which a self-describing architecture can pay off. It also removes carried context as a confound. The agent runs with the same tools and the same neutral just-solve prompt in both arms. The prompt says to implement the change so all tests pass.

3.4  Isolation and anti-gaming

Three controls keep the comparison honest. They stop the agent from inflating its own score.

  • Experimenter-owned acceptance tests. Each checkpoint's tests are black-box. They hit the REST API. The harness injects them and resets them to the authored version before the correctness gate runs. So an agent that weakens a test cannot produce a false pass. Any edit the agent makes to a test is recorded, then reverted.
  • A pinned project guide. A fixed CLAUDE.md sits in both arms. It is restored to its base version after every checkpoint. It can never become cross-checkpoint memory.
  • A two-commit history. Each checkpoint makes an agent commit. Its diff is exactly what the agent changed. A reset commit follows. It re-normalizes the tree and stages the next checkpoint's test. Isolating the agent's true delta is what makes the structural metrics measurable per change.

Across all 400 sessions the agent edited a pinned document 0 times. It tampered with an acceptance test 0 times. The controls were never exercised. Their presence still removes two obvious threats to validity.

3.5  Capture now, derive later

A run stores only raw capture on the branches. That capture is the part that cannot be recomputed. It holds the agent's cost, tokens, and full event stream. It holds the raw pass/fail map of every test. It holds the true pre-normalization diff. It holds a provenance manifest with the model, tool versions, and a config snapshot. Every derived metric is recomputed later from the commit history. That covers erosion, verbosity, blast radius, coupling, and the rest. The split has a payoff. A metric can be defined or fixed long after a run and re-applied to it, with no new agent cost. Every number in this paper came from that offline re-derivation over the committed trees.

3.6  What we measure

Structural metrics are computed over production Java only. The same tools and thresholds apply to both arms.

  • Erosion. The share of total complexity mass that sits in high-complexity functions. Mass is cyclomatic complexity times the square root of SLOC, per function. High means above the standard CC = 10 threshold. This is the benchmark's headline statistic.
  • Verbosity. Flagged clone lines and anti-pattern lines, per line of code.
  • Blast radius. For a checkpoint's change: how many already-existing functions it modifies, and how many new files it adds.
  • Weighted Methods per Class. The god-class indicator. The highest per-class sum of method complexity.
  • Entry-handler complexity. The cyclomatic complexity of the one function the create endpoint routes through. In Spring that is addOwner. In OfficeFloor it is the designated create-entry.
  • Temporal coupling, or re-edit rate. When a checkpoint edits an existing function, the share of that function's body written by earlier checkpoints. High means new rules keep re-opening old ones.
  • Process and correctness. Cost, tokens, and API duration. Then Strict, ISO, and Core pass, Normalized Change, regressions, EvoScore, and Zero-Regression Rate.

3.7  Statistics

The degradation slope m is the OLS slope of a metric against checkpoint index within a chain. We report the mean-curve m with a 95% bootstrap confidence interval. The interval is resampled over the ten chains, with 2,000 replicates. A metric separates the arms when one arm's slope CI excludes zero and the other's brackets it. It separates more strongly when the two arms' CIs do not overlap. The scale is 10 chains, times 20 checkpoints, times 2 arms. That is 400 agent sessions. Each slope is fed by 200 checkpoints per arm.

Positionality and conflict of interest. OfficeFloor is authored by the experimenter. The OfficeFloor arm's base was purpose-built for this comparison. We mitigate this several ways. Identical requirements. Identical black-box tests, scored by an anti-gaming gate. A fixed agent. Fully published commit histories, so every number is independently recomputable. A reader should still weight the framing accordingly. See section 6.

4

Results

4.1  The degradation slopes, at a glance

Figure 1 plots the degradation slope and its 95% interval for every metric where the arms behave informatively. The metrics have different units. So each row is scaled to its own range. Within a row, two things matter. Does an interval cross the zero line? Do the two arms' intervals overlap?

Spring OfficeFloor ● slope estimate · ▬ 95% CI · ┆ zero tick · each row scaled independently
Figure 1. Degradation slopes with 95% bootstrap CIs. Four metrics describe how change lands: blast radius, files created, entry-handler CC, and re-edit rate. They separate the arms. Spring's disturbance and coupling rise. OfficeFloor's stay flat or fall. The two aggregate complexity slopes, hotspot CC and WMC, both rise but overlap. Erosion is the nominal decisive statistic. It is a wash. Both slopes hug zero.

4.2  How a change lands: the arms separate

The clearest signal is blast radius. It measures how much pre-existing code a single new rule disturbs. In Spring it climbs over the run. The slope is +0.052, CI 0.016 to 0.084. That excludes zero. In OfficeFloor it is statistically flat. Aggregated, Spring modifies about twice the existing surface per chain. It almost never gets away with touching nothing. OfficeFloor takes the zero-blast path more than a third of the time. It adds a rule without re-opening any existing function.

Table 1. Blast radius per chain (200 checkpoints per arm)
SignalSpringOfficeFloorReading
Existing functions modified / chain55.828.2Spring 2× the disturbance
Zero-blast checkpoints11/20070/200OF touches nothing 35% of the time
New files created / chain1.218.7add vs. mutate
Line churn / chain+500 / −22+662 / −30OF adds more lines, in new units

This is the mechanism in one table. A new rule in Spring is folded into existing methods. The same rule in OfficeFloor is attached as a new function. A cross-check from the agents' own event streams makes it concrete. Over the first five chains, the OfficeFloor agents wrote 75 distinct function and handler classes. That is a whole rest/function/owner/ package. Think AssignMembershipTier, CheckCityCapacity, AuditOwnerCreation. Spring wrote 20. It folded the same logic into its controller and service.

The consequence shows up in two places. The create endpoint. And temporal coupling. These are the cleanest separations in the study. Here the arms' confidence intervals do not even overlap.

Table 2. Concentration and coupling (final values and slopes)
MetricSpringOfficeFloorSlope CIs overlap?
Entry-handler CC (final)6.71.9no, clean split
Entry-handler CC slope m [CI]+0.25 [.14,.38]+0.06 [.05,.07]Spring bloats ~4× faster
Re-edit rate (mean)0.3310.216
Re-edit rate slope m [CI]+0.011 [.005,.016]−0.024 [−.030,−.018]no, opposite signs
God-class WMC (final)64.535.7Spring's worst class ~2× heavier

Spring's single create handler grows about 4× faster than OfficeFloor's. It ends more than three times as complex. And its re-edit rate rises while OfficeFloor's falls. So as the run proceeds, Spring keeps re-opening code written for earlier rules. OfficeFloor increasingly leaves earlier rules alone. The sign of the coupling slope flips between the two architectures. That is the erosion thesis in its strongest, cleanest form. It is just not measured by the erosion metric.

4.3  The designated statistic washes out

The benchmark's headline metric is erosion. It is the fraction of whole-app complexity mass held in high-complexity functions. It was meant to be the decisive number. It is not. Both arms' slopes hug zero. Both confidence intervals include zero. The phase-binned means converge on nearly the same final value.

Table 3. Erosion, phase-binned mean (Start to Final)
ArmStartEarlyMidLateFinalslope m [CI]
Spring0000.00360.0128.0007 [0,.0017]
OfficeFloor00.00590.00570.00520.0121.0006 [0,.0014]

The reason is structural, and it is instructive. Erosion is a ratio over the whole application. OfficeFloor adds many small functions. That inflates the denominator, the total complexity mass. At the same time, Spring concentrates complexity in the numerator, a few high-complexity functions. Both effects push the ratio the same way. Against a roughly 280-function application, one bloating controller barely moves it. So the metric meant to be decisive is the one metric blind to the difference. It normalizes away the very concentration it was trying to detect. The discriminating statistics are the un-normalized, per-change ones. Blast-radius slope. Entry-handler CC. Re-edit rate.

4.4  Verbosity: a real cost, but a constant one

With clone and pattern detection restored, verbosity gives an honest counterpoint. OfficeFloor carries more duplication than Spring at every phase. Its 75-odd small function classes share near-identical scaffolding. The clone detector flags it. But neither arm's verbosity grows. Both slopes are slightly negative.

Table 4. Verbosity index, phase-binned mean (flagged lines ÷ LOC)
ArmStartEarlyMidLateFinalslope m [CI]
OfficeFloor1.201.191.161.141.12−.0052 [−.0054,−.0049]
Spring1.081.071.041.020.99−.0058 [−.0061,−.0054]

This exposes the real cost of the OfficeFloor strategy. Isolation-by-proliferation pays a constant boilerplate tax. There is more repeated scaffolding up front. In exchange, the per-change blast radius stays bounded and does not compound. Spring pays the opposite way. Little duplication, but an entanglement cost that keeps rising. One note on the numbers. The verbosity index goes above 1.0 because clone lines count both sides of each pair. It is an index, not a fraction.

4.5  No correctness difference, and cost falls

Two outcomes people expect to move did not. Both arms are perfectly green. Strict-pass is 1.0 in every phase. EvoScore is 1.0 at every discount factor. The Zero-Regression Rate is 1.000. No checkpoint in either arm broke a previously passing test, across 200 checkpoints. The structural erosion is entirely latent. It accumulates. At this twenty-checkpoint horizon it never bit. Per-change cost also fell slightly over each run, in both arms. The slope is about −0.008 USD per checkpoint. That is the opposite of the idea that comprehension gets more expensive as the hotspot grows. Prompt-cache reuse and the agent's own efficiency dominate any rising-comprehension effect at this scale.

The finding in one sentence Architecture cleanly governs how AI-driven change is absorbed. Spring's disturbance and coupling grow. OfficeFloor's stay flat. But at twenty checkpoints that difference in mechanism has not yet changed correctness. And the aggregate erosion ratio is too coarse to see it.
5

Discussion

5.1  Architecture is a real lever, on the mechanism

The strong reading is not that OfficeFloor is better. It is that the two architectures put an agent's accumulating changes in structurally different places. And that this is measurable, with tight confidence intervals. Spring absorbs each rule by mutating and re-coupling existing units. So blast radius, create-endpoint complexity, and temporal coupling all trend up. OfficeFloor absorbs each rule by adding an isolated unit. So those same quantities stay flat. The cost is more boilerplate. There is a practical takeaway for anyone building a system that AI agents will maintain over a long horizon. The variable that separated is the one to manage. Keep the per-change blast radius bounded. Then the compounding costs, coupling and hotspot concentration, never get a foothold.

5.2  Choose the right decisive statistic

Our most transferable finding is a caution about metrics. The pre-designated decisive metric was the whole-application erosion ratio. It is the wrong instrument for this question. Normalization cancels the concentration it targets. Studies of architectural degradation under AI-driven change should lead with un-normalized, per-change statistics. Blast-radius slope. Entry-point complexity. Temporal coupling. Treat aggregate ratios as secondary. The headline number should be the one that can discriminate.

5.3  Latent vs. manifest degradation

Zero regressions in either arm is itself a result. It says Spring's structural entanglement is latent risk. It is not yet realized failure. Not at twenty checkpoints, against this acceptance suite. So the "OfficeFloor is safer" claim, in the sense of fewer broken tests, is unsupported by this run. It stays an argument about maintainability and future change-cost. The structural metrics support that. They do not speak to observed correctness. Surfacing a correctness difference would need more. A longer horizon. A harder or more interdependent test suite. Or a task where the rules conflict, rather than merely accumulate.

6

Threats to validity

  • Conflict of interest. OfficeFloor is the experimenter's framework. Its arm's base was purpose-built. Identical requirements, black-box anti-gaming tests, a fixed agent, and published commit histories mitigate the risk. They do not remove framing bias.
  • Single agent, single strategy. One model, claude-opus-4-8. One neutral prompt, just-solve. The harness supports intervention arms, anti-slop and plan-first, and other models. None are run here. Results may not carry across agents.
  • Single domain and hotspot. One application. One accreting endpoint. One family of business rules. Architectures that look different on a create-owner pipeline may converge on other workloads.
  • Horizon. Twenty checkpoints separated the mechanism. It did not manifest a correctness or cost difference. The most interesting effects may live past this horizon.
  • Metric construct validity. Erosion's normalization limit is covered above. The verbosity index double-counts clone pairs. Structural metrics recomputed offline depend on the analysis-time versions of the complexity and clone tools. Provenance pins them, but they are not identical to run-time.
  • Independence. Chains are independent by construction. Each starts a fresh worktree from the base. But both arms ran from one machine and one quota window. Systematic model drift over the run would affect both arms alike.
7

Reproducibility

Every result here is recomputable from published artifacts. Each of the twenty chains is a git branch. It is named evolve/<run>/just-solve/<arm>/chain<n>. Per checkpoint, its history is an agent commit and a reset commit. A final commit carries the raw capture, a provenance manifest, and a snapshot of the exact analysis configuration used. The analysis tool re-derives every metric, including any added later. It materializes each checkpoint commit and recomputes over its source. So the tables above regenerate on demand. New metrics apply to the same run. Neither step re-invokes the agent.

Appendix A below lists the complete degradation-slope table for all thirteen tracked metrics, both arms, with 95% CIs. It is the full data behind Figure 1.
8

Conclusion

We held the coding agent fixed and varied only architecture. Architecture is a real lever on how AI-driven change accumulates. Across 400 sessions, the Spring arm's per-change blast radius, create-endpoint complexity, and temporal coupling rise. Their confidence intervals exclude zero. The OfficeFloor arm's stay flat. The difference is not visible in an aggregate erosion ratio. That ratio cannot see it. It is visible in the un-normalized statistics of how each change lands. That divergence in mechanism did not become a correctness difference at this horizon. Both arms stayed perfectly green. The next questions are about horizon and generality. Longer runs. Conflicting rather than accumulating requirements. More architectures. More agents. The aim is to learn whether the latent entanglement one architecture builds up eventually becomes the failures the other avoids.


A

Appendix. Full slope table

Degradation slope m (OLS on checkpoint index) with 95% bootstrap CI over 10 chains
MetricSpring  m [CI]OfficeFloor  m [CI]Separates
Structural. How change lands
existing_fns_modified+0.0523 [.016,.084]+0.0056 [−.009,.023]yes
files_created−0.0021 [−.0037,−.0005]+0.0208 [.008,.030]yes
entry_cc+0.252 [.137,.381]+0.062 [.048,.068]yes
reedit_rate+0.0107 [.005,.016]−0.0244 [−.030,−.018]yes
Structural. Aggregate
hotspot_cc+0.241 [.163,.343]+0.172 [.102,.243]overlap
wmc_max+1.77 [1.38,2.06]+1.44 [1.36,1.49]overlap
packages_touched+0.0092 [−.001,.017]−0.0011 [−.007,.005]both ~0
erosion+0.0007 [0,.0017]+0.0006 [0,.0014]wash
erosion_scoped+0.0021 [0,.0051]+0.0014 [0,.0032]wash
verbosity−0.0058 [−.006,−.005]−0.0052 [−.005,−.005]level, not slope
Process
cost_usd−0.0082 [−.015,−.002]−0.0079 [−.015,−.001]both fall
duration_api_ms−1948 [−3552,−424]−1267 [−2379,−151]both fall
cache_read_tokens−1643 [−8867,4647]−4600 [−10060,−99]noisy

Correctness held constant across both arms and all phases. Strict-pass 1.0. EvoScore 1.0 at γ = 1, 1.5, 2. Zero-Regression Rate 1.000. Pinned-doc touch 0/200. Acceptance-tamper 0/200.

R

References and notes

  1. SlopCodeBench · arXiv:2603.24755. The no-context iterative-extension benchmark. The harness borrows its Erosion and Verbosity metric definitions, the degradation-slope statistic, and the prompt-intervention arms.
  2. SWE-CI · arXiv:2603.03823. The CI-gate evaluation framework. The harness borrows Normalized Change, EvoScore, and Zero-Regression Rate.
  3. spring-petclinic-rest. The REST PetClinic sample application both arms fork from.
  4. OfficeFloor · officefloor.net. The graph-of-functions application framework used in the OfficeFloor arm.
PetClinic-Evolve · 400 agent sessions · claude-opus-4-8 · run 2026-08 · figures and tables generated from the committed run data.
Draft for a blog and preprint. Palette and figures designed for the subject. Validated for colour-vision-deficiency separation.

Thursday, 30 July 2026

Initial findings on multiple chained changes

Provisional. These are early numbers from a run in progress. Two Spring chains and one OfficeFloor chain are complete, and a second OfficeFloor chain is still going. Every figure below will be refreshed once more runs land, and the values are likely to move. Read the direction of the findings, not the exact numbers.

This is a progress report. It shares early data from a longer running experiment. The numbers here are real but the sample is still small. I will update as more runs land.

The question

Does software architecture change how code decays when requirements keep arriving?

The common worry with AI coding is drift. You ask for one more rule, then another, then twenty more. The endpoint that started clean slowly turns into a swamp. I wanted to test whether the choice of architecture changes that outcome. Not the agent. Not the model. Just the architecture.

For previous experiments driving to ask this question, see here.

The two applications

Both applications come from the Spring PetClinic REST reference application. A fork lives in the OfficeFloor GitHub at https://github.com/officefloor/spring-petclinic-rest. It has two branches. The spring-compare branch is the standard Spring layering. The officefloor-compare branch is the OfficeFloor version. They expose the same REST API. They pass the same tests. They differ in how the code is wired together.

See how the two projects were create here.

How the experiment works

The design borrows from two papers. SlopCodeBench measures how code quality erodes when a model extends its own work with no shared context. SWE-CI measures change through a continuous integration gate, so only passing work counts.

The setup holds everything fixed except the architecture.

  • One agent does all the work. It is Claude Opus. The same model on both arms.
  • One endpoint evolves. It is POST /api/owners. No new endpoints are added.
  • Twenty checkpoints. Each checkpoint adds one business rule to that endpoint. Examples include duplicate detection, unique telephone, a derived membership number, a per day signup cap, a per city capacity limit, and an audit trail.
  • The agent gets no memory between checkpoints. Each step starts cold. This mimics the real world, where the person changing the code is rarely the person who wrote it.
  • A black box acceptance suite judges both arms by identical externals. Tests create owners and read fields back over HTTP. The internal design is never assumed.

Every checkpoint is committed to its own branch. Anyone can review the exact code the agent produced at each step. Each run of twenty checkpoints is called a chain.

What has finished so far

Three chains are complete. Two are Spring. One is OfficeFloor. A second OfficeFloor chain is still running. So the Spring result is replicated and the OfficeFloor result is a single run for now. Read the numbers with that in mind.

Finding 1. Both arms are correct

Across the three complete chains, every checkpoint passed. That is sixty checkpoints out of sixty. Strict, core, and isolation checks all green. Neither architecture broke an earlier rule while adding a later one. Zero regression on both sides.

So the interesting differences are not about correctness. They are about shape and cost.

Finding 2. Classic erosion did not appear

The first metric was erosion. It measures the share of code that sits inside heavy functions, where heavy means a cyclomatic complexity above ten. A god method would light this up.

It stayed at zero on every chain. The busiest single function reached a complexity of seven on Spring and eight on OfficeFloor. Neither agent ever wrote a god method. The model factors logic into small helpers no matter which architecture it works in. So this metric could not tell the two arms apart.

That is a useful result on its own. The decay people fear is not a single bloated method here. It shows up somewhere else.

Finding 3. The real difference is fan out

The two arms diverge sharply in how the work spreads across files. Here is the state at the twentieth checkpoint.

ArmFiles touchedFunctionsFunctions per file
Spring chain 035819.3
Spring chain 136120.3
OfficeFloor chain 025602.4

Both arms end with about sixty functions. Spring packs them into three files. OfficeFloor spreads them across twenty five. The two Spring chains landed on almost the same shape, so this is stable, not luck.

The trajectory of files touched tells the story checkpoint by checkpoint.

Spring       1, 1, 1, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3
OfficeFloor  3, 3, 3, 5, 6, 7, 7, 8, 9, 10, 12, 13, 16, 19, 20, 21, 22, 23, 24, 25

Spring settles on three files by the sixth checkpoint and never grows again. Every new rule is added inside those same files. OfficeFloor grows almost one file per rule. Each rule tends to become its own small wired function.

Finding 4. Spring grows the controller, not the service

This is the headline. Where does Spring put all that logic?

The base application ships the standard PetClinic layering. There is a service layer with a ClinicService interface and a ClinicServiceImpl class. That is the intended home for business rules.

The service layer was never touched. Not once. On either Spring chain. Across all twenty checkpoints.

The REST controller absorbed the work instead. At the twentieth checkpoint the controller holds about seventy six percent of the changed code. The rest is mostly derived fields added to the data model, such as a display name and a set of initials. So Spring did not build a god method. It built a god class. The controller quietly became the business logic layer.

LayerFunctionsShare of changed code
REST controller2675.7%
Data model3123.4%
Mapper10.9%
Service layer00%

This is why the fan out numbers look the way they do. Spring pins at three files because everything piles into the controller. OfficeFloor spreads because each rule attaches as its own function, wired into the flow rather than stacked in one place.

The decay is not a bloated method. It is a layer leak. The logic climbs up into the controller and settles there.

Finding 5. Adding a rule rarely disturbs existing code on OfficeFloor

This is the measure that matters most for AI maintenance. When a new rule arrives, how much working code does the agent have to reach into and change? Change that lands in existing functions is risky. Change that lands as a new isolated unit is safe. This is the blast radius of a change.

I counted, for each checkpoint, how many pre existing functions the agent modified, and how many brand new files it created instead. Summed across the twenty rules of a chain.

ArmExisting functions modifiedNew files created
Spring (mean of two chains)560
OfficeFloor2422

Spring adds every rule by editing existing code. It created no new files at all and reached into about fifty six existing functions over the chain. OfficeFloor disturbed less than half as many existing functions, twenty four, and stood up twenty two new files to hold the new logic. OfficeFloor adds behaviour by addition. Spring adds behaviour by modification.

The clearest way to see it is to count the checkpoints where the agent touched no existing function at all. A rule that lands with zero blast radius changed nothing that already worked.

  • OfficeFloor added eight of the twenty rules with zero blast radius.
  • Spring managed that for only two, and both were trivial field additions.

It also does not ease off. Late in the chain Spring is still modifying four or five existing functions per rule. The distributed shape lets OfficeFloor keep attaching new rules without reopening old ones.

There is an honest trade here. OfficeFloor writes more new code to do this, because a new wired function carries its own wiring. So the total lines added are higher. The point is where the change lands, not how much is typed. OfficeFloor keeps the change away from working code.

Finding 6. Comprehension was about even

At several checkpoints a fresh agent with no prior context was asked to explain the system. The score is how much of the expected behaviour it recovered. Both arms rose as the system grew. OfficeFloor held a slight edge at the final checkpoint.

What this means so far

The story is not the one I first went looking for. I expected a god method and rising complexity on Spring. That did not happen. The agent is too tidy at the method level for that.

What did happen is a layering story. On Spring the business rules drift up into the controller and the service layer sits idle. On OfficeFloor the rules stay distributed as small wired functions. Same behaviour. Same test results. Very different shape.

Which shape you prefer is a judgement call. A concentrated controller is fewer files to open. A distributed set of functions is smaller pieces to reason about and change in isolation.

The blast radius numbers push that judgement in a clear direction for AI work. A machine that adds each rule as new code, without editing the functions that already pass their tests, is a machine that is harder to let break something. On OfficeFloor the agent could do that eight times out of twenty. On Spring almost never. That is the strongest signal in the data so far.

Caveats

  • The sample is small. Two Spring chains and one OfficeFloor chain are complete. A second OfficeFloor chain is still running.
  • One agent and one model produced all of this. A different model may behave differently.
  • The comprehension edge needs the remaining OfficeFloor runs before I would call it stable.

Next steps

Blast radius and the controller share are now recorded at every checkpoint by the harness, so the next runs will chart them directly. From here it is more chains on each arm, to firm up the fan out, blast radius, and comprehension numbers with a larger sample.

Monday, 27 July 2026

Can a composed function architecture give AI a better map of your code?

I took the Spring PetClinic REST reference application and built it two ways. One uses conventional Spring @RestController methods. The other uses OfficeFloor endpoints declared in YAML as composed functions. Then I pointed the same AI agent at both. Each got an identical set of eleven prompts. The question is simple. Does declaring endpoints as a wiring of small, named functions give an AI coding assistant a better index into the code? And does that make changes cheaper, more reliable, and more maintainable?

The short answer

The results support the hypothesis directionally. More usefully, they show exactly where it holds. OfficeFloor wins clearly on comprehension. That means listing endpoints and explaining a request flow. It also wins on cross-cutting changes that fan out across many endpoints. Authorisation, audit logging, and shared validation are examples. It draws or slightly loses on simple in-place logic fixes. The advantage washes out on deep data-layer changes, where most of the work lives below the endpoint.

It is not yet conclusive as a general "cheaper changes" claim. There were only three runs per prompt, and run-to-run variance was high. The two applications are not perfectly equal. And OfficeFloor had one recurring habit: it often stopped before writing tests. So a tighter second round is needed. But the strongest finding is architectural. In Spring, new behaviour kept getting bolted into existing controller and service methods. In OfficeFloor, it always landed as a new, single-purpose function.

The hypothesis

OfficeFloor (officefloor.net) provides a Spring Boot starter. It lets REST endpoints be declared in YAML as composed functions. These functions behave less like traditional service methods. They behave more like code blocks woven together by the configuration. The hypothesis is that this structure gives an AI a ready-made contextual index into the codebase. It is a map of what exists and how a request flows. That should make the code easier and cheaper to understand and change.

The setup

Both applications derive from the well-known Spring PetClinic REST reference application. They were deliberately made equivalent. Same domain, same persistence layers, same test suite. They differ only in how endpoints are expressed.

  • Spring. Endpoints are methods on @RestController classes. This is the stock PetClinic REST approach.
  • OfficeFloor. Each endpoint is a YAML file wiring together small Java functions. New behaviour is added by writing a function and wiring it into the pipeline.

A fork is available so you can try the experiment yourself. It is at github.com/officefloor/spring-petclinic-rest. The two variants live on branches spring-compare and officefloor-compare. Check out each branch. Run the prompts below against it. Then compare your own numbers.

The same prompt was run against each application. Every prompt was run three times per application. The hardest one was run four times. Each run captured cost, API duration, and the resulting diff. The agent was Claude (Opus-class) driving Claude Code.

How to read the cost numbers

Cost is dominated by cache-read tokens. That is effectively a proxy for how much code the agent had to pull into context. So it is a reasonable stand-in for "how hard was it to find and understand the relevant code." The API duration is the time spent in model inference. It is the cleanest measure of how much work the model did. Two caveats are worth keeping in mind throughout.

  • Different approaches swing the cost wildly. Sometimes the agent chose to add a versioned v2 endpoint instead of editing in place. That roughly doubled the cost. This happened independent of the framework.
  • "Cheap" sometimes means "unfinished." OfficeFloor frequently stopped after the functional change. It left tests unwritten until nudged. The averages below fold those follow-up prompts back in. That keeps the comparison like-for-like to a finished change.

Cost per prompt, at a glance

This is the average total cost in USD across all runs. It includes any follow-up prompts needed to reach a finished change with tests. Prompt 10 is averaged over four runs. All others are averaged over three. Lower is cheaper.

PromptCategorySpringOfficeFloorCheaper
1 · Count endpointscomprehension$0.35$0.25OfficeFloor
2 · 404 to 200 empty listin-place fix$0.50$0.76Spring
3 · Reject blank phonein-place fix$0.92$1.19Spring
4 · Visit date limitcross-cutting$1.03$1.28about even
5 · Duplicate pet namecross-cutting$2.07$1.91OfficeFloor
6 · New pets endpointnew endpoint$1.81$1.49OfficeFloor
7 · Paginate vetsdeep vertical$2.97$2.40OfficeFloor
8 · VET read accesscross-cutting$2.07$1.81OfficeFloor
9 · Audit deletescross-cutting$1.25$1.02OfficeFloor
10 · Soft-delete ownerdeep vertical$5.96$3.80OfficeFloor
11 · Explain flowcomprehension$0.81$0.40OfficeFloor

What the numbers say

Read the table together with the per-prompt detail below. Four categories emerge.

  • Comprehension (1, 11). Clear OfficeFloor win. It was roughly 30 to 50 percent cheaper. It was about twice as fast on API time. It was more consistent. And it pulled in far less code, with cache reads roughly half. This is the hypothesis's mechanism showing up directly. The YAML pipeline is the endpoint index. It is also the request flow. So the agent reads a map instead of reconstructing one.
  • Cross-cutting changes (5, 8, 9, and the coverage side of 4). OfficeFloor win. A change must apply to many endpoints here. Declarative wiring pays off. The authorisation change is a one-line edit across seven YAML files. Audit logging is five small functions wired into five DELETE pipelines.
  • Simple in-place logic fixes (2, 3). Spring win. The change lives inside a single method body. The extra function-plus-YAML indirection is overhead, not leverage. Spring was cheaper. It was also more reliably one-shot.
  • Deep vertical changes (7, 10). Roughly even. Most of the work is in shared repository, model, and schema layers. So the endpoint-layer advantage dilutes. OfficeFloor still edged ahead on cost. But both were expensive and high-variance.

The strongest finding: where new behaviour lands

A consistent structural difference appeared across the runs. In Spring, the agent tended to accrete new behaviour into existing methods. Validation went into savePet. Checks went into controller methods. Audit log() calls went inside each delete method. Method responsibilities grew beyond what their names promised.

In OfficeFloor, the composition model forced new behaviour into new, named functions. Examples include ValidateVisitDate, CheckNewPetName, and AuditPetDeletion. They were wired in via YAML. Existing functions stayed single-purpose. There was nowhere to quietly tuck extra logic.

This is the most compelling case for OfficeFloor's real payoff. That payoff is not the cost of change number 1. It is the cost of change number 20, after nineteen changes have accumulated. The single-shot cost figures cannot see that. Every prompt started from the same clean baseline.

The prompts and the data, one by one

1. How many REST endpoints does the application have?

How many REST endpoints does the application have?
StackRunCostAPILines ΔWhat it did
Spring1$0.4346s0/0Found 36 plus root redirect (also /oops), from OpenAPI YAML as authoritative source
2$0.3547s0/0Found 37 with /oops. Missed root redirect even when checking controllers
3$0.2843s0/0Same as run 2
OfficeFloor1$0.3418s0/0Found 37 including root redirect (no /oops), taken from YAML index
2$0.2118s0/0Same
3$0.2118s0/0Same

OfficeFloor win. It was cheaper. It was about 2.5 times faster on API time. It was perfectly consistent. It reads the YAML index directly. Note that the ground truth itself differs slightly. Spring surfaced /oops. OfficeFloor surfaced the root redirect. That muddies a pure correctness comparison.

2. Empty search result should be 200, not 404

GET /api/owners returns 404 when no owners match the lastName filter. That's wrong. Searching and finding nothing should return 200 with an empty list, the same as any other empty result set. Fix it.
StackRunCostAPILines ΔWhat it did
Spring1$0.481m3s+4/−5Removed the if statement and fixed the test
2$0.501m3s+4/−5Same
3$0.511m7s+4/−5Same
OfficeFloor1$0.3040s0/−4Removed the if but did not fix tests. Flagged that other list endpoints needed updating
1 · follow-up$0.551m22s+4/−6"run the tests" then fixed tests
2$0.491m8s+4/−6Removed the if and fixed tests in one shot
3$0.3335s0/−4Removed the if. Did not fix tests
3 · follow-up$0.621m19s+4/−6"run the tests" then fixed tests

Spring win. A one-line body change is Spring's home turf. It was consistent, one-shot, and cheaper. OfficeFloor needed a nudge to finish tests in two of three runs. It did notice sibling endpoints with the same bug.

3. Reject a new Owner with a blank telephone number

When a new Owner is created without a telephone number, don't leave it blank. Reject the request with a 400 and a clear validation message instead of silently saving a blank phone number.
StackRunCostAPILines ΔWhat it did
Spring1$0.882m14s+26Wrote two tests (blank and missing phone) to confirm existing behaviour
2$0.932m14s+26Same
3$0.952m37s+26Same
OfficeFloor1$1.534m28s+36/−3Wrote two tests to confirm. An expensive outlier run
2$1.042m50s+26Same
3$0.992m52s+26Same

Spring win. Both discovered the validation already existed. Both just added tests. So this mostly measures exploration cost. Spring was cheaper and steadier. OfficeFloor threw one pricey outlier.

4. Reject visits dated more than a year out

Vets shouldn't be able to book a visit more than one year in the future. Adding a visit dated beyond that should fail with a 400 and a message explaining why.
StackRunCostAPILines ΔWhat it did
Spring1$1.032m22s+76Validation annotation class, updated openapi.yml, test for POST /visits (inferred PUT)
2$0.891m56s+83Validation class, openapi, unit test, plus 2 Spring MVC integration tests
3$1.163m6s+71Exception in ClinicServiceImpl, advice covering all endpoints, 2 integration tests
OfficeFloor1$0.752m3s+78Exception plus reusable function wired into 3 endpoints (POST /visits, POST owners/.../visits, PUT /visits/{id})
1 · follow-up$1.022m33s+121"add test for 400 response" then 3 tests
2$1.122m30s+97Like run 1 but missed PUT /visits/{visitId}
3$0.962m32s+73Exception plus function on the two POSTs (missed PUT). No tests

Slight OfficeFloor edge, mixed. OfficeFloor reused one ValidateVisitDate function across multiple endpoints via YAML. That is broader coverage for less code. But both stacks were inconsistent. They missed the PUT endpoint at times. And they varied on writing tests unprompted.

5. No two pets with the same name for one owner

An owner shouldn't be able to register two pets with the exact same name. When adding or renaming a pet, reject it with a 400 if that owner already has a pet with that name.
StackRunCostAPILines ΔWhat it did
Spring1$1.924m30s+163/−4Check added inside ClinicServiceImpl.savePet, plus exception, handler, and tests
2$2.154m13s+139Logic in the controllers (incl. updatePet), plus exception, handler, and tests
3$2.144m48s+165Same as run 2
OfficeFloor1$2.034m59s+230Exception, handler, focused check functions, YAML wiring into 3 pipelines, and tests
2$2.104m52s+206Same
3$1.614m59s+122Same but no tests

OfficeFloor win, and a scope-creep exemplar. The cost was comparable. But the shapes differ sharply. Spring folded the check into savePet or the controller methods. OfficeFloor created discrete single-purpose functions, such as CheckNewPetName and CheckRenamedPetName. Then it wired them in.

6. Add a "list an owner's pets" endpoint

Add an endpoint to list every pet belonging to a given owner, GET /api/owners/{ownerId}/pets, returning 404 if the owner doesn't exist and an empty array if they have no pets.
StackRunCostAPILines ΔWhat it did
Spring1$1.653m53s+92openapi.yml, controller method, and tests, in one shot
2$1.764m8s+92Same
3$2.014m11s+89Same
OfficeFloor1$0.541m0s+31Reused LoadPet, new function, and YAML endpoint. No tests
1 · follow-up$0.881m35s+68"write a test for this endpoint"
2$0.4756s+29Same. No tests
2 · follow-up$0.801m28s+65"write a test for this endpoint"
3$0.7256s+30Same. No tests
3 · follow-up$1.061m29s+65"write a test for this endpoint"

OfficeFloor win on effort, with a caveat. The base implementation was strikingly cheap. It reused an existing LoadPet function. That is a clean composition win. But it omitted tests in all three runs. Fold the test prompt back in and the total is comparable to Spring. Spring delivered tests first time.

7. Paginate the vet list

The vet list is going to get long. Add page and size query parameters to GET /api/vets so callers can page through results, same as they already can for owners.
StackRunCostAPILines ΔWhat it did
Spring1$3.375m33s+286/−3New v2 endpoint. Repository, service, mapper, openapi, and tests
2$2.535m46s+282/−1Same (v2)
3$3.016m41s+131/−4Updated the existing endpoint in place
OfficeFloor1$2.205m48s+188/−21Endpoint function, repository methods, mapper, openapi, and tests (in place)
2$2.986m26s+276/−3New v2 endpoint
3$2.025m7s+180/−21Updated in place

Slight OfficeFloor edge. Effectively a draw. Most of the work is in shared repository, service, and mapper layers. So the endpoint abstraction matters little. Both stacks split between "add a v2" and "edit in place." That decision drove cost more than the framework did.

8. Give the VET role read access

Vets should be able to view owner and pet records. Right now only admins can. Give the VET role read access (GET requests) to owners and pets, but writes should stay admin-only.
StackRunCostAPILines ΔWhat it did
Spring1$1.513m35s+11/−11Switched to hasAnyRole across 7 endpoints, updated tests, and also spotted security was disabled
2$1.904m49s+69/−7Same, plus added tests
3$1.072m47s+7/−7Annotation change only
3 · follow-up$1.724m4s+124/−11"fix tests and add tests"
OfficeFloor1$0.4654s0/0Edited role in 7 YAML files (hasAnyRole). No tests
1 · follow-up$1.232m28s+120/−4"fix tests and add tests"
2$0.4154s0/0Same. No tests
2 · follow-up$1.613m23s+155/−4"fix tests and add tests"
3$0.4146s0/0Same. No tests
3 · follow-up$1.322m31s+137/−4"fix tests and add tests"

OfficeFloor win on the change itself. The authorisation edit is purely declarative. It was seven one-line YAML changes for under $0.50. Spring had to edit annotations across multiple controllers. But OfficeFloor wrote no tests in any run without a nudge. This is the clearest instance of its test-discipline gap.

9. Audit trail on deletes

We need an audit trail. Every time any resource (owner, pet, vet, pet type, specialty) is deleted, log the authenticated user, the resource type, and its id to a dedicated logger named AUDIT.
StackRunCostAPILines ΔWhat it did
Spring1$1.153m1s+31Log statements added inside each delete method. No tests
2$1.363m46s+68/−1AuditLogger dependency plus calls in each delete method. No tests
3$1.233m28s+25Same, no wrapper. No tests
OfficeFloor1$0.912m30s+124Dedicated audit function per resource plus shared AuditLog, wired into 5 DELETE YAMLs
2$1.173m21s+121Same
3$0.982m47s+118Same

OfficeFloor win. It was cheaper and more consistent. It was also more modular. It used separate audit functions wired declaratively. Spring threaded log lines into the existing delete methods. Neither wrote tests.

10. Soft-delete owners

Deleting an owner shouldn't remove their record. It should set a deleted flag instead. Deleted owners must stop showing up in the owner list and in individual lookups (404), but DELETE /api/owners/{id} should still respond 204 as before.
StackRunCostAPILines ΔWhat it did
Spring1$3.8419m56s+52/−3Repository-level soft delete plus entity annotations
1 · follow-up$4.6421m36s+61/−11"also apply to the jdbc profile"
2$4.2311m56s+73/−34Soft delete in ClinicServiceImpl rather than repository
3$6.2316m37s+85/−51Repository level
4$2.195m53s+22/−3Minimal via repositories plus annotations. No tests
4 · follow-up$2.706m54s+31/−11"convert the jdbc profile too"
OfficeFloor1$5.3911m58s+95/−38deleted column, entity, repo checks. Delete function sets flag. Tests. Also fixed owner-pets lookup
2$4.218m53s+73/−18Same, using an entity annotation for the soft delete
3$2.986m55s+61/−35Same
4$2.636m7s+76/−29Same

Slight OfficeFloor edge on the hardest task. This touches the model, three repository implementations, schema files for several databases, and tests. So the endpoint layer barely matters. Both were expensive and high-variance. But OfficeFloor was somewhat more consistent. It did all repositories in one pass. Spring twice needed a "do the jdbc profile too" nudge. OfficeFloor also once caught the knock-on owner-pets lookup.

11. Explain the PUT-owner request flow (no code)

Before I ask you to change anything: walk me through exactly what happens, in order, when a PUT request comes in to update an existing owner, from the HTTP request landing to the response going out. Don't write any code yet.
StackRunCostAPILines ΔWhat it did
Spring1$0.581m11s0/0Full report. Identified an incorrect 204 that should be 200
2$1.073m1s0/0Same
3$0.781m40s0/0Same
OfficeFloor1$0.4156s0/0Full report. Identified the same 204-should-be-200 bug
2$0.4052s0/0Same
3$0.3955s0/0Same

OfficeFloor win. It cost about half as much. It was far more consistent. Both stacks independently spotted the same latent bug. But OfficeFloor read the flow off the YAML pipeline. Spring reconstructed it from controller, service, and mapper code.

Conclusion

The experiment is genuinely encouraging. It supports the idea that a composed-function architecture gives an AI a better index into a codebase. The comprehension and cross-cutting results are exactly what the hypothesis predicts. The token accounting shows the mechanism at work. But the clearest signal is not in any single cost figure. It is structural. OfficeFloor's model keeps functions small and single-purpose by construction. The conventional controllers steadily accumulated responsibilities beyond their names. If that pattern holds up under a longitudinal test, then it is the real case for the approach. Not the per-change cost.

Try it yourself. The fork is at github.com/officefloor/spring-petclinic-rest. Branch spring-compare is the conventional @RestController build. Branch officefloor-compare is the YAML composed-function build. Run the eleven prompts above against each. Then see how your numbers compare.

Sunday, 19 July 2026

PetClinic Converted Again, This Time All The Way To Functions

Last month I wrote about moving OfficeFloor v4 from one graphical file to YAML per endpoint. Spring PetClinic REST was the worked example. The configuration story was right. The code story was not.

That conversion gave each endpoint its own YAML file. But behind most of those files sat a single function. It did everything the old controller method did. Validate. Look up. Mutate. Save. Respond. I had swapped the configuration format and left the fat method alone. Explicit orchestration over one function is just a verbose @GetMapping.

So I did the conversion again. Properly. The code is up as a pull request with the following walk through:

What changed

The controllers are gone. All nine of them. So are ExceptionControllerAdvice and BindingErrorsResponse. In their place are 37 endpoint YAML files and 73 small function classes.

An update endpoint now reads as the steps it always was.

# rest/api/owners/{ownerId}.PUT.yml
composition:
  authorize: "hasRole('OWNER_ADMIN')"

validate:
  class: ...rest.function.owner.ValidateOwner
  govern: [ transaction ]
  next: load

load:
  class: ...rest.function.owner.LoadOwner
  govern: [ transaction ]
  next: apply

apply:
  class: ...rest.function.owner.ApplyOwner
  govern: [ transaction ]
  next: save

save:
  class: ...rest.function.owner.SaveOwner
  govern: [ transaction ]
  next: respond

respond:
  class: ...rest.function.owner.RespondWithOwnerUpdated
  govern: [ transaction ]

Validate. Load. Apply. Save. Respond. You do not reconstruct the flow by reading a method body.

The functions stay small enough to hold in your head.

public class LoadOwner {
    public void service(@PathVariable(name = "ownerId") Integer ownerId,
            OwnerRepository ownerRepository, Out<Owner> loaded) throws NotFoundException {
        loaded.set(Lookups.findOrNotFound(() -> ownerRepository.findById(ownerId),
                "Owner not found: " + ownerId));
    }
}

State moves between steps as variables. Out<Owner> here. @Val Owner in the function that consumes it. The steps stay decoupled. Each one is unit testable on its own.

The parts that stopped being code

Two cross cutting concerns fell out of the Java entirely.

Transactions are govern: [ transaction ] on the steps that need them. The transaction spans the orchestration. Not a method boundary. That is what you wanted from @Transactional anyway.

Exception handling is a directory. The file escalation/...NotFoundException.yml names the handler for NotFoundException. An escalation and its handler are now discoverable by file name.

Security stayed Spring's. The authorize: "hasRole('OWNER_ADMIN')" line is a Spring expression. The repositories, mappers, DTOs and Spring Boot wiring are untouched. This is not a rewrite away from Spring. 

It is Spring with the request flow made explicit.

Was it worth it

There are more files. 73 classes where there were nine controllers. But each one is short. Each is independently testable. Each is named after what it does. And the file that composes them tells you the whole route at a glance.

If you looked at the last conversion and thought the YAML was not buying you much, that was fair. Have another look at this one.

Friday, 10 July 2026

Layers are the symptom of Methods

Ask someone who does not write software to describe what happens when they edit an article and press save. You will get something like:

  1. find the article
  2. change the title
  3. tell me it worked

Steps each finishing before the next begins.

Now open the code that does it:

  1. A controller receives a request DTO, validates it, and calls down
  2. A service asks a repository for the article, mutates it, saves it, and returns it
  3. The original controller then maps the entity to a response DTO and returns that

The steps are still in there. But they happen going down and up the layers. Nothing about the file layout tells you the order things happen in. The person who described the feature would not recognise the code that implements it.

The usual explanation is that real systems are more complicated than the business description and layers are how we manage the complication. I have stopped believing that. The business steps were a good description. What the technology lost was the shape and the shape was taken from us by the method call.

Here is the same feature as a function orchestration:

load:
  class: com.example.LoadArticle
  next: apply

apply:
  class: com.example.ApplyArticle
  next: respond

respond:
  class: com.example.RespondWithArticle

Find the article. Change the title. Tell me it worked. Steps in the order they happen, each a small class you can read on its own. That is the whole of it. There is no controller resuming, no service in the middle, and nothing happening on the way back up.

The interesting part is not that it matches the business steps. It is what stops being necessary once the method round trip is gone and particularly that layers stop being necessary. The transactional proxy stops being necessary, as the transaction can span the steps rather than a method call. Furthermore, aspects stop being necessary. What replaces them can start further functions rather than "magically" injecting functionality between method calls. Unchecked exceptions stop being necessary, because nothing has to climb a call hierarchy to be handled. Each of these I see was a workaround for a constraint that the method call imposed. And with function orchestration, each of them dissolves (see Inversion of Coupling Control for a more in depth discussion of coupling by the method).

The rest of this post discusses functional orchestration in more detail.

The layered sandwich is the shape of a method return

A method call is a round trip. The caller prepares, calls the method and then does further processing with the return. Effectively a sandwich around the method call. Stack enough sandwiches and you have a layered architecture.

We were taught to call this separation of concerns (presentation, service, repository). But which way round is it? Did we choose the principle and the method call happened to serve it? Or did we work around the method call and call it a principle after the fact?

What the business describes is a pipeline

Function orchestration removes the round trip. Functions run once and hand off forward. There is no way back up. The sandwich disappears and with it the reason for the layers.

In OfficeFloor a function has one of the following roles:

  • producer obtains an entity and sets it in scope
  • action changes an entity
  • responder turns an entity into a response
Every endpoint is a chain of producers then actions that ends in a responder.

Find the article (producer). Change the title (action). Tell me it worked (responder).

The non-technical description was already a pipeline. It always was. We just did not have the first class architecture capabilities to represent it.  Well, until function orchestration.

The content I showed at the top was simplified. Here is the real update endpoint with the validation and the transaction that I left out:

validate:
  class: com.example.ValidateArticle
  next: load

load:
  class: com.example.LoadArticle
  govern: [ transaction ]
  next: apply

apply:
  class: com.example.ApplyArticle
  govern: [ transaction ]
  next: respond

respond:
  class: com.example.RespondWithArticle

This is a sequence of steps. It reads top to bottom in the order it happens. It requires no layers.

Note what happened to the transaction. It is not an aspect wrapping a method call. It is govern, declared on each function spanning the transaction. This deserves more than a note and I will come back to it.

No longer is the response handled as part of the method sandwich. RespondWithArticle is a function like any other. It is not a controller resuming after its service bean returns. That distinction sounds pedantic until you notice it frees the pipeline from the synchronous nature of the method call.

Variables give a function explicit inputs and outputs

If functions do not call each other, and nothing returns a value up a stack, how does the entity get from LoadArticle to ApplyArticle?

Through a variable. A producer declares Out<T> and calls set. A later function declares @Val T and receives it. OfficeFloor matches them by type.

public class LoadArticle {
    public void service(@PathVariable(name = "id") Long id,
            ArticleRepository repository,
            Out<Article> loaded) throws NotFoundException {
        Article article = repository.findById(id)
                .orElseThrow(() -> new NotFoundException(id));
        loaded.set(article);
    }
}

public class ApplyArticle {
    public void service(@Val Article article, @Val ArticleRequest request) {
        article.setTitle(request.getTitle());
        article.setContent(request.getContent());
    }
}

Neither class references the other. ApplyArticle does not know that an article is loaded from a repository or that one was loaded at all. It just requires that an Article and an ArticleRequest are in scope and it says so in its signature.

This is the part that matters most. Consider that a single method gives its statements a local scope. Set a value on line three of the method and every line after it can use that value. State accumulates as the method runs. It is enormously convenient and likely why we keep writing long methods even though we know better.

Now try to split that method into separate "purist" functions (multiple parameters with single return type). Each function sees only its own parameters. With a single return value, a function hands exactly one value to the next one. So to carry several accumulated values forward you bundle them into a type. Then the flow grows and you need another combination, so you write another type. These carrier types model nothing in the domain. They exist to move state between functions. Anyone who has decomposed a large service method has met them, and has quietly given up somewhere around the third one.

Variables restore the shared scope. The set of live variables is the scope and any later function reads any earlier value it needs.

But it also improves on the single method and this is the real point. Inside a method a block of code uses the surrounding variables implicitly. Nothing states what that block reads or what it leaves behind. You cannot lift the block out and use it somewhere else because the set of variables it uses is implicit. They are inferred and the block of code has no interface.

A function in function orchestration declares them:

  • ValidateArticle transforms the post body into an ArticleRequest and sets it in scope
  • LoadArticle reads a path variable and sets the loaded Article entity in scope 
  • ApplyArticle reads an Article and an ArticleRequest from scope to take action of applying changes of the request onto the Article
  • RespondWithArticle reads an Article and transforms it into a DTO for sending in the HTTP Response

Those signatures are the contract. So the block becomes reusable wherever those variables are in scope. Furthermore, reuse follows the producer/action/responder pattern almost mechanically. LoadArticle serves the GET, the PUT and the DELETE. ApplyArticle applies the request body for the POST and the PUT. RespondWithArticle is shared by every endpoint that returns the Article. Each is written once and the YAML says where it appears.

That is the trade being made. A method gives you implicit state and no reuse. Variables give you the same shared scope, except the inputs and outputs are made explicit, with reuse as the consequence.

Governance is not an aspect by another name

Now back to the transaction because if the function orchestration is going to replace the layers then something has to replace what the layers were quietly doing for us.

In a layered application the transaction is a proxy. You annotate a service method with @Transactional. Spring wraps the bean and the proxy begins a transaction before the method and commits after it. The demarcation is the method boundary for no reason other than that the method boundary is the only thing a proxy can see.

Everyone who has used this has been bitten by what follows from it. The annotation only works if the call arrives through the proxy. Calling the method from another method of the same class silently skips the transaction. Furthermore, the unit of work must be a method. So when a unit of work is genuinely multiple steps you either fuse them into one method to get one transaction or you accept multiple transactions. The architecture bends around the demarcation mechanism.

Governance in OfficeFloor is declared as a YAML file in officefloor/govern/ and applied to functions by name:

# officefloor/govern/audit.yml
governance:
  class: com.example.AuditGovernance

The following governance class states the three moments of the lifecycle:

public class AuditGovernance {

    private Auditable enrolled;

    @Govern      // before the function body, once per enrolled object
    public void govern(Auditable auditable) {
        this.enrolled = auditable;
        auditable.recordEvent("governance-begin");
    }

    @Enforce     // after the governed functions complete successfully
    public void enforce() {
        enrolled.recordEvent("governance-commit");
    }

    @Disregard   // when a governed function throws
    public void disregard() {
        enrolled.recordEvent("governance-rollback");
    }
}

Begin, commit, rollback. The same three controls of a transaction. Transactions are just governance.

The differences are worth drawing out and none of them are cosmetic.

What it governs is chosen by type, not by pointcut. The parameter of the @Govern method declares an extension interface. Any managed object in a governed function that implements Auditable is enrolled automatically. Eligibility is worked out when OfficeFloor compiles the application graph, not when a weaver matches a string at class-load time. There is no execution(* com.example.service.*.*(..)) to get subtly wrong.

What it wraps is a run of functions, not a method. Look again at the PUT pipeline: govern: [ transaction ] appears on load and apply. Those functions run inside one transaction. Nothing had to be fused into one method to make that true. Nothing self invokes because self invocation is not a concept in a pipeline. Governance spans the appropriate functions because the runtime, not a proxy, is holding the lifecycle.

It is applied where the pipeline is described. The service class carries no annotation. To find out whether a function is transactional you read the endpoint file. You do not read the class, then check whether it is a bean, then check whether the caller went through the proxy, then find the pointcut, then work out whether it matches.

Administration does not have to fit through a method signature

Governance handles lifecycles that begin and end. For work that happens simply before or after a function, OfficeFloor has administration.

This is where the comparison with aspects gets uncomfortable for aspects.

An aspect is shaped by the method it advises:

  • it receives untyped, positional arguments (Object[] args) which are meaningful only if the aspect knows the signature it is advising. Furthermore, if the advice needs the entity, the entity has to be a parameter of that method. 
  • it must call proceed() so it sits on the calling thread inside that stack frame. Anything it wants to do has to be done inline and synchronously.

An administration is not shaped by any signature. It is attached before or after a function and it declares the extension interface it works with as the type of an array parameter:

public class AuditAdministration {

    @FlowInterface
    public interface Flows {
        void writeAuditRecord(AuditEntry entry, FlowCallback callback);
    }

    public void administer(Auditable[] auditables, Flows flows) {
        for (Auditable auditable : auditables) {
            flows.writeAuditRecord(auditable.toEntry(), null);
        }
    }
}

Read what it receives. Not the function's arguments but every object in scope that implements Auditable. The administration and the function it administers share no signature and no knowledge of each other. Neither has been distorted to accommodate the other.

Furthermore, read what it can do. Flows invokes further functions. This further function orchestration can have dependency injection, run asynchronously and escalate checked exceptions.  There is no method call to return, so function orchestration can add further steps to the pipeline.

There is one more thing an administration can take as a parameter and it is the one that shows how far this is from advice, a GovernanceManager. Administration can activate, enforce and disregard governance. A post-administration can commit the transaction and start a new one mid-pipeline, as a first class configured thing. In the AOP world that is the territory where you stop writing aspects and start reading the transaction manager's source code.

Administration can also read the annotations on the function it administers. The OfficeFloor Spring Boot starter implements @PreAuthorize via administration.

So the ranking is not close. An aspect gets untyped arguments, on the calling thread, inside one method, wired by a pointcut expression that is checked when the weaver runs. An administration gets typed extensions, can start further functions, can drive governance, can read the function's annotations. The first is a clever way to smuggle behaviour into a method call. The second does not need to smuggle anything because the pipeline was never a method call to begin with.

Which is why you can reason about it

Here is the practical test I keep coming back to. Take a function and ask what happens around it.

In the function orchestration you open the endpoint YAML. The functions are there by name with necessary govern. That is the answer complete in one file.

In the layered application you open the service class. You find @Transactional, so you check the class is a Spring bean and that nobody calls this method from inside the class. You find @PreAuthorize, so you work out where it sits in the proxy chain relative to the transaction, which is determined by advisor order, which is somewhere else. You search for pointcuts that might match the method's signature and hope you get it right in what methods they apply to. Then you find the annotation is on the interface or the parent class, and start again.

Both applications have cross cutting behaviour. In one it is declared where the functions are orchestrated. In the other it is inferred from annotations, proxies and pattern matching against method signatures, all of which are invisible at the point where they take effect. We got very good at the second. We should not mistake being good at it for it being good.

Exceptions do not have to climb

Now consider exceptions that are thrown at the bottom and caught somewhere on the way up. It is the clearest case of all because the language itself was bent to accommodate it.

In a layered application an exception is handled by @ControllerAdvice, which sits above the controller. For the advice to see the exception, the exception must survive the journey. It is thrown in the repository, passes through the service, passes through the controller, and only then reaches the handler.

Now recall what a checked exception requires. Every method it passes through must declare it. So a checked exception thrown at the bottom writes itself into the signature of every method between there and the top. Change what the repository can throw and you edit the service and the controller too. The signature of a method that does not care about the failure now names the failure.

This is where the unchecked exception came from. I believe not from a considered view about which failures are recoverable. It came from the cost of transit. Unchecked exceptions travel up the call hierarchy for free, declaring nothing, and once you are handling errors above the controller you need them to travel a long way. Java's checked exceptions are hard to live with in a layered application because the layers are exactly the thing that makes them expensive. So the industry gave up on them and gave up with them the one place the compiler could tell you what a piece of code can fail with.

A function orchestration has no call hierarchy so there is nothing to climb. A function throws, the orchestration short circuits, and the runtime routes the exception to a handler registered for that type:

# officefloor/escalation/com.example.NotFoundException.yml
handle:
  class: com.example.NotFoundExceptionHandler
public class NotFoundExceptionHandler {
    public void handle(@Parameter NotFoundException ex,
            ObjectResponse<ResponseEntity<Void>> response) {
        response.send(ResponseEntity.notFound().build());
    }
}

Look at what LoadArticle declared earlier in this post: throws NotFoundException, and NotFoundException extends Exception. It is checked. It costs nothing because there is no function between the throw and the handler. The handler is not above LoadArticle in anything. It is registered against a type and reached directly.

So checked exceptions become affordable again and the thing we traded away comes back. The signature of LoadArticle states that loading an article can fail because the article is not there. The compiler knows. A reader of the one function knows without reading the handler.

The handler is itself a function, which is the part I would not want anyone to miss. It takes the exception as a parameter, it has its dependencies injected, and it can invoke further functions. So handling a failure is not a matter of translating it into a response and getting out. It short circuits the orchestration that was running and it starts another one. An orchestration that ends in a failure hands over to another orchestration that deals with failures.

Which is why OfficeFloor calls this escalation rather than propagation. Propagation is what a stack does with the exception rising through method calls. Escalation is what an organisation does. The case leaves the process it was in and goes to the person whose job is that case. No business describes a failed credit check by saying it bubbles up through the department hierarchy until someone catches it. They say the application goes to the exceptions team, who have their own process, their own systems and their own authority to act. That is a handler with dependencies that starts its own function orchestration.

Every business process I have ever seen documented has a happy path and a set of exception paths that branch off it. Not a happy path plus a mechanism for unwinding it. The exception path is another business process joined at the point where things go wrong. That is what an escalation handler is. It is another case of the code being able to take the shape the business description already had.

The layer boundary becomes a data boundary

A little of the old layered architecture shape survives and only at the edges. The front function takes the request DTO. The back function produces the response DTO. That is the presentation boundary and it is now the two ends of a pipeline rather than a layer that wraps the others. The middle functions work with the entity, which resembles the service layer. Except that it is the body of the pipeline and not something that is called and returned from.

There is no data layer at all. A repository is a dependency of a function injected like anything else.

So the boundary that used to be enforced by a call is now enforced by data. Entities live in the middle. Each function declares what it works with and that declaration is the boundary. ApplyArticle alters an entity and cannot see a status code or a JSON body. This is not because a layer forbids it but because those things are not in its signature.

What you give up

I would rather say this than have it discovered.

The wiring moves out of the code and into the YAML. You cannot find the next step by clicking through in an IDE. That is a real loss and the endpoint file is the compensation. One file, one endpoint, the whole sequence visible at once.

Variables are matched by type. So two variables of the same type in one pipeline need qualifiers to tell them apart. Plus there is no single stack trace through a request because there is no single stack.

These are the costs of not having a call stack. They are the same costs, incidentally, that everyone pays as soon as they go asynchronous, or reactive, or event-driven. Function orchestration is honest about paying them up front in exchange for the shape closer to the business process itself.

Closer to how people already think

The claim I want to make is not that function orchestration is faster or more scalable. Though the absence of a blocking round trip has something to say about both. It is that the function orchestration is what the person describing the feature said in the first place.

Find the article. Change the title. Tell me it worked. Nobody has ever described an endpoint by saying that the presentation layer will map the request and then delegate to the service layer, which will begin a transaction before consulting the data layer, and that the response will be assembled as the calls unwind. That description is not more precise than the first one. It is a description of the call stack wearing the clothes of an architecture.

We spent a long time teaching ourselves to think in layers. We taught it to everyone who came after and it worked well enough that the origin was forgotten. Layers are what a method call leaves behind. Take the round trip away and they do not need replacing because the thing they were organising is gone. What is left is a sequence of functions, with the state each function reads and writes written on its face.

The full pattern is written up in the Orchestration Patterns and Naming reference, the mechanics of passing state between functions in the Variable tutorial, and the begin/commit/rollback lifecycle in the Governance tutorial. They run on Spring Boot so the comparison can be made on the same ground rather than against a strawman.

As always I would be glad to hear from anyone who has tried to decompose a service method into reusable pieces and come against the limitations of the method.