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.

Monday, 29 June 2026

Whoever Sets the Default Sets the Architecture

In my last post I asked whether AI would keep us stuck in 2020 architectures. I argued that the model has no opinion about which architecture is better. What it has is a confidence gradient, shaped entirely by how well-specified each option is, and so the fix is to make new patterns legible: document them thoroughly enough that an assistant can follow them even though it has never seen ten thousand examples.

I still believe that. But I've kept turning the argument over, and I've become convinced the conclusion is less comfortable than “so write better docs.” If I take my own premise seriously, it leads somewhere with real consequences for who actually gets to decide what software looks like from here. Three steps get there.

“Just write better docs” is a trap door, not an exit

The strongest objection to my last post is deflationary, and I want to meet it head on because I nearly talked myself into it. If documentation is the lever, then document your existing stack better and skip the new framework entirely. Hand the model a thorough rules file for the Spring conventions you already use, and you get AI-legible code without adopting anything new.

It's a fair point, and it's wrong in an instructive way. Notice which direction that lever points. Improving the legibility of the thing that is already winning only entrenches it further. “Write better Spring docs so the AI uses Spring well” doesn't rebut my argument. It is my argument, aimed at the status quo. The deflation collapses into the claim it was meant to puncture. Documentation is never neutral; it always advantages whatever it documents. Pointed backward, it is a force for staying exactly where we are.

The lock-in is to a vintage, not a vendor

I framed last time as new-framework-versus-Spring, with OfficeFloor as the test case. That framing was too small, and it let Spring off the hook unfairly. The same gravity drags Spring's own modern patterns.

Ask an assistant to “add a REST endpoint” with no further steering and you'll reliably get an imperative @RestController, blocking JPA, and quite possibly RestTemplate, a client Spring has effectively retired. You will not, by default, get WebFlux, the functional RouterFunction style, RestClient, or anything touching the AOT and native-image work. Not because those are wrong, and not because Spring failed to document them, but because the corpus's center of mass is older than Spring's current recommendations.

I don't say this to knock Spring. I built Spring Boot in as the base of OfficeFloor v4 precisely because the ecosystem is far too good to replace. The point is that even the incumbent can't easily pull its own users forward. What AI locks us into isn't a vendor at all. It is a vintage, roughly mainstream Java at peak Stack Overflow. Spring-introducing-WebFlux faces a gentler version of the exact headwind OfficeFloor faces. This is not a competitive disadvantage for newcomers; it is a structural tax on novelty itself.

Documentation lifts the ceiling, not the floor

This is the distinction I understated last time, and my own experiment shows it. When I worked with an assistant to convert Spring PetClinic REST over to the OfficeFloor YAML approach, documentation was the bottleneck, and once the docs were good enough the conversion went through. But there was a quieter result I didn't dwell on: left to its own judgement, with no steering, the assistant reached for @RestController every single time.

That's the whole thing in miniature. Documentation does real work, but only one kind: it makes a pattern usable when explicitly asked for. That's the ceiling. It does nothing to the pattern the model reaches for unprompted. That's the floor. The docs are what let the assistant produce something other than a controller once I told it to. They never changed what it volunteered when I didn't. So even a perfectly documented new architecture stays opt-in, competing against a default that is opt-out. Docs let an innovation be chosen. They don't make it get chosen by default, and they don't make it get discovered by anyone who didn't already know to ask. Necessary, but nowhere near sufficient.

And the floor is sinking

Now make it dynamic, which is the part that worries me most. If AI writes a growing share of new code, and that code clusters on the center of mass, then the next training corpus is more concentrated there, and the next model's default is stronger. The patterns a model reaches for unprompted don't merely persist. They compound, as models increasingly train on their own most common output.

That isn't static lock-in. It is a ratchet toward the mean: the space of architectures a model will volunteer narrows over time, generation by generation. The conservative force I identified last time is self-reinforcing. Left alone, “what's common” and “what's good” don't just differ. They actively diverge.

So who sets the default?

If documentation only lifts the ceiling, then the thing that actually decides which architectures survive is whatever sets the floor: the agent's system prompt, the project's rules file, the scaffolding and templates a tool ships with, and underneath all of it, the model's priors. A single line in a rules file can flip the default outright:

# AGENTS.md / rules file
When adding a REST endpoint, define it as a YAML endpoint file
under src/main/resources/officefloor/rest/, one function per step.
Do not generate @RestController classes.

