Insights
- 9 min read
Design Patterns in the Age of AI

Design Patterns in the Age of AI

Explore how design patterns remain relevant in AI-assisted software development as a shared vocabulary for architecture, tradeoffs, and engineering intent.

Libélula Software logo

Libélula Software

Software architecture and engineering consultancy

Software developers of a certain age are intimately aware of the famous Design Patterns: Elements of Reusable Object-Oriented Software book. The fundamental premise of the book is that while few software problems are identical, many rhyme. Each recipe in the book is essentially a tool in the software developer's toolbox. The art of software development is in the analysis of the problem and the application of the right tool. Everyone knows the anecdote that "if your only tool is a hammer then every problem looks like a nail". As more and more software development moves to LLMs, the danger becomes treating the LLM as the ultimate hammer. Is this a wise approach? What about classical design patterns? Do they still matter at all?

The Obvious Solution

LLMs can generate workable code given a prompt, but they are limited in understanding the broader context of a problem. Humans have a lot of implicit assumptions and often use short-circuit reasoning without realizing it. It is hard, if not impossible, for a human to convey all this knowledge to an LLM, so an LLM will often be operating with imperfect information. In order to reach a solution, an LLM will need to make its own assumptions, which might differ from the human's. This can lead it to build a suboptimal solution.

As a trivial example, consider asking an LLM to build a simple calculator supporting addition and subtraction:

The LLM will likely come up with something usable, where the main logic (the arithmetic) looks something like this:

def try_execute(operation_name, a, b):
    if 'add' == operation_name:
        print(a + b)
    elif 'sub' == operation_name:
        print(a - b)
    else:
        raise ValueError(f'Unknown operation "{operation_name}". Expected "add" or "sub"')

In certain situations, this code might be fine. It is certainly functional and satisfies all the requests in the prompt. If the code is intended to be a quick proof of concept, this approach is perfectly acceptable.

When Requirements Grow

What if the code is actually only the start of a larger project? Additional requirements start streaming in: multiplication, division, and operation-specific validation.

We can ask the LLM to do this for us too:

The LLM will again come up with something usable:

def try_execute(operation_name, a, b):
    if 'add' == operation_name:
        print(a + b)
    elif 'sub' == operation_name:
        print(a - b)
    elif 'mul' == operation_name:
        print(a * b)
    elif 'div' == operation_name:
        if b == 0:
            raise ValueError('Division by zero is not allowed')
        print(a / b)
    else:
        raise ValueError(f'Unknown operation "{operation_name}". Expected "add", "sub", "mul" or "div"')

Quickly, the try_execute function is becoming very busy. The structure is beginning to work against the open-closed principle because adding additional operations requires modifying the main if/else control flow. The don't repeat yourself (DRY) principle is also being stretched because the supported operations are repeated in the if/else control flow and the available operations list. try_execute is beginning to have too many responsibilities. It now contains dispatch, validation, calculation, and presentation logic. These crosscurrents of responsibility are beginning to make maintenance difficult. For example, if we wanted to change the output format, we'd need to change all four print statements.

Applying an Appropriate Pattern

Luckily, we know some design patterns that can help improve this situation. We can take inspiration from the chain of responsibility and strategy patterns discussed in Design Patterns and adapt them to better fit our problem. This customization is essential because design patterns are only guidelines. They should not be thought of as overly strict and all or nothing. All problems are different and might require slightly different applications. The art of software development comes from knowing how to adapt and apply these patterns to the problem at hand.

First, we define an Operation object that is composed of the following members:

  • name - identifies the operation that the object handles
  • check_parameters - inspects both parameters for validity
  • execute - performs the arithmetic operation given the two parameters

Next, we create a list of supported operations and search it in try_execute. Once a matching operation is found, we use it to check parameters and perform the desired arithmetic operation.

The updated code will look something like this:

from collections import namedtuple

Operation = namedtuple('Operation', ('name', 'check_parameters', 'execute'))

