In the Pursuit of Modularity

In the Pursuit of Modularity
Photo by Mike Hindle / Unsplash

It’s no surprise that when faced with overwhelming complexity, we instinctively break problems into smaller, manageable parts. This approach reduces cognitive load, making each piece easier to understand, reason about, and solve. It also sharpens focus, as narrowing the scope allows for deeper, more precise thinking. This mental model seamlessly translates into software development, where complexity is inevitable and the ability to make changes quickly and cost-effectively is crucial.

There’s one concept that’s key to achieving these essential goals in modern software development: modularity. It builds on our natural human tendency to decompose complexity, helping us build maintainable and adaptable software systems.

Even with all the advancements in generative AI, we humans are still at the heart of software development. That’s why modularity remains not just a guiding principle, but also a lifeline.

Yet, modularity isn’t as simple as it seems. Breaking systems into smaller parts often introduces its own challenges, sometimes even failing at its primary goal: managing complexity. In the pursuit of this elusive modularity, we risk falling into the trap of oversimplification. The assumption that "the whole is just the sum of its parts" often overlooks the emergent properties of real systems, behaviours and dynamics that are more than, or fundamentally different from, what the individual components suggest.

This article explores the subtle challenges we may stumble upon on our way to modularity, and why simply breaking things down doesn’t always get us where we want to go.

When Predictability Matters: Linearity in Complex Systems

Imagine you’re making a cup of tea, and the time it takes to boil water is directly related to how much water is in the kettle. If you have more water, it takes longer to boil. If you have less, it takes less time. Everything is predictable and deterministic. You can calculate exactly how much time it will take to boil the water based on how much is in the kettle. No surprises.

Boiling Water. More Water = More Time, Less Water = Less Time

If you try to impress someone by saying "I can boil water faster than you" it turns out you just have a smaller kettle!

In software development, predictability and determinism are key. Current state, configuration, and input are all we should need to reliably predict behaviour. Software models business processes, after all, and these models must be both accurate and useful. Any loss of precision can result in operational disruptions or even revenue loss.