That relocates architectural power in a way worth naming plainly. It used to sit, in theory at least, with “the best idea wins on merit.” It now sits with whoever configures the agents. My “is this idea legible to an AI” was the entry ticket; it only gets you into the room. The harder version of my own point is this: legibility gets you considered; only the default gets you chosen.

What follows, including for me

This changes what I think I owe you as the author of OfficeFloor. The job isn't finished when an assistant can use YAML endpoints because I documented them well. It's finished when an assistant reaches for them without being told, which means the starter should ship with the agent rules and scaffolding that make function injection the default, not just tutorials that make it possible. The battleground is the configuration my users actually load, not the tutorial they may never read.

For teams, the implication is broader: your architectural decisions increasingly live in your agent configuration, not only in your codebase. An unmanaged default is still a decision, a decision to ship 2020. The rules file deserves to be treated as a first-class architectural artifact and reviewed like one.

And for all of us: be a little wary of the ratchet. “The model wrote it this way” is evidence that a pattern is common, not that it is good, and those two properties are now drifting apart on purpose.

AI won't keep us in 2020 by holding opinions. It will keep us there by holding defaults, and defaults are always set by someone. The question I opened isn't really whether my idea is legible. It's who sets the default when nobody is steering, and that turns out to be less a technical question than a political one. I'd rather we decided it on purpose than inherited it by accident. 

Hence, I've added the "Add OfficeFloor rules to your project" on the OfficeFloor home page to make it easier to overcome the defaults.

As before, I'd be glad to hear from anyone seeing the same thing, especially anyone who has tried to win the default for a new pattern and found out what it costs.

Sunday, 28 June 2026

Will AI keep us stuck in 2020 architectures?

Every time I sit down with an AI coding assistant, I notice the same thing: it is very good at Spring. Annotations, profiles, @Autowired, the whole call-stack-driven dance of beans wiring into beans. AI has seen twenty years of this. It guesses well, even when it has to infer how a profile-specific bean is going to be selected at runtime. This is because it has seen ten thousand examples of exactly that pattern.

Which raises an uncomfortable question for anyone working on a new architecture: if AI is this fluent in 2020-era patterns, are we as an industry going to stay locked into those patterns simply because that's what the model knows? Is AI a conservative force that quietly drags software architecture backwards to its training data's centre of mass, no matter how good a newer idea might be?

I wanted to find out, using my own project as the test case.

The bet: an explicit index beats an implicit one

OfficeFloor version 4 added a feature I think is genuinely interesting for the AI era: REST endpoints can now be defined in YAML files, sitting alongside your existing Spring Boot code, with the directory structure following the URL structure. A file at greeting.POST.yml defines POST /greeting. A file at greeting/{name}.GET.yml defines GET /greeting/{name}. Inside that file, you compose the small functions that handle the request:

# greeting.POST.yml
validate:
  class: ValidateGreetingLogic
  outputs:
    valid: build
build:
  class: PostGreetingLogic
  next: audit
audit:
  class: AuditGreetingLogic

Each function still gets its dependencies injected by Spring exactly as it always has. OfficeFloor doesn't replace Spring's DI, persistence, security, or actuator setup. What changes is the flow. In a typical @RestController, the order in which validation, business logic, and auditing run is implicit: it lives in the call stack (in if statements and which methods call which other methods). To understand it, you read code. To change it, you read more code, because the wiring isn't written down anywhere as data; it's compiled into control flow.

In the OfficeFloor YAML version, that wiring is the file. Conditional branches, sequencing, error flows: they're declared, not buried. No function in the chain knows about the others. No annotation is describing the relationship from inside a class. The YAML is a complete, readable specification of how the endpoint behaves, sitting right next to the endpoint's own URL path in the directory tree.

This is essentially Function Injection, the same move Dependency Injection (DI) made decades ago, but one level up. DI took "what do I depend on" out of imperative constructor code and made it an explicit, configured, first-class concern. Function Injection takes "what happens next" out of the implicit call stack and makes that explicit and configured too. It's a continuation of the Inversion of Coupling Control idea I've been writing about for years: Dependency Injection only ever solved one slice of the coupling problem. Control flow coupling was always still there, just invisible.