def require_nonzero(description, value):
    if 0 == value:
        raise ValueError(f'{description} by zero is not allowed')

operations = [
    Operation('add', lambda _a, _b: None, lambda a, b: a + b),
    Operation('sub', lambda _a, _b: None, lambda a, b: a - b),
    Operation('mul', lambda _a, _b: None, lambda a, b: a * b),
    Operation('div', lambda _a, b: require_nonzero('Division', b), lambda a, b: a / b)
]

def try_execute(operation_name, a, b):
    operation = next((operation for operation in operations if operation.name == operation_name), None)
    if not operation:
        raise ValueError(f'Unknown operation "{operation_name}". Expected {" or ".join(f"\"{operation.name}\"" for operation in operations)}')

    operation.check_parameters(a, b)
    print(operation.execute(a, b))

Notice the differences compared to the obvious solution. The try_execute function is now closed for modification when adding new operations, in accordance with the open-closed principle. Adding additional operations only involves updating the list of operations. While this trivial example uses lambdas to encapsulate different operation logic, in a more complex example, the logic differences would likely be encapsulated in separate classes with a shared interface. Validation (check_parameters) and computation (execute) are clearly separated in alignment with the single responsibility principle. Finally, the list of supported operations is generated dynamically. Each operation's name only appears once in the code, better conforming with the DRY principle.

Suppose we get a new requirement to add support for modulo. We only have to add one additional entry to operations. Nothing else needs to change.

    Operation('mod', lambda _a, b: require_nonzero('Modulo', b), lambda a, b: a % b)

Design Patterns as a Shared Vocabulary

Design patterns have always been used as a lingua franca among developers. A software developer could suggest using a design pattern - the flyweight pattern, for example - and another would understand the suggestion immediately without it needing to be explained from first principles. In the age of AI, the other developer is an LLM instead of a human. The shared vocabulary is more, not less, important now. Suggesting a design pattern communicates the developer's intent clearly and effectively to the LLM. Some of the architectural intent that led the developer to choose that pattern is encoded in the choice itself and implicitly communicated to the LLM.

We believe it is best to think of an LLM as a collaborator. Let the human do what the human is good at, and let the LLM do what the LLM is good at. Don't treat it like a universal hammer and expect it to do everything. Teams that do this will likely end up with suboptimal solutions.

With an AI collaborator, knowing how to implement a design pattern becomes less important, while knowing when to use one becomes more important.

Takeaways

One of our core beliefs is that software development is as much an art as a science. While the limitations of the obvious solution may be clear, the pattern-based solution is not a panacea either. Layers of abstraction can add complexity and maintenance overhead of their own, so they should be created with careful thought. The appropriateness of the solution depends on the context - both technical and business - for which it is built. If many operations are likely to be added in the future, that is a point in favor of the abstractions. The additional flexibility and maintainability will be very desirable. If the code is expected to have a short lifecycle before being discarded, that is a point against the abstractions. The additional complexity might make the code more difficult to understand, which is especially undesirable if it is intended to be used as an example. In software development, like everything else, there are tradeoffs everywhere.

An experienced human developer will typically have a better understanding of the overall context - both explicit and implicit - of a project, some of which will be difficult to express to an LLM. This puts the developer in a better position to design the higher-level architecture and weigh the interests of various stakeholders. In contrast, LLMs shine at fast prototyping and can produce code very rapidly. Used without sufficient context or architectural guidance, that speed can just as easily accelerate the accumulation of technical debt. They can be given instructions to use specific design patterns in certain situations, which might lead to better outcomes.

The human can relay goals, constraints, and relevant context to the LLM collaborator. This can include specific design patterns to use or even a template for the LLM to complete. LLMs can quickly prototype solutions based on these strictures and discover alternatives. Both can iterate over architecture and implementation improvements, eventually reaching a good endpoint. Ultimately, the human is responsible for determining whether the result fits the actual problem.