Dieser Blogpost ist auch auf Deutsch verfügbar

TL;DR

  • AI changes the trade-offs of software development, not the goal.
  • Don’t spend AI’s productivity gains only on more features.
  • Reinvest the time into quality, technical debt, and reusable abstractions.
  • Optimize for maintainable software, not maximum throughput.

With the advent of agentic programming, we’ve proven that we can produce loads of code at an astounding rate. But what effect does that have on maintainability? And how can we use coding agents to not only improve feature output, but also increase the quality of our codebase at the same time?

When thinking about this issue, the project management triangle keeps popping up in my head.

The project management triangle is visualized as a triangle with quality in the middle and scope, cost and time in each corner.
The Project Management Triangle

The idea behind the project management triangle (also known as the iron triangle) is that there are real-world constraints between scope, cost and time. In a limited period of time, you can either choose to cut your scope of features or increase budget and add an extra developer to work on other features which need to be delivered by the deadline. If you insist on rolling out features without increasing budget or decreasing scope, this will likely result in you not hitting your deadline and almost certainly will result in an overall decrease in code quality.

For decades, the constraints of project management have pretty much held steady, which allowed us a certain degree of predictability. One of the reasons why AI feels so revolutionary is that we are now dealing with a completely different triangle than we are used to. The “iron triangle” has, through the investment of a great deal of energy (pun intended), become a new “steel triangle” with different properties: for problems with a preexisting solution, a feature with a specific scope can be developed in much less time and for a fraction of the cost.

Cut Scope to Reach a Deadline

Extending a deadline will cause a knock-on effect of not being able to reach other goals. And before AI, throwing money at the problem was unpredictable (adding more developers doesn’t always increase throughput). My favorite tool for delivering on time has always been to cut scope.

A specific feature can be broken down into a collection of “must-have” and “nice-to-have” subtasks. When implementing these tasks within a constrained period of time, we ensure we can reach the deadline by only implementing the must-haves and perhaps one or two nice-to-haves. Nearing the end of the deadline, we frequently make trade-offs and deliberately take on technical debt in order to deliver.

A stack of must-haves and nice-to-haves show what subtasks belong to a feature. When positioning these on a timeline with a deadline, the must-haves take up most of the time and many nice-to-haves fall away. Inevitably, some unplanned technical debt is also accrued to meet the deadline.
Cutting Scope

Although this is still a valid strategy to have in your toolbox, with AI we now have different options for how we can deliver software within the time constraints that we have.

Deliver More Features in the Same Timeframe

The naive approach would be to just try to deliver more features in the same amount of time that we have.

In our example, when the must-haves and nice-to-haves from our original feature with their corresponding technical debt can be delivered in a fraction of the time, we can squeeze in other features with their corresponding technical debt before the deadline is reached.

The same features and technical debt for what is now labelled as Feature A are compressed on the timeline, giving room for squeezing in a Feature B and Feature C with corresponding technical debt before the deadline is reached.
Delivering as many features as possible

This is using AI to maximize feature output, which can definitely be a valid approach, especially if you have a lot of features to deliver and a deadline is looming. The issue with this is that by putting our focus on feature throughput without making a conscious effort to combat technical debt, we are going to accumulate technical debt at a faster rate.

Remember that in real life, we’ve always made trade-offs and accumulated technical debt (hopefully intentionally) in order to deliver on time. With AI, we are able to deliver faster, but the AI will also work like an amplifier, and we will manage to accumulate the same amount of technical debt we would have accumulated without AI, but in a fraction of the time.

With AI, it’s also quite possible that we will accumulate more technical debt than we otherwise would have, because the models are very good at generating verbose, over-engineered solutions to get the job done.

This is one of the reasons I’m skeptical about the current trend of merging code without a human having a clear understanding of what that code does. In the short term, this can result in bugs and unwanted behavior. But in the long term, it also results in an increase in overcomplicated code. LLMs are next-token generators, and as such they are proficient in generating solutions with a lot of code. They are less proficient at being succinct. I am also skeptical about an agent running on a loop which would scan the codebase and clean up the code written by other agents, because it is easier to evaluate whether you are going to need the code before it ends up in the codebase. Once it is merged, there is also a high likelihood that other agents are going to mimic that suboptimal code, and the bloat is a gift which is just going to keep giving.

There’s a certain gut feeling that I’ve developed over the years when I see overly complicated code. A certain revulsion. Some would say that we need to retrain our gut reactions and accept code that we otherwise would have rejected, so that we don’t lose the efficiency gains the model has given us during our code review.

This made me wonder why I have that gut feeling in the first place. What was the point of clean code to begin with? I don’t think that having clean code was the goal in and of itself.

The goal was to have software which is well-maintained and easily extensible. So how can we achieve that and still get all of the benefits of AI as well?

Increase Time Spent on a Feature to Improve Quality

One approach we can take is to slow down a tiny bit and take the time that we need to really round out the feature, clean up the code, and deliver it without making trade-offs which will increase the technical debt in our codebase.

