I am a principal engineer, and I started this experiment trying to defend something I have believed for years: invest in a good structure now, and later changes should get easier.
Two or three years ago, I would have pushed back on the claim that hexagonal architecture was mostly overhead. I wanted to show that the investment would also help a coding agent.
Across nine features, the hexagonal version took 38% longer to reach acceptance. That was not the result I had hoped for.
I still care about clean code. But this experiment made me much less willing to prescribe an architecture before I can explain what it buys us.
In my work, agents are changing how quickly we can write and ship code. I think that changes which engineering practices deserve our time. This experiment did not compare agents with humans; it tested a question about the code we ask agents to work in.
I am less interested in whether a method has crossed a line-count threshold than in whether I can find the pricing rule, trace a state change, and understand where a transaction begins and ends. I still want good names. I still want business rules to have a clear home. I still want tests that exercise the behavior that matters.
What I am questioning is indirection added by habit: the interface with one implementation and no clear boundary, the pass-through method, or the extraction that scatters one operation across several files without making it easier to understand or change. Every layer has a cost for the person and the agent that must read it. I now think that cost needs a concrete justification.
ChargeLedger gave me a way to test that belief. It is a Java service for an EV charging network. It turns a completed session into an invoice: energy used, time spent plugged in after charging completes, tax, and a total that must remain stable once stored. It is small, but its requirements were not trivial. A tariff change must not rewrite an old invoice. A retry must not refund twice. If an audit write fails, issuing the invoice must roll back with it.
Those are the kinds of guarantees I want software structure to make easier to preserve.
I prepared two implementations with the same baseline business contract, Java 21, Javalin, SQLite, Gradle, and dependency pins.
The hexagonal condition was asked to use a domain isolated from HTTP and SQLite, application use cases, ports, adapters, edge composition, and architecture tests. The flat condition received the same functionality requirements but an architecture-neutral prompt. “Flat” does not mean deliberately messy. It became a direct, coherent application with shared pricing, persistence, transactions, HTTP, and CLI behavior.
I compared two complete setups. Their starting code, architecture guidance and test suites differed. I used both the local model and OpenAI assistance to prepare the hexagonal baseline; the flat baseline was generated by the local model. I excluded that preparation time from the measured clock. The results therefore cannot tell us that hexagonal architecture alone caused anything, or whether either approach has a better total lifecycle cost.
I served the local model on my DGX Spark and ran OpenCode on my Mac. ChatGPT/Codex helped prepare the tasks, build the evaluator and orchestrate the runs. Only one participant job ran at a time, so the two projects were not competing for the model.
For every measured task, the agent got a frozen public contract in a fresh session. The controller then ran project tests and a separate public-interface evaluator. If a product-level check failed, it returned feedback for a bounded repair attempt. A run counted as accepted only when both gates passed. The timing is time to acceptance: agent work, project tests, external checks, and any repair. The clock stopped only after validation passed.
The baseline suite had 14 original tests. Across the study, all 30 measured runs passed their project and external acceptance checks. That means the implementations met the covered behavior. The suite can still miss things.
I gave both implementations nine cumulative feature tasks. They began with CSV export and an offline invoice CLI, then changed idle billing while keeping historic invoices fixed. They added issuance audit events and atomic rollback behavior, then effective-dated tariffs, partial refunds with concurrent balance protection, and tenant routing across the service.
I expected the architectural investment to become useful as that surface area grew.
Instead, the flat trajectory reached acceptance in 165.93 minutes. The hexagonal trajectory took 228.57 minutes: 37.8% longer. Upfront preparation was outside both clocks.
The time budget was 30 minutes per task for F1–F6 and 90 minutes for F7–F9, with the same budget for each side of a pair. Each task occurred once per condition, so those lines are not a statistical trend. They connect outcomes from a cumulative sequence whose task difficulty and validation scope changed over time. But they are still the result that made me . There were individual wins for hexagonal, but no overall speed advantage.
The trace data did not offer a simple explanation. Across F1–F9, the flat runs logged 31.25 million input tokens and the hexagonal runs 53.40 million. Those totals include context repeatedly supplied across model steps. They are not billed dollars, GPU work, or unique repository text. Hexagonal also logged more model steps and tool calls. Those are observations about this setup, not a verdict on an architecture.
My first response was that the feature sequence might have been too easy. So I added six harder contracts: calculate a quote without creating a row, void a refund while preserving history, import NDJSON offline, create and deliver transactional outbox events, reconcile daily tenant balances, and paginate invoices with a tenant-bound keyset cursor.
These were not F10 through F15. Each challenge started independently from its condition’s accepted F9 checkpoint. None inherited the previous challenge’s implementation.
Across the six challenge pairs, flat took 161.55 minutes and hexagonal took 174.24 minutes, about 8% longer for hexagonal. Hexagonal was faster on two tasks: batch import and transactional outbox delivery. Flat was faster on the other four.
That is a more mixed result. I cannot treat the smaller gap as evidence that hexagonal would eventually overtake flat: these were different tasks on separate branches, with one run per condition.
The input-token totals were also higher in the structured condition: 33.69 million flat and 51.23 million hexagonal across the challenges. Again, repeated context is part of those values. They are evidence about recorded agent activity, not a price tag or a quality metric.
I then looked at the largest matched challenge versions: C2, refund voiding.
The flat version had 4,072 production Java lines in 37 files. The hexagonal version had 4,939 lines in 85 files. Flat’s largest production file accounted for about 36% of its production lines; hexagonal’s largest was about 18%.
Flat concentrated more code in one place; hexagonal spread it across more files. That gives us something concrete to inspect, but a file count cannot tell us which system will be easier to maintain.
The code review added some context. The flat code put shared pricing in Pricing and issuance in SessionRepository.issue. Hexagonal used PricingPolicy, LedgerService and ports. At F9, more persistence and migration work sat in the flat repository, while hexagonal separated schema migration and tenant wiring across roles. Both had shared business operations and transaction handling. Neither was a caricature.
The tradeoff is real. The direct path can be easier to trace. The separated path can make dependency direction and role boundaries explicit. I no longer think I should assume either benefit wins before I can name the problem it solves.
These are small applications. That limits the conclusion, but focused services are a relevant setting too. A microservice does not need to become a large codebase before we can ask whether its structure is helping. What we have here is a few thousand production lines with some demanding rules, not evidence about a whole enterprise system.
My conclusion is an opinion informed by the experiment, not a rule established by it.
Start simple. Add a boundary when it isolates a dependency, separates a real responsibility, protects a volatile integration, or makes an ownership decision clearer. Do not add it because every service is supposed to have another layer.
At the same time, simplicity cannot mean “throw it all in one file and trust the agent.” I want one place to change the tariff-selection rule. I want an idempotency key to have a clearly understood scope. I want invoice issuance and auditing to have a traceable all-or-nothing transaction. I want names that tell the next reader where those decisions live.
And I want stronger behavioral evidence. When code is cheaper to generate, a common external acceptance suite, regression checks, and explicit failure cases become more valuable. Tests only establish what they cover, but that is still much better than treating a confident agent response as proof.
This also changes how I think about reviews. A nitpick about a line break or another mechanical convention is often a good candidate for automation. A blocking review comment should explain the concrete consequence it prevents: duplicated state, an unsafe retry, an unbounded query, a broken tenant boundary, or a transaction that can partially commit.
There is another possibility: larger context windows and better retrieval may help agents make use of modular structures more effectively, not just direct code. My hypothesis is that stronger context may expand the range where a simple implementation works well, but this experiment did not test that boundary.
Proofs of concept also become production more often than we plan. If someone has to investigate an incorrect invoice during a SEV1, they need to find the decision and understand the state change. The benchmark clock stops at acceptance; ownership does not. I want to spend engineering effort where I can explain the behavior it protects or the change it makes easier.
This was one model/harness setup, one service, and 15 paired tasks, each run once per condition. The local service was labeled Qwen3.8FlashNext for the project, but frozen records identify the served alias as qwen; the exact checkpoint and server settings were not independently verified. The evaluator changed through documented amendments, baseline preparation was excluded, and the treatments differed in more than architecture.
The result changed my mind about a default I used to defend. It leaves plenty of questions open.
The full method, frozen contracts, acceptance data, figures, and limitations are in the technical report:
Read the technical report and download the data Stoyanov, K. (2026). Hexagonal architecture and coding agents: an exploratory study of change acceptance in ChargeLedger (Version 1.0). Zenodo.
AI systems assisted with planning, orchestration, analysis, figures, and drafting; the report’s method section describes the coding-agent participant and the limits of the evidence.
I am hyped to share this and hear from people working through the same questions. In particular: what layer, interface, or clean-code rule has earned its cost on your team—and what evidence convinced you?