The Code You Don't Write
AI agents have changed the way we work as software developers. You can ask an agent to implement a feature, and code that would have taken an entire afternoon to write is ready in minutes, complete with automated tests and sometimes even fixes for problems we hadn't noticed.
The power of these tools is undeniable, but it's easy to get carried away and forget that the cost of the code they produce goes well beyond the time it takes to write it. What happens when someone, possibly even ourselves, has to work on that code six months or two years from now, with no trace left of the original conversation with the agent?
The cost of writing code has plummeted. The cost of maintaining it, not nearly as much.
The bill comes after the merge
When developing a new feature, the first thing we usually ask ourselves is how long it will take, including the time needed to write tests and make sure everything works correctly. Once the tests pass and the changes have been reviewed, we breathe a sigh of relief and consider the job done. In reality, the code we've just written has only just begun its life.
Every time we write code, we introduce potential bugs. Even when it works perfectly, the behaviors we've added will have to be taken into account when implementing new features. The same goes for dependencies and architectural decisions: everything we add to a project needs to be understood, updated, and maintained.
What matters isn't so much the amount of code we write, but the complexity we're committing to managing over time. The benefits of a change are immediately visible, while its costs often emerge months, if not years, later. This is one way technical debt accumulates: a series of decisions that seem individually reasonable but collectively make the system harder to maintain.
Adding complexity to a project is often unavoidable, and not every change creates technical debt, but the benefits should always justify the future costs.
The abstraction waiting for its second use case
One of the most common ways to introduce unnecessary complexity is to create abstractions before we actually need them. Imagine implementing a service with only one implementation: we could simply write the code it needs to work, or we could define an interface, a factory, and a mechanism for choosing between different implementations.
The second approach might seem better designed, since it would allow us to replace the implementation without modifying the code that uses it. But if we have no concrete reason to believe we'll ever need to do that, we're adding complexity to solve a problem that doesn't exist.
If we eventually need a second implementation, we can introduce the abstraction then, when we have two real use cases to work from. We'll probably be able to design it better, too, because we'll know which behaviors need to be shared and which should remain independent. Making that decision in advance means relying on assumptions that might turn out to be wrong.
Anticipating future requirements matters, especially when changing the architecture later would be expensive. But not every decision needs to be made immediately. Postponing some of them allows us to make better-informed choices when the time comes. Before introducing an abstraction, we should ask ourselves whether it addresses a real need or merely something that might come in handy someday.
A little duplicated code is fine
One of the best-known principles in software development is DRY (Don't Repeat Yourself), which tells us to avoid duplicating the same logic in different parts of the codebase. It's an important principle: when a rule is implemented in several places, we risk changing it in one place and forgetting to update the others, introducing inconsistent behavior.
The problem arises when we confuse duplicated logic with code that simply looks similar. Imagine two functions that perform similar operations but belong to different parts of the system and serve independent purposes. We could extract their common behavior into a shared function, removing the duplication and reducing the number of lines. On the surface, we've simplified the code, but we've also created a dependency between two components that could previously evolve independently.
When one of the functions needs to behave differently, we might have to add parameters or conditions to the shared function to handle those differences. Over time, what initially seemed like a good abstraction could become more complicated than the two original implementations.
Duplicating rules that need to remain consistent is certainly risky, but a few repeated lines can be preferable to an abstraction that introduces unnecessary dependencies. Before removing duplication, we should ask ourselves whether the two pieces of code really represent the same behavior and are expected to evolve together, or whether they just happen to look alike.
Dead code and code that's just sleeping
Another often overlooked source of complexity is code that exists in a project but isn't being used. The simplest case is dead code: old functions that are no longer called, obsolete implementations, or execution paths that can no longer be reached. Once we've confirmed they're no longer needed, we can remove them without much regret, especially since version control allows us to recover them if we ever need them again.
More interesting is what we might call dormant code: perfectly functional features, perhaps even covered by tests, that don't address any current need. It could be a plugin system nobody has ever used, a configuration option for a mode nobody uses, or an extensibility mechanism introduced to support possible future developments.
Unlike dead code, dormant code might actually become useful, which is precisely what makes it difficult to remove. In the meantime, it still requires maintenance: when we update a dependency, we need to check compatibility; when we change the architecture, we need to account for it; and anyone working on the project needs to understand how it works.
The situation is different for components that need to be available even if they're rarely used, such as an emergency recovery system. In that case, we're addressing a real need, however exceptional. The dormant code we should be concerned about is the code we implemented simply because we might need it someday. Meanwhile, the maintenance bill keeps coming, even if that day never does.
The boring solution
When solving a problem, we're often tempted to choose the more technically sophisticated solution, even when a simpler one would meet our needs perfectly well. Imagine we need to manage some application settings: we could use a configuration file, or we could build a control panel complete with a web interface, APIs, persistent storage, and access control.
The second solution certainly offers more features, but if those settings are changed a couple of times a year by an administrator who already has access to the repository, what are we actually gaining? We've added several components that we'll need to maintain, update, and secure, when a configuration file could have solved the same problem with a fraction of the complexity.
The same reasoning applies to performance. Optimizing an algorithm, introducing caching, or parallelizing an operation can bring significant improvements, but without measurements showing that a problem actually exists, we risk making the code more complicated without gaining any noticeable benefit. A cache, for example, introduces issues around invalidation and data consistency that might not be justified by the improvement it provides.
Choosing simplicity doesn't necessarily mean writing fewer lines of code, nor does it mean giving up performance when it's needed. It means preferring readable, predictable solutions that are easy to maintain, while avoiding complexity we can't justify with concrete requirements. A less sophisticated solution might not seem particularly interesting from a technical standpoint, but if it solves the problem without adding further responsibilities to the project, it's probably the one we should choose.
When the agent tries too hard
The tendency to overcomplicate code certainly didn't start with artificial intelligence. Developers have always been tempted to build more elaborate solutions than necessary, introducing abstractions, frameworks, and extensibility mechanisms for future requirements. Anyone with a few years of experience has probably had to maintain at least one of those architectures designed for a future that never arrived.
The difference is that AI agents have now made this kind of code extremely easy to produce. We ask an agent to handle an error and end up with a generalized error-handling system, or we ask it to add a parameter and it decides to introduce an entire configuration infrastructure. These changes can look like improvements, especially when they come with tests, documentation, and explanations justifying their usefulness. The result is something that appears more complete than what we originally asked for.
Before these tools came along, building a solution like that would have required a considerable investment of time, which would at least have encouraged us to question whether it was really necessary. Now that we can get the same result in minutes, we risk accepting unnecessary changes simply because they've already been implemented and seem to work correctly.
The time saved writing code, however, doesn't remove the need to understand and maintain everything that was generated. Judging an agent's productivity by the amount of code it produces or the number of improvements it introduces can therefore be misleading. A change involving just a few lines that solves exactly the problem we asked it to solve might be preferable to a much more elaborate solution, even when the latter is technically correct.
When an agent introduces a bug, we can identify it, reproduce it, and fix it. But if it produces a technically sound, well-structured solution that works perfectly, we might have no apparent reason to question it, even when it introduces complexity we didn't need. It's good code solving a problem we don't have.
The code you don't write
We're used to evaluating a developer's work through what they produce: features implemented, problems solved, pull requests merged. These are tangible results that we can show and, to some extent, measure. It's much harder to recognize the value of something we've deliberately chosen not to build.
We'll never know how many bugs we've avoided by leaving out an unnecessary feature, how many dependencies we won't have to update, or how much time someone will save years from now because they won't have to understand an abstraction we never really needed. These benefits are difficult to quantify, but they can make a considerable difference over the lifetime of a project.
With AI tools, we'll be able to produce more and more code in less and less time, but we can't assume that our ability to understand, verify, and maintain it will grow at the same pace. I therefore believe that a developer's role will increasingly depend on the ability to evaluate what's being produced: recognizing when a solution is unnecessarily complicated, when an abstraction is premature, or when a feature, however well implemented, adds no real value to the project.
We'll need to become more selective, even when adding something takes only a few minutes. Sometimes that will mean giving up an optimization, accepting a few lines of duplicated code, or postponing a decision until we have enough information to make it. Other times, it will simply mean deleting some of the code an agent has just written.
Writing code is getting easier and easier, but deciding which code deserves to exist is getting harder.
And the code we choose not to write will never need to be fixed, updated, or explained to whoever comes after us.