Just like in an e-commerce store, customers expect that when they click "Buy", the total matches what they saw. No surprise fees, no mystery items in the cart. The business doesn’t expect any products to be offered for free if they shouldn’t be (although customers wouldn't complain about that particular issue). The moment that trust breaks, frustration kicks in, and what should have been a smooth checkout turns into second-guessing.

Just like a well-designed software system, this tea-making process follows a set of clear rules based on current state (amount of water), input (how long you let it boil), and configuration (the kettle’s voltage - how much electrical power it uses to heat the water). All of these initial conditions are what is needed to predict what the system does. In mathematics such systems are named linear systems. In such systems a future behaviour can always be inferred from these initial set of conditions at any point of time.

In Linear Systems, Input, State, and Configuration Determine Behaviour

Now, let’s imagine the same boiling water scenario, but with a slight change. The heating element inside the kettle is designed to work within a specific voltage range, but if the line voltage fluctuates (perhaps because other kitchen appliances are drawing power) things get unpredictable. Instead of heating the water in a steady, proportional way, the element might overheat or struggle to warm the water at all. In such cases, predicting the outcome becomes difficult. The relationship between the initial conditions and the expected result becomes unclear or might not even exist at all. The only way to observe the outcome is by running a simulation. These systems are known as non-linear.

💡
It's worth noting that, in reality, all systems (software or otherwise) are inherently non-linear. The key difference is that in systems we consider "linear" the non-linear factors are so minor that they can be ignored. Even something as simple as an a + b program runs in a hosting environment, and no hosting setup is immune to outages or failures. When that happens, what seemed like a predictable system suddenly isn’t so predictable anymore.

I have to admit, non linear systems can be far more fascinating, as they exhibit some interesting characteristics that their linear counterparts lack. But in business, predictability (and yes, even a bit of boredom) is what makes a system valuable. Surprises might be exciting, but no one wants them when it comes to transactions, operations, or customer trust.

When Breaking Things Down Doesn’t Make Things Simpler

When we break a system apart, we aim to create self-contained parts or modules that each handle a specific responsibility. Regardless of the scale of these parts, modularisation is really about balance. It’s about finding the sweet spot between keeping things independent and making sure they still work together in a way that makes sense for both today’s and tomorrow’s needs.

But here’s the catch: breaking things apart is easy, doing it well is not. To get modularisation right, we need clear and meaningful boundaries between components and explicit communication interfaces. And that’s where things get tricky. As we’ll soon see, the real challenge and complexity isn’t just in splitting a system into pieces. It’s in how those pieces interact. That’s what decides whether a system stays predictable and linear or drifts into unpredictable, nonlinear territory.

To better understand this, let’s introduce the concept of emergent behaviour, a foundational principle of systems thinking. Emergent behaviour refers to complex, unpredictable behaviours that arise from the interactions of simpler components. What’s important to note here is that these behaviours can’t be predicted by looking at the individual components in isolation. They emerge from the relationships and dependencies between them.

Let's get back to our analogy with the Kettle and lets dive deeper into how voltage fluctuations might cause emergent behaviours. In this scenario, we have several interacting parts, each with its specific function. Here’s a breakdown of the modules:

  • Heating coil: Heats the water based on the supplied voltage.
  • Thermostat: Shuts off the heater when the water reaches boiling point.
  • Power supply: The power supply delivers voltage to the kettle.
  • Water level sensor: Tracks the water level and can influence heating times or warn the user if the water is too high or too low.

Let's assume the water level is high, and at the same time, someone plugs in a hairdryer on the same circuit, causing a significant voltage drop. Since the heater’s power is directly affected by voltage, the water takes much longer to reach boiling point. Meanwhile, if the water level sensor has a slight delay in reporting changes, the system might incorrectly assume there’s less water than there actually is. As a result, the heating element could stay on longer than expected, leading to excessive energy consumption or even unexpected shutdowns as the thermostat tries to compensate. This is just one example of how voltage fluctuations, combined with other factors, can create emergent behaviours that are impossible to predict just by looking at individual components in isolation. Depending on the system’s configuration and input conditions, different unexpected outcomes can arise, making the overall behaviour non-linear.

💡
Of course modern kettles mitigate the effects of voltage fluctuations with safety features like automatic shutoff, boil-dry protection, and thermostats that ensure accurate shutdown timing. These measures aim to suppress the effects of emergent behaviour and make the system behave in a more linear, predictable way. That’s what we aim for in software too!

And this is the heart of the problem: emergent behaviour turns linear systems into nonlinear ones. Individually, the parts behave as expected. Each one plays by the rules. But once they interact, the rules start to bend in unexpected ways.

Emergent Behaviour in Code

Believe it or not, emergent behaviour does not arise only when using a kettle. It’s a principle that applies across many systems, including software. Think of modules in software as anything from small-scale components like classes and functions to large-scale ones like independently deployable services. To see this in action, let’s take a closer look at a code example. Consider a scenario where we apply two percentage discounts to a product, but the order in which they are applied affects the final price.

public interface IDiscount
{
    decimal ApplyDiscount(decimal originalPrice);
}

public class PercentageDiscount : IDiscount
{
    private readonly decimal _discountPercentage;

    public static PercentageDiscount SpecialCustomerDiscount => new(4.45m);
    public static PercentageDiscount RegularDiscount => new(33);

    public PercentageDiscount(decimal discountPercentage) => _discountPercentage = discountPercentage;

    public decimal ApplyDiscount(decimal originalPrice)
    {
        var finalPrice = originalPrice * (100 - _discountPercentage) / 100;

        return Math.Round(finalPrice, 2, MidpointRounding.ToZero);
    }
}

public static class DiscountManager
{
    public static decimal ApplyDiscounts(decimal originalPrice, IList<IDiscount> _discounts)
    {
        var finalPrice = originalPrice;

        foreach (var discount in _discounts)
        {
            finalPrice = discount.ApplyDiscount(finalPrice);
        }

        return finalPrice;
    }
}

Discount Application Logic

When we apply these discounts in different orders (maybe we cant ensure the order by which they are retrieved from repository each time), we may end up with different results and its not driven by the limitations of the underlining types used to store this information. For example, in some cases applying the SpecialCustomerDiscount first and then the RegularDiscount gives us a different final price compared to applying the RegularDiscount first and then the SpecialCustomerDiscount.

using PursuitOfModularity.Logic;
using static PursuitOfModularity.Logic.PercentageDiscount;

var originalPrice = 99.99m;

var finalPrice1 = DiscountManager.ApplyDiscounts(originalPrice, [SpecialCustomerDiscount, RegularDiscount]);
var finalPrice2 = DiscountManager.ApplyDiscounts(originalPrice, [RegularDiscount, SpecialCustomerDiscount]);

Console.WriteLine($"Final price when applying SpecialCustomerDiscount then RegularDiscount: ${finalPrice1}");
Console.WriteLine($"Final price when applying RegularDiscount then SpecialCustomerDiscount: ${finalPrice2}");

The Client Code

When running the client code above, the console output displays:

Final price when applying SpecialCustomerDiscount then RegularDiscount: $64.01
Final price when applying RegularDiscount then SpecialCustomerDiscount: $64.00

Impact of Discount Order on Final Pricing

This is a clear example of emergent behaviour. While each discount type operates independently and behaves correctly in isolation, their combined effect, determined by the order in which they are applied, leads to a nonlinear and potentially unpredictable outcome.

💡
The unexpected outcome above comes from truncating the amount each time a discount is applied. This can be mitigated, depending on business requirements, by moving the rounding step to the ApplyDiscounts method. Still, identifying this issue requires looking beyond a single module.

Similar unexpected effects can be seen in multi-threaded environments, where accessing shared, unprotected state can lead to inconsistencies or even deadlocks. These issues are often hard to spot during development and usually only reveal themselves at runtime. Likewise, overly broad communication interfaces between modules can create unintended interactions, leading to unexpected system behaviour, a challenge where principles like the Liskov Substitution Principle can help. There are many other cases where such nonlinearity emerges in code, as a result of decomposing a system without fully accounting for the new complexity introduced.

Emergent Behaviours in Distributed Systems

These kinds of emergent behaviours become even more apparent when we zoom out to look at distributed systems. Unlike code modules running in a single process, which are kept close together and make it somewhat easier to maintain perspective, independently deployable modules (or even separate systems) can drift out of sight, making it harder to anticipate their interactions. As an example, let’s imagine a typical communication scenario between services. Say we have two of them involved in the order shipping process: OrderService and ShippingService. OrderService handles incoming order commands and, once an order is accepted, it publishes an OrderPlaced event. ShippingService, which takes care of actually shipping the products, listens to those events. They talk to each other asynchronously, though honestly, the exact protocol doesn’t really matter here.

Everything runs smoothly under normal conditions. But then a spike in traffic hits, and OrderService kicks off its scaling-out policy. More instances spin up to handle the increased load, which means more orders being placed and more OrderPlaced events flooding into the event broker. Meanwhile, ShippingService is just minding its business, scaling at a different pace (or maybe not at all). Suddenly it's overwhelmed with events and can’t keep up. A backlog starts building, and before you know it, the service is lagging behind or even dropping messages.

flowchart LR
    subgraph OrderService
        OS1[Instance 1]
        OS2[Instance 2]
        OS3[Instance 3]
    end
    OS1[Instance 1] --- OrderPlaced1@{ shape: braces, label: "OrderPlaced" }
    OS2[Instance 2] --- OrderPlaced2@{ shape: braces, label: "OrderPlaced" }
    OS3[Instance 3] --- OrderPlaced3@{ shape: braces, label: "OrderPlaced" }
    U[User] --> OS1
    U[User] --> OS2
    U[User] --> OS3
    OrderPlaced1 --> EB@{ shape: das, label: "Event Broker" }
    OrderPlaced2 --> EB
    OrderPlaced3 --> EB
    subgraph ShippingService
        SS1[Instance 1]
        SS2[Instance 2]
    end
    EB --- SubOrderPlaced1@{ shape: braces, label: "OrderPlaced" }
    EB --- SubOrderPlaced2@{ shape: braces, label: "OrderPlaced" }
    SubOrderPlaced1 --> SS1
    SubOrderPlaced2 --> SS2

Resource Exhaustion in Scaled Services

A similar kind of bottleneck can happen if each instance of a scaled-out service connects to a local shared database. If that database isn’t ready for a flood of new connections, things start slowing down or worse, failing.

Building on the previous example, imagine that the OrderService needs access to customer tier information, like "Premium" or "Gold", in order to apply discounts when placing an order. This data is owned by the CustomerService and is exposed asynchronously. To work with it, the OrderService maintains a local copy of the customer data, which it updates by subscribing to CustomerTierUpdated events.

flowchart LR
    U[User] --> OS[OrderService]
    
    OS --- OrderPlaced@{ shape: braces, label: "OrderPlaced" }
    OrderPlaced --> SS[ShippingService]
    
    CS[CustomerService] --- CustomerTierUpdated@{ shape: braces, label: "CustomerTierUpdated" }
    CustomerTierUpdated --> OS

However, once data crosses service boundaries, it becomes eventually consistent. So if a customer just changed from "Premium" to "Gold", delays in message processing might mean that the OrderService still sees them in their previous tier. As a result, the discount logic applies the wrong tier, leading to a poor user experience.

💡
In the example above, the communication could just as well be synchronous. It doesn't change much. Once data crosses a boundary, it becomes eventually consistent, potentially putting business processes at risk due to stale information. Depending on the business context, this can lead to unexpected consequences. While there are tools and patterns, such as XA Transactions and Two-Phase Commit (2PC), that ensure strong consistency in such scenarios, they come with significant complexity and operational cost. In such cases, the first thing to examine is the service boundaries: are they aligned with business capabilities, and is the ownership of the underlying data clearly defined?

When we take a look at the fallacies of distributed computing and the challenges that come with integrating independently deployable modules, it becomes clear how a whole range of unexpected behaviours can arise. This makes me think that distributed systems are naturally non-linear, and they need a lot of careful thought and a broad perspective. The trick to minimising these emergent behaviours often goes against what we might expect, and requires a mindset that embraces all these complexities.

But there’s something else we haven’t really touched on yet. Unlike code modules that are kept together, in distributed systems, the distance between them usually is much greater, with different teams or people working on their parts separately. And as that distance grows, the human factor starts to play a bigger role, which makes it way more likely that non-linearity will pop up. The people building these modules might have different interpretations, priorities, or goals, and when those get misaligned, things can go off course in unexpected ways once the system comes together.

Final Thoughts

Emergent behaviours are a natural part of complex, non-linear systems. Breaking a system into smaller parts doesn’t automatically mean you've created something modular. True modularity comes from understanding how the pieces interact and how those interactions can create unexpected behaviours. This is a reminder that the whole is often more than just the sum of its parts.

Take the discount example: something as simple as changing the order of application can have a big impact on the final outcome. It’s a perfect example of how small changes in a system can lead to emergent behaviours. If its so easy to change linear system into non-linear one, that doesn’t mean we should abandon modularity. It means we need to approach it more thoughtfully. Decomposing a system can sometimes cause us to lose the global perspective needed to spot these emergent behaviours, effects that ultimately determine whether the system delivers lasting business value. That’s why defining clear boundaries, good contracts between modules, and setting up proactive ways to catch emergent behaviours (such as through targeted tests) are essential, whether you're building a monolith or distributed microservices. Let's not forget about the human factor, which can, in itself, contribute to non-linearity when the system is recomposed.

At the end of the day, the goal isn’t just to decompose for the sake of it. It’s about creating systems that are adaptable, maintainable, and resilient. Achieving modularity is a balance — one that requires us to stay flexible, keep refining, and align with evolving business needs. We’ve only scratched the surface here. In future articles, we’ll dive deeper into techniques for defining boundaries and managing interactions.

The code examples discussed in this article are available on GitHub. You can find the complete implementation here.

Read more