For a human reading the code, this might feel like a wash, maybe even a step backwards.  This is exactly why the industry settled on annotations next to code in the first place; developers wanted the wiring close to the implementation, not off in some separate descriptor. That preference made sense when humans were the primary readers doing the navigating.

But an AI assistant isn't a human reading top to bottom. An AI assistant is trying to find the minimum context needed to make a correct, surgical change, and that's a search and navigation problem, not a stylistic one. A YAML file that names every function in an endpoint's execution path, in order, with explicit conditional branches, is a search index. The AI doesn't need to read the rest of the code base to be confident it has found everything relevant to that endpoint. It opens one small file and the entire behavioural contract of that URL is sitting right there.

That's the theory, anyway. I wanted to know if it would actually hold up against a model that has been trained almost exclusively on the other way of doing things.

The experiment: converting Spring PetClinic REST

To test this for real, rather than on a toy example, I took Spring PetClinic REST, the long-standing reference REST implementation of the PetClinic sample app that the Spring community has used for years, and worked with AI to convert its endpoints over to the OfficeFloor REST YAML approach.

It did not work on the first attempt. It took about five iterations to get a clean conversion, and the bottleneck wasn't OfficeFloor's runtime, and it wasn't really the AI's coding ability either. It was documentation. Each attempt surfaced a gap in the tutorials: some assumption I'd left implicit because it was obvious to me, a place where the YAML schema's possibilities weren't spelled out, an edge case in how a Spring @RestController-style behaviour should map across. I used the AI's confusion as a signal: where it guessed wrong or asked the wrong question, that was exactly where the tutorial needed another paragraph, another example, another explicit rule. Five rounds of "AI gets stuck, tutorial gets fixed, try again" later, the conversion went through cleanly.

I recorded the final working conversion. You can watch it here:


Spring PetClinic REST to OfficeFloor REST YAML

You can also see the resulting changes in the forked repository pull request.

So which is it: does AI lock in 2020 architecture, or not?

Both things turned out to be true, depending on what's actually being asked of the AI.

Where AI defaults to what it knows: left to its own judgement, an AI assistant will reach for Spring conventions, because Spring conventions are the statistically dominant pattern in its training data. If you ask it to "add a REST endpoint" with no further steering, you'll get an @RestController and an @Autowired field, every time. That's not a flaw in the model. It's just what twenty years of public code looks like, averaged.

Where AI happily adopts something new: the moment the new pattern is clearly and completely specified, the model's prior training stopped being an obstacle and became almost irrelevant. AI doesn't need to have seen ten thousand examples of a YAML-driven REST framework to use one correctly. It needs an accurate, complete description of the schema and the conventions, and then it follows that description. The five-iteration process wasn't really "teaching the AI to think differently." It was closing the gaps between what I assumed was obvious and what was actually written down anywhere the AI could read it.

That reframes the original question. The risk isn't that AI is architecturally conservative by nature. The risk is that new architectures rarely come with documentation anywhere near as exhaustive as Spring's, because Spring's documentation had two decades and a vast community writing tutorials, blog posts, Stack Overflow answers, and books about it. A new approach starts that race from zero. If its docs stay thin, AI will keep defaulting to Spring patterns, not out of preference, but because Spring is simply the only option it has enough information about to be confident in.

So the honest answer is: AI won't keep us in 2020 architectures by itself. But it will, by default, if nobody does the work of making the alternative legible to it. The model doesn't have an opinion about which architecture is better. It has a confidence gradient shaped entirely by how well-specified each option is in what it's been able to learn or been given.

The interesting part for framework and tool authors

If this holds generally, and I'd be curious whether others doing similar work see the same thing, it changes the calculus for anyone designing a new way of building software in the AI era.

It used to be that the cost of an explicit, separated configuration artifact (think XML wiring files, or graphical configuration tools) was paid almost entirely by human developers, who found it slower to read and slower to navigate than code-adjacent annotations. That cost was real, and it's a big part of why annotation-driven frameworks like Spring won the last decade.

AI changes that cost calculation. An explicit, structured index, a YAML file that names every function and every transition in an endpoint, located exactly where the URL structure says it should be, costs an AI assistant almost nothing to read and a great deal less to get wrong, because there's no implicit call-stack archaeology required. The structure that used to be a tax on humans is now a gift to the thing increasingly doing a large share of the maintenance work.