The must-haves and nice-to-haves from our feature task breakdown can now be implemented quickly enough to deliver a feature before a deadline without accumulating further technical debt.

All of the must-haves and nice-to-haves can now easily fit on the timeline with a lot of space before the deadline. Here there is no technical debt which is accumulated.
Invest Time in Improved Quality

This requires a change in mindset, from focusing on delivering output to focusing on delivering output with quality. Even with this change in mindset, we will often still deliver before we would have in a previous world, but here we are intentionally investing more time into the end product to ensure we leave the codebase no worse off than we found it. Note that I would still break down the feature into smaller subtasks to keep the pieces of work small enough for my mental context to deal with.

Leave It Better Than You Found It

The logical next step would be to intentionally plan in time to continually make our codebase better than we found it.

Here I don’t necessarily mean that we dive into our project backlog and pull out the gnarly, complicated refactoring tickets that we’ve been avoiding (although that is definitely an option).

What I’m talking about here is just intentionally building in time at the very beginning of our workflow to have a look at the codebase and at the code we are going to need to modify, and see if there are any smaller tasks that I can take the time to implement now which will make the codebase better once I’m finished with the task that I actually want to begin.

In addition to the must-haves and nice-to-haves which we will still implement, we schedule a block of time at the very beginning of our task to clean up technical debt. In total, we are still able to deliver everything long before the deadline arrives.

The feature development has now been pushed slightly back, giving time for two big technical debt cleanup jobs. All of the work still is completed long before the deadline.
Scheduled Time for Technical Debt Cleanup

In practice, I find that my eagerness to begin coding the “real feature” is often what makes this more difficult. Even though it now takes me a lot less time to implement a feature, that thought still often doesn’t register when I’m planning how to implement a feature. When I see an old TODO in the code, I often have the desire to ignore it because that’s not what I’m working on right now. But if I take the time to have a look at it, it isn’t difficult to plan in a bit of time to take care of it and clean up the codebase a little bit at a time.

Investing Time in Developing Libraries and Reusable Abstractions

In addition to investing time to clean up the codebase and leave it better than we found it, another option that we can consider is to invest time to implement internal libraries and reusable abstractions for our system which will make it easier to implement features in the future.

After scheduling the technical debt cleanup, we can schedule a block of deep work time to work on internal libraries and reusable abstractions which we can then use to complete the must-have and nice-to-have features in a fraction of the time. The time invested in the libraries and abstractions may be more than that of implementing the tasks without them, but we will likely find that the total development time of all tasks, abstractions, and technical debt cleanup will still be well within the deadline we would have set for the feature without AI support.

Between the technical debt and feature development, a block of time has been designated for implementing internal libraries and abstractions. The must-have tasks are now shown to take less time for implementation and the entire body of work still comfortably fits in the timeline with some buffer before the deadline.
Internal Libraries and Reusable Abstractions

One reason this is particularly useful is that AI models will often generate solutions which use a lot of boilerplate and thereby bloat the codebase, but they are also really good at understanding and using a library to do something similar.

This is not necessarily a new idea. At the beginning of my current project, I invested time in creating form helpers for our Spring Boot applications to create an abstraction in our pattern library to automatically connect the Spring validation errors to our HTML templates and make sure that the labels and errors are rendered in an accessible way. That investment in our codebase has resulted in much less boilerplate when dealing with form submissions. With the help of AI to try out different approaches, I recently extended the abstraction to include a custom element and an API contract for a field which allows us to have live validation of a form field before form submission. Now that we’ve found an abstraction that we like, that is also something for which we will not require any boilerplate in the future.

Another example is a bit of work I did recently to generate custom annotations for the legacy permission system to which we need to connect our system. This bit of work means we only need one line of code instead of three, but it’s much more readable, and since we have already used it frequently, it will result in significantly less boilerplate in the codebase over time.

For this kind of work, you really need to set a substantial timebox for the work that you want to get done. In the past, we used timeboxes to help rein in scope when it got out of hand. With the advent of AI, I find them additionally useful as a reminder that good work takes time. For deep work tasks, the bottleneck was always the speed at which I am able to think, not the speed at which I am able to code, and that hasn’t changed, although I am able to prototype and discard my ideas much more rapidly. After performing deep work for a period of time, I’m also increasingly impressed by the difference between the deep work (which feels so slow) and routine work (which feels almost instantaneous using AI). I think this is a perception issue. Even though I feel like the deep work is slow, I usually still deliver more quality in less time than before AI, and I’m also developing abstractions that will provide the building blocks to write better code at a quicker pace in the future.

Conclusion

The real-world constraints of scope, cost and time as relating to quality are still very much in effect, but the constraints are just radically different than they were before AI, especially since for routine tasks the amount of time and cost it takes to implement a feature is drastically reduced. That being the case, we should take the opportunity to reconsider how we are planning our work. We can, of course, try to deliver as many features as humanly possible, but to create a codebase which is extensible and maintainable in the long run, it would be a good idea to reinvest some of that time/money won by AI into the software to make it more maintainable. Just think. With AI, it might actually be possible for us to create the perfect software. Wouldn’t that be something?