The plugin built, packaged and published on the first try. I could not have explained the packaging step to anyone, and I shipped it anyway. After several months of building games, static sites, libraries and plugins with an artificial intelligence (AI) assistant doing most of the typing, that sentence is the whole story: producing code stopped being the hard part, and something else moved into its place.
The picture to hold in your head
The scarce thing in software engineering is no longer producing code. It is knowing what the code should mean, deciding where it belongs, and proving that the whole adds up to a working system. Everything below is an elaboration of that one shift, seen from the inside of a lot of small projects.
I want to be careful with the word shift, because the most obvious objection to the thesis is that it is not new. In 1986 Fred Brooks split the difficulty of building software into the essential part, the conceptual construct of what the system is and does, and the accidental part, the friction of expressing that construct in a particular language, toolchain and environment, and argued in No Silver Bullet that the essence was always the real cost. Joel Spolsky added the other half of the picture in 2000: it is harder to read code than to write it. Understanding was the bottleneck long before any model could write a function.
So the bottleneck did not move. What moved is the price of the part that used to hide it. Typing out the accidental complexity was cheap enough to feel like the work, and expensive enough to pace the essential thinking so that it kept up. Now the accidental part costs almost nothing, and the essential part stands there undiluted. Spolsky's asymmetry has become the whole job: I write almost nothing and I read everything.
Where the speed came from
The speed was real, and it came from a specific place. In a few months I went through a browser game with a hand-rolled loop, a documentation site on a generator I had never configured, a small library that had to ship two module formats, an editor plugin with its own manifest and packaging rules, and the deployment for each of them. Every one of those involved a layer of the stack where my knowledge was shallow: build configuration, an unfamiliar application programming interface (API), a hosting provider's conventions. Each used to cost me an evening of documentation. Each became a twenty-minute conversation.
The pattern only became clear when I looked at where the gain was not. The assistant helped most where I knew least, and least where I knew most.
| Kind of work | Speed gain | My ability to judge the result |
|---|---|---|
| Build tooling, bundling and packaging | ||
| Unfamiliar framework or API setup | ||
| Deployment and hosting details | ||
| The domain logic of my own product | ||
| Architecture, boundaries, ownership |
Read the two columns together and the uncomfortable part shows itself: the tool was most useful exactly where I was least able to check its output. That is not a coincidence. The gain comes from not knowing, and not knowing is also what makes the result hard to judge.
This also reconciles my experience with the study everyone quotes against it. In 2025 Model Evaluation and Threat Research (METR) ran a randomised trial with sixteen experienced open-source developers working on their own repositories, codebases of a million lines or more that they had contributed to for years. With AI tools they took nineteen percent longer, while believing they had been twenty percent faster. A year earlier the DevOps Research and Assessment (DORA) report had found AI adoption associated with lower delivery throughput and stability at the organisation level. Neither contradicts what I saw. Those developers were standing at the deep end of the table, where the gain is smallest and the checking cost is highest. I was standing at the shallow end, on small solo projects with no legacy and no teammates, which is the friendliest terrain the tool will ever get. "AI made me faster" turns out to be a claim about where you are standing, not about the tool.
The gaps it exposes
Delivering something without deep knowledge of every mechanism underneath it sounds worse than it is. Abstraction has always worked that way: I do not know how my compiler allocates registers, and nobody thinks I should. The assistant extended that comfort one layer up, into tooling and configuration I would otherwise have had to learn by hand. Less time fighting incidental complexity meant more time on the product and the actual problem, and that trade was worth it every time it was really incidental.
The catch is that the gap does not go away. It gets parked, and it gets parked in precisely the layer I cannot inspect. Two weeks after the plugin shipped, its packaging broke on a version bump. I had no model to reason from, so I asked again, and the second answer was locally plausible and wrong. I could not tell, because the knowledge I had skipped was the knowledge needed to tell. That is understanding debt, and like the financial kind it is cheap right up until it is called.
The distinction I now use is Brooks's. A gap is a feature when it sits in accidental complexity, and a debt when the mechanism turns out to be load-bearing for my product. I do not need to understand every layer, but I do need to know which layers my product depends on, and I need to be able to explain those ones without help. The freed time is real, and it does go to the problem, but only if I keep the accounting honest about which gaps I am carrying.
Locally correct, globally incoherent
The gaps in my knowledge were the smaller problem. The larger one appeared in the code that was, line by line, entirely correct.
In the game, "completing a level" came up three times over a few weeks: once for the score screen, once for the unlock logic, once for saving progress. Each feature was requested on its own, and each was implemented well. One counted collected stars, one checked the finish time against a threshold, one read a flag from saved state. Every implementation was reasonable in isolation. The game was wrong, because a level could be complete on one screen and incomplete on the next, and no single piece of code was to blame.
Peter Naur described what was missing in 1985. In Programming as Theory Building he argued that a program is not its source text but the theory the programmers hold about how it solves the problem, and that the text alone cannot carry that theory to anyone who does not already have it. An assistant produces text that fits the surrounding text. It can be fed a great deal of context, and it uses it well, but it does not hold the theory across the weeks, and it is not accountable for the theory being one thing rather than three. A system composed of hundreds of reasonable local decisions can still be incoherent, and the incoherence lives nowhere in particular.
This failure is old. Large teams have always produced locally sensible, globally muddled code; it is most of what architecture exists to prevent. What is new is the volume and the speed. When I typed every piece myself, each one cost enough that I carried a slice of the theory with it whether I meant to or not. Now nobody carries it unless I do so deliberately. I wrote about the review side of that problem already; this post is about the design side, and it comes down to three habits that the months kept reinforcing.
One fact, one owner
The first habit is single source of truth, and it needs saying precisely, because the phrase is often taken to mean "put everything in one global store". It means the opposite of a pile. Every concept in the system should have exactly one authoritative representation, with clear ownership, and every other appearance of it should be derived from that one by a known path. That applies to state, to schemas, to configuration, to domain rules, to contracts between parts, and to computed data.
Derived copies are fine. A cache, a read model, a type generated from a schema, a memoised total: each is a copy, and each has an owner and a refresh path, so when the source changes the copy follows. The copies that hurt are the independent ones, where the same fact has been re-derived from scratch at each place it is used, because that was the locally cheapest thing to do. That is the assistant's natural failure mode, and it is exactly what happened to "level complete".
There is a legitimate exception, and domain-driven design named it long ago: a bounded context. "Customer" in billing and "customer" in support are genuinely different concepts that share a word, and forcing them into one model makes both worse. The rule survives the exception: one owner per concept per context, with the translation between contexts explicit at the boundary, rather than one global model of everything. Which is a way of saying that single source of truth is an ownership rule, not a storage rule.
Knowing what the system means
Ownership rules only help if someone knows what the concepts are, which is the second habit: domain knowledge, in the strong sense. Not "how does this function work" but "what is this system supposed to mean". An assistant will generate a refund flow that works for the example it was given and never notice that an order must not be refundable twice, or introduce a Profile beside an existing User and a Player because the prompt used a different word. Those are domain invariants and domain concepts, and they are invisible in any individual file.
The practical consequence surprised me. Holding the theory in my head was not enough, because the tool cannot see my head. Whatever the system is supposed to mean has to be written down where the tool will read it: in the types, in the schemas, in the tests, in a short glossary of domain terms in the repository. Naur's theory has to be externalised if a generator is going to respect it.
That also answers the strongest objection to "the engineer must maintain the global mental model". In a large organisation the model never fit in one head; it lived in documents, ownership maps and architecture decision records, or it did not live at all. On my solo projects the theory could live in my head, and the lesson was that even there it had to be written down. In a team the lesson is the same with the stakes raised: the artefacts that let people share a theory are the artefacts that let a tool share it too.
Boundaries matter more when code is free
The third habit is the one I now spend the most deliberate time on: deciding where code belongs. When producing code is expensive, structure gets some protection for free, because nobody adds a module lightly. When producing code is nearly free, the cheapest local move is always "put it here", in the file that is already open, and a few hundred cheap local moves later the layers are gone.
Boundaries survive that pressure only when they are real. An assistant respects a boundary that exists as a package split, a lint rule on imports, a schema at the edge, a contract with a test behind it. It walks straight through a boundary that exists as a convention in my head. So the questions that used to feel like luxury on a small project, which component owns this, which layer may import which, how do two domains talk, became the questions I answer first, before asking for any code at all. The tooling can then produce inside a shape rather than produce the shape.
There is an economic reason this matters more rather than less. Cheaper code does not mean less code; it means more of it, the way cheaper fuel means more driving. Every one of my projects ended up larger than its hand-written equivalent would have been, and larger systems need more structure, not less. The cost of a missing boundary is paid on every read, and reads are now the only thing I do.
Validating a system, not a function
Structure tells you where things go. It does not tell you whether the product works, and that question got harder in a specific way. When the same tool that wrote the code also writes the unit tests, the tests tend to describe the implementation rather than the intent. They pass by construction. Green is no longer evidence of much.
The check that resisted this best was end-to-end (E2E) testing of the critical user journeys: start a level, complete it, see it unlocked, reload, still unlocked. An E2E test exercises the product from the outside, against what it is supposed to do, and it cannot be satisfied by code that agrees with itself. It is the least gameable signal I have, and it is the one that caught the three-definitions bug.
That is not an argument for E2E instead of everything else. Martin Fowler's test pyramid is still right that end-to-end tests are slow, brittle and expensive, and that most feedback should come from lower, faster levels. Unit and integration tests still give the fastest and most precise answer about a single module's contract. The point is what each layer can and cannot tell you now.
| Layer | What it catches | Feedback speed | Can generated code pass it without being right? |
|---|---|---|---|
| Static analysis and types | Wrong shapes, wrong wiring | ||
| Unit tests | One module's local logic | ||
| Integration tests | Two or more parts agreeing | ||
| Contracts and schemas | Disagreement at a boundary | ||
| E2E on critical journeys | Whether the product works | ||
| Observability | What actually happens in use |
Quality, then, is not a test suite. It is the combination of static analysis and types, focused unit and integration tests, E2E tests on the journeys that matter, explicit contracts and schemas, observability, and an engineer who actually understands the domain. The last item is not a soft addition to the list. It is the thing that decides what the other five should check.
The attention dividend
All of this sounds like more work, and in a sense it is. But the hours came from somewhere, and where they went is the part of the experiment I would not give back. Implementation and experimentation got cheap enough that I built three versions of a feature in an afternoon and threw two away. A year earlier, the cost of building the first version would have made me argue for keeping it. Cheap experiments changed the questions I could afford to ask: should this feature exist at all, how should it behave, does anyone actually benefit from it.
Two honest caveats. The dividend only pays if it is spent on those questions, and the pull is to spend it on more features instead, which is how the larger systems from the previous section come about. And on a solo project the product questions were also mine to answer; in a team, that attention lands in conversations with the people who own the product, so the shift is partly a shift in who the engineer spends the day talking to. It is still a shift toward the problem and away from the typing, and that is the direction I would choose.
What I would keep
Code generation becoming cheap has not made engineering knowledge less valuable. It has moved the point at which that knowledge provides leverage: away from the keystrokes and toward ownership, invariants, boundaries and the checks that verify the whole. An assistant can generate an enormous amount of locally correct code; what it cannot reliably provide is a coherent mental model of the entire product, and holding that model, and writing it down where the tool can see it, is now the engineer's job.
The lesson I keep coming back to is the smallest one. The faster software becomes to produce, the more it matters to know whether we are producing the right software, with the right structure. Code got cheap. Coherence did not.
References
- Fred Brooks, No Silver Bullet: Essence and Accident in Software Engineering (1986)
- Joel Spolsky, Things You Should Never Do, Part I — Joel on Software (2000)
- METR, Measuring the Impact of Early-2025 AI on Experienced Open-Source Developer Productivity (2025)
- DORA, Accelerate State of DevOps Report 2024
- Peter Naur, Programming as Theory Building (1985)
- Martin Fowler, Bounded Context — martinfowler.com (2014)
- Martin Fowler, Test Pyramid — martinfowler.com (2012)
