Skip to content Skip to footer

The Simple to Fix Doctrine

Moving Away From Easy to Write Code in the Age of AI

TL;DR

In the world of software engineering, we have optimized for reducing the lines of code written in a project, which is what I call an "Easy to Write" doctrine. This may have served us well when we had to write code by hand, but in the age of LLM's, writing lines of code is no longer time-consuming. It is time to acknowledge advancements in technology and adopt a new doctrine: "Simple to Fix".

Software Engineering
Software Architecture
Software Engineering Management

Large gray stone Gothic-style church with a tall square tower, pointed-arch windows, and buttresses, set on a hillside with patches of snow, evergreen trees, and mountains in the background under a bright blue sky with scattered clouds.
The Cadet Chapel at West Point

Introduction

Recently I was working on a Java Spring codebase that was using Hibernate as an ORM and was dealing with a performance issue with querying the database. For context, the Spring application pre-loads the whole database, measured in the megabytes, into memory, but it was taking about 15 minutes to do it. The code looked something like this:

List<Order> orders = orderRepository.findAll();

And the repository:

import org.springframework.data.jpa.repository.JpaRepository;

public interface OrderRepository extends JpaRepository<Order, Long> {
}

Looking at just this code there is no way to see what is wrong from a performance standpoint. You have no idea what queries will be run which, to be fair, is the point of an ORM, but that means performance issues are hard to see. In order to fully understand this code, you still need additional information, which are the class entities:

import jakarta.persistence.*;
import java.util.List;

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String customerName;

    @OneToMany(mappedBy = "order", fetch = FetchType.EAGER)
    private List<OrderItem> orderItems;

    public Long getId() {
        return id;
    }

    public String getCustomerName() {
        return customerName;
    }

    public List<OrderItem> getOrderItems() {
        return orderItems;
    }
}
import jakarta.persistence.*;

@Entity
@Table(name = "order_items")
public class OrderItem {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String product;

    private int quantity;

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "order_id")
    private Order order;

    public Long getId() {
        return id;
    }

    public String getProduct() {
        return product;
    }

    public int getQuantity() {
        return quantity;
    }

    public Order getOrder() {
        return order;
    }
}

You’ll see a lot of Java annotations, but the relevant one is the EAGER fetch type. It means we’re getting additional child records from another table. This code in oversimplified form might be fine, but we had many eager fetch types in our entities, and for whatever reason Hibernate was converting the queries into N+1 queries.

To quickly explain what an N+1 query is, let’s rewrite the code.

import javax.sql.DataSource;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;

public class NPlusOneExample {

    private final DataSource dataSource;

    public NPlusOneExample(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public void printOrdersAndItemCounts() throws SQLException {
        String ORDER_SQL = """
            SELECT id, customer_name
            FROM orders
            """;

        String ORDER_ITEMS_SQL = """
            SELECT id, order_id, product, quantity
            FROM order_items
            WHERE order_id = ?
            """;

        // Gets all orders
        try (
            Connection connection = dataSource.getConnection();
            PreparedStatement orderStmt = connection.prepareStatement(ORDER_SQL);
            ResultSet orderRs = orderStmt.executeQuery()
        ) {
            while (orderRs.next()) {
                long orderId = orderRs.getLong("id");
                String customerName = orderRs.getString("customer_name");

                List<OrderItem> orderItems = new ArrayList<>();

                // Calls the database to get the order items for each order
                try (PreparedStatement itemStmt = connection.prepareStatement(ORDER_ITEMS_SQL)) {
                    itemStmt.setLong(1, orderId);

                    try (ResultSet itemRs = itemStmt.executeQuery()) {
                        while (itemRs.next()) {
                            OrderItem orderItem = new OrderItem(
                                itemRs.getLong("id"),
                                itemRs.getLong("order_id"),
                                itemRs.getString("product"),
                                itemRs.getInt("quantity")
                            );
                            orderItems.add(orderItem);
                        }
                    }
                }

                System.out.println(
                    "Order " + orderId +
                    " for " + customerName +
                    " has " + orderItems.size() + " items"
                );
            }
        }
    }

    public record OrderItem(long id, long orderId, String product, int quantity) {
    }
}

You can see we have a nested for loop that is calling the database for each iteration which is what an N+1 query is. You query the database once to get the first N records then N more times to get their “child” records. Now in our second code example, while more verbose, I don’t think there is any debate that it is easier to identify where the issues are, and even more importantly, how to fix them. You simply need to do a left join to fetch all the data at once and then do logic on the Java side to properly map the SQL result to the Java objects.

What I ended up doing was having Codex, which is an LLM CLI tool, write a bunch of raw SQL queries and completely bypass Hibernate. This made it very simple to write high-performance queries. The startup time for our application went from about 15 minutes to about 15 seconds. That is still pretty slow given the size of our database, but it was a massive improvement given how little was needed to get this gain.

That’s when I realized something: I would have never done this without an LLM because it would have been too much code to write, and it would have been too easy for me to create a subtle bug. LLM’s, however, are great a writing tedious boilerplate code. Simply put, LLM’s are creating a need to reevaluate what is important from a Developer Experience. We currently have embraced an “Easy to Write” Doctrine for software engineering meaning that things like Backend/Frontend Frameworks, C Macros, and ORM’s are designed to reduce the number of lines written. The cost is that it is much more difficult to debug when things go wrong. In my example using Hibernate to put together queries, it only took a single line of code to execute the query, but when I wrote raw SQL queries I had many more lines to read, but it was easier to identify the issue. I think worrying about how many lines of code are written is now an obsolete way of thinking since LLM’s can write code so quickly. We need to embrace a different approach which is being simple to fix when the code breaks.

Military Doctrines

When I was unemployed for a few years, I would play a game called Hearts of Iron 4 which is a military strategy game which takes place during World War 2 where you take control of any country, yes any country, and try and win the war. I feel like this game taught me a lot about how to think strategically. Every country in the game has some limit on its resources, and you must employ a strategy that is optimized for your strengths and accounts for who your enemies will be. One aspect where this plays out is picking a Military Doctrine which is essentially forces you commit to a way of organizing your country’s resources to support your armies. In Hearts of Iron 4 you have four land doctrines to choose from.

