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.

No comments:

Post a Comment