But that gift only arrives if someone pays a different tax: writing the documentation thoroughly enough, and unambiguously enough, that an AI assistant can pick up the new pattern from the docs alone, the way it picked up Spring from a decade of incidental exposure. Architecture innovation in the AI era may end up being gated less by "is this a good idea" and more by "is this idea legible to an AI that has never seen it before." That's a genuinely different bar than the one we used to optimise for, and it's one I think is worth more people paying attention to.

If you want to look at the actual schema, the tutorials, or try the conversion yourself, the starting point is the OfficeFloor REST tutorials, and the Spring PetClinic REST source is on GitHub if you want to attempt your own conversion and see where your AI assistant gets stuck. That's usually exactly where the next improvement to the docs needs to go.

OfficeFloor v4: from one graphical file to one YAML file per endpoint

OfficeFloor has always been about Inversion of Coupling Control: not just injecting objects, but injecting the functions and threading too. For a long time, the way you expressed that wiring was graphical, a single configuration file with boxes and lines showing how your functions connected together.

It looked great in a demo. It did not survive contact with a team.

The problem with one big graphical file

A single graphical configuration file is fine when one person is building the application. The moment a second or third developer starts working on the same project, that file becomes a bottleneck. Two developers add two different endpoints, both touch the same file, and now someone has to merge a diagram. Graphical formats don't merge the way text does. There's no good way for a merge tool to reconcile two sets of moved boxes and re-routed lines. You end up resolving it by hand, in the underlying XML, squinting at coordinates and generated identifiers to figure out what actually changed.

That's a hard sell to any team used to git merge just working.

One YAML file per endpoint

OfficeFloor v4 replaces the single graphical file with one YAML file per REST endpoint. The file name itself encodes the HTTP method and the URL path, so the configuration and the routing are the same thing:

src/main/resources/officefloor/rest/
  greeting.GET.yml        →  GET  /greeting
  greeting.POST.yml       →  POST /greeting
  greeting/{name}.GET.yml →  GET  /greeting/{name}

Inside the file, named steps wire functions together, each naming the Java class (and, where needed, the method) that implements it:

validate:
  class: ValidateGreetingLogic
  outputs:
    valid: build

build:
  class: PostGreetingLogic
  next: audit

audit:
  class: AuditGreetingLogic

That's the entire specification for a three step pipeline of validate, build, and audit, with conditional branching and sequential composition both declared right there in the file. None of the three classes knows about the other two. The YAML is the only place that knows the order and the wiring.

Why this works so much better for teams

Each endpoint now lives in its own small text file. Two developers adding two different endpoints touch two different files, so there's nothing to merge. Even when two people do need to touch the same endpoint, it's a small YAML file, not a generated diagram, so a normal text merge actually works. When it doesn't, the conflict is a few readable lines rather than a tangle of graphical coordinates.

It also turns out this same property of small, explicit, readable files is exactly what helps AI coding tools. An endpoint's steps, their order, and their branches are all explicit in one file rather than implied by a call stack. That gives AI tooling (and, frankly, any developer new to the codebase) a direct index into the small, focused functions behind each step, rather than something it has to reconstruct by reading framework conventions.

Built on Spring Boot, not instead of it

This change comes with v4 moving OfficeFloor onto Spring Boot as its base. Add the starter to your existing pom.xml, and you can start declaring endpoints as YAML files alongside your existing @RestController classes, with no migration required. Spring's dependency injection, security, persistence, and actuator configuration are untouched. Spring beans are injected into your step methods exactly as they would be into a controller. OfficeFloor isn't replacing Spring; it's taking over just the wiring of REST endpoints, where the graphical file used to live.

<!-- Add to existing pom.xml -->
<dependency>
  <groupId>net.officefloor.springboot</groupId>
  <artifactId>officefloor-rest-spring-boot-starter</artifactId>
  <version>4.0.2</version>
</dependency>

Where to go next

The tutorials walk through this in detail, starting with Spring REST for the basics of YAML endpoint files, and the Function Injection tutorial for multi step pipelines with conditional branching. From there the tutorials cover validation, exception handling, Spring Security, OpenAPI generation, thread injection, and more, all configured the same way, one YAML file per endpoint.

If you've used OfficeFloor's graphical configuration before, this should feel familiar in spirit and much easier in practice. The functions are still composed, not coded together. They're just composed in a format your team's tools already know how to handle.