  • Mobile Warfare
    • You commit to moving fast with vehicles to encircle enemy troops.
  • Superior Firepower
    • You commit to using an overwhelming amount of artillery to soften up the enemy when attacking.
  • Grand Battle Plan
    • You commit to spending more time planning an offensive, so your attacks are more efficient.
  • Mass Assault
    • You commit to overwhelming the enemy by sending a large amount of troops when attacking.

A country like the Soviet Union probably will pick Mass Assault since they have a huge population, but they probably would fail if they use a Mobile Warfare doctrine since they do not have the infrastructure to mass produce tanks at a large scale. A country like Canada, conversely, does not have a huge population, so a Mass Assault doctrine would exhaust all of their manpower.

Why am I bringing up doctrines? Because I think it’s important to understand your strengths and weaknesses when designing software at a higher level. To give another other military example, when the first world war started many of the tactics used in wars past were completely obsolete due to technological advancement and needed to be modernized. Charging into machine gun fire was not effective. However, there was another extreme where people believe too much in the technologies, Giulio Douhet believed it was possible to win a war through the use of air power alone, but that has yet to be shown to work to this day.

For us, we have a range of attitudes about LLM’s from predicting all software engineers will be replaced to believing there will be little to no impact, although I think most, if not all, software engineers have accepted that they’ll be using AI to code in some capacity from here on out. I think it is time for us to adopt a new doctrine that is realistic about the current strengths and weaknesses of LLM’s.

The New Doctrine

I think we should adopt the “Simple to Fix” Doctrine. Note that I intentionally chose the word “simple” over “easy”. I’m using Rich Hickey’s definitions of simple and easy here which is to say that simple means not tangled and easy means to be near at hand. Easy is the consequence of reducing effort by using something like Hibernate to write your queries, whereas simple is using raw SQL queries so that you have a much better understanding of how your code will behave at runtime. Why did I focus on fixing as opposed to saying something like “Simple to Maintain”? In my experience, all the projects I have worked on where I had no problems identifying and fixing bugs or performance issues were projects I felt were successful, no exceptions. Being able to rapidly identify and fix bugs or performance issues also means the risk is much lower that you will write them to begin with. This was the case when I bypassed Hibernated to do my SQL queries. Also, it is easy to know for sure if you are having a hard time fixing your code, but the definition of maintainable is a bit more vague.

Job Security

I want to close with thinking about our job security as software engineers. I think we have all heard, half jokingly, that having difficult to maintain and understand code is job security, but that is no longer the case. LLM’s are great at pumping out code that can be maintained by no one. The advantage a professional has is their ability to know which lines of code are better to write than others. When you adopt a “Simple to Fix” Doctrine to your own coding practices you will find that you will have greater job security because people know that when you are in charge of a project they will get software that doesn’t break and even if it does, then you’ll be able to get it back online quickly.