Beyond the Code: Why a Strategic Approach to Software Design Outperforms Tactical Thinking.
In this article we will explore the best approaches to managing software complexity during development. I apologize to those who were expecting the next part of the Android OS series and I promise to publish it in two weeks.
A few days ago, I decided to write this article after a heated conversation with a colleague of mine about the most effective approach to software development.
I couldn't convince him that a strategic approach to programming is more effective than a tactical one, nor that the structure of software is more important than its behavior. For him, there was only the task to be done, followed by the next one, and so on. It was all tactics, all Agile, and nothing more.
In the past, I have written extensively about software quality, design principles, patterns, and systematic methods for software architecture design. However, in this article, I want to focus specifically on the overall approach to software development.
Alright, let's start by looking for the answer to this question.
Why is a systematic approach to software development necessary?
After all, we often take pride in calling software development a creative activity, so why not let instinct guide us, like artists do?
The answer is simple: because it is an incredibly complex task, and even worse, it’s a growing complexity. New systems are always more complex than their predecessors, and even the same system becomes more intricate over time due to maintenance and updates.
If we consider the number of lines of code in a software project as an indicator linked to complexity, here are some numbers:
The Linux kernel alone consists of 20 million lines of code.
The Android AOSP project has around 15 million lines.
Windows 10 has 50 million about, the same as a typical Linux distribution.
macOS reaches an impressive 85 million lines of code.
a mobile app like YouTube can contain from 500,000 to 2 million lines of code.
It’s clear that without a disciplined approach to design and develop software we wouldn't get anywhere. A good approach is essential to managing and mitigating complexity before it overwhelms us.
We will delve into the two main approaches to software development: the tactical and the strategic, but first, it’s useful to take a closer look at what software complexity really is and how we might define a metric for it. Having a way to measure complexity, at least enough to compare different software systems, can give us a valuable perspective.
Of course, lines of code alone do not fully measure the complexity of a system. A crucial factor is its structure or the lack of it. Metrics that assess the cohesion of components and the coupling between them are essential in determining the system’s structure and, consequently, its level of complexity. Closely tied to complexity is the programming paradigm used in software development. An object-oriented paradigm generally handles complexity better than an imperative or functional approach. Additionally, the type of software being developed also influences complexity. For example, a compiler or a real-time embedded system is typically more complex than an accounting management application.
Let's start with a real example that clearly shows how, over two decades, the same software product increases in complexity following a geometric progression.
A real case study: the evolution of treadmill software complexity over the past twenty years.
I’d like to give you a real example that clearly illustrates how software complexity increases over time. For the past 25 years, I have been working in the fitness industry, so as an example, I will share the story of how the software of a treadmill has evolved and become more complex over the last 25 years.
To simplify, let’s consider the number of lines of source code (LOC) as an indicator of software complexity. In this case, it’s an acceptable simplification because the software has always been developed using an object-oriented paradigm, even though different languages and operating systems have been used over time.
Here is the graph of the evolution of treadmill software complexity over the last twenty years: it shows that the complexity of the treadmill software has grown exponentially with the system size, increasing by a factor of 10 every decade.
The first version of the treadmill software, developed in 2000, had 15K LOC and managed basic functions like motor control, messaging, displays, a keyboard, an emergency system, and a heart rate receiver. It also included about 20 training programs and supported a portable memory device for storing and reading workout data. Developed in C++ with some assembly, it ran on the NUCLEUS OS and an ARM7 processor. Compared to the previous 30K LOC version written in C and assembly, my team reduced the code size by half, making it more structured and future-proof.
By 2002, hardware upgrades (Intel 386SX, touchscreen, graphical display) required adapting the software to a new OS ("OnTime") and processor, adding a graphical UI module. LOC grew to 25K, with 30% for UI management.
In 2005, an analog TV tuner and new training features increased LOC to 45K. In 2007, a digital tuner and medical/military fitness tests pushed it to 75K.
By 2009, internet features (browser, YouTube, IPTV) required new hardware (VIA Technologies - EDEN C5) and Windows CE 3.0, doubling LOC to 150K. Since Windows CE lacked Flash support, we switched to a custom Linux distribution in 2011, increasing LOC to 300K, including OS modifications.
With the rise of app stores, we moved to Android in 2013. Since Android lacked real-time processing, we offloaded critical tasks to a microcontroller running FreeRTOS, while the main processor (NVIDIA Tegra2) ran a custom Android 4.02 AOSP. LOC grew to 600K.
In 2015, we added multi-user support to Android, requiring major OS modifications (Zygote, AMS, PackageManagerService), earning us a patent. With a new Tegra3 processor, we integrated Unity3D for advanced graphics, bringing LOC to 800K.
By 2020, with the shift to NXP IMX8 and Android 9, the treadmill software reached 1 million LOC, reflecting its exponential growth in complexity over two decades.
As shown in the graph above, software complexity in this case has grown exponentially, increasing tenfold every decade. From my experience, managing complexity becomes very difficult beyond 500K LOC, especially with a small team. At this scale, object-oriented design alone is not enough additional solutions are needed.
In this example, we looked at how complexity naturally increases due to technological progress, leading to more features and greater system complexity.
Soon, we’ll see that other factors also contribute to complexity, and we need to manage them carefully.
Now, let's define complexity, how it appears, and what causes it beyond natural growth.
Conceptual Model of Complexity.
Let's define software complexity in a practical way.
Complexity is anything in a software system that makes it difficult to understand and modify. It can take many forms unclear code, small changes requiring significant effort, or fixing one bug causing another. A system that is hard to understand and modify is complex; one that is easy to work with is simple (2.).
We can also view complexity in terms of effort. In a complex system, even small improvements require a lot of work, while in a simple system, larger changes are easier to implement.
The overall complexity of a system depends on where developers spend most of their time. If some parts are very complex but rarely modified, they have little impact. Keeping complexity isolated is almost as effective as removing it entirely.
In "A Philosophy of Software Design"(3.), John Ousterhout introduces a conceptual framework for understanding and measuring software complexity. While he doesn’t provide a strict mathematical formula, he offers a qualitative model for thinking about complexity.
Ousterhout defines complexity as a combination of dependencies and obscurity. He doesn’t provide a specific formula but suggests that complexity can be thought of as a function of these two factors:
Dependencies occur when one part of the system relies on another part. The more dependencies a system has, the harder it is to modify because changes in one part can ripple through the system. Ousterhout emphasizes that tight coupling between modules increases complexity. For example if a class directly depends on the internal implementation of another class, modifying the second class may break the first.
Obscurity arises when important information about the system is not clear or is hidden. This can be due to poor naming, lack of documentation, or overly clever code. Obscurity forces developers to spend extra time understanding the system, which increases cognitive load. For example a function with a vague name like processData() doesn’t reveal what it actually does, forcing developers to dig into its implementation to understand it (7.).
Ousterhout’s model is qualitative rather than quantitative. It encourages developers to think about complexity in terms of dependencies and obscurity and to design systems that minimize both. For example:
Reduce dependencies by designing deep modules with simple interfaces.
Reduce obscurity by writing clear code, using meaningful names, and providing good documentation.
Another common trait of complexity is that it doesn’t come from a single big mistake but builds up gradually in small pieces. A single dependency or unclear section of code may not have a big impact on software maintainability, but when hundreds or thousands of these small issues accumulate over time, every change becomes more difficult. (4.)
This gradual build-up makes complexity hard to control. It’s easy to think that adding a little complexity with a small change won’t matter. However, if every developer does this, complexity grows quickly. Once it has built up, removing a single issue won’t make much difference, making it very difficult to reduce.
Complexity usually manifests itself in three general ways, each of these makes it harder to work on the system. Here is a brief description of the symptoms of complexity:
Change amplification: A simple change requires modifications in many places.
Unknown unknowns: It’s unclear what needs to be modified or what the side effects of a change might be.
Cognitive load: Developers need to keep a lot of information in their heads to work on the system. A higher cognitive load means that developers have to spend more time learning the required information, and there is a greater risk of bugs because they have missed something important.
Let's summarize what we've learned about complexity:
Complexity comes from an accumulation of dependencies and obscurities. As it grows, changes require more modifications, increase cognitive load, and introduce unknown risks. Developers spend more time understanding the system, and sometimes they can't find all the needed information. In the end, complexity makes code harder and riskier to modify.
Now I want to share with you a real example that shows how good coding practices can effectively manage cognitive load.
Cognitive load: a real example.
The example I am about to show you relates how to manage application updates on an embedded device from a proprietary store. The device is a piece of cardiovascular exercise equipment, and the applications can include workout programs that run on the machine, social media apps, entertainment apps, or utility apps. The gym manager can use a web application to select which apps from the company's store should be installed on the equipment.
A possible specification for this functionality could be as follows:
The device stores a list of installed applications that are available in the store. Each item in the list contains the following information: app name, package name, unique identifier, version, type (e.g., Games, Social Media, Messaging, Video Sharing, Music & Audio, Travel Guides, etc.), the network URL to download the app, and its status (to be downloaded, downloaded, installed).
The list should be updated basing on a new version retrieved from a cloud service, which returns the latest selection of applications chosen by the gym manager.
The code should generate three lists:
A list of installed applications that need to be uninstalled because they are no longer included in the gym manager's latest selection.
A list of applications that are already installed but need to be updated because a newer version is available in the updated list from the cloud service.
A list of applications that need to be installed because they are included in the manager’s selection but are not yet present on the device.
Here is the test code:
Data Class: App represents an application with its properties.
Below I show you two solutions for this problem. The first implementation is concise but harder to read, while the second is more readable and maintainable.
1. Ultra-Compact and Hard-to-Read Implementation:
This ultra-compact implementation achieves the functionality but sacrifices readability and maintainability for brevity. It is not recommended for collaborative projects or long-term maintenance. Always prioritize clarity and maintainability in real-world applications!
The Key Features of This Implementation are
Extreme Conciseness: The entire logic is condensed into a single line. Uses functional programming features like filter, map, and any to achieve the goal
Poor Readability: Variable names are shortened to single letters (o for old list, n for new list). The logic is nested and hard to follow without careful analysis.
High Cognitive Load: Requires familiarity with Kotlin's functional programming idioms. Difficult to debug or modify due to lack of clarity.
2. Readable and Maintainable Implementation:
This implementation breaks down the logic into smaller, well-named functions and uses descriptive variable names for better readability.
here is the explanation: convert both the old and new lists into maps (oldAppMap and newAppMap) for quick lookup by packageName. There are three Helper Function: findAppsToUninstall (finds apps in the old list that are not in the new list), findAppsToUpdate (finds apps in the old list that are in the new list but have a lower version, findAppsToInstall (finds apps in the new list that are not in the old list. Return a Triple containing the three lists.
Both solutions, the ultra-compact and the readable and maintainable one, work correctly and pass the unit test, as shown below:
Here are my final thoughts on the two implementations we reviewed.
The readable implementation is modular, uses descriptive names, and is easier to understand and maintain.
The ultra-compact implementation sacrifices readability and maintainability for brevity. It is not recommended for collaborative projects or long-term maintenance but could be used in scenarios where code size is a critical constraint (e.g., code golf or quick prototyping). Always prioritize clarity and maintainability in real-world applications!
Well, friends, after exploring complexity, its causes, and its symptoms, we can now move on to the key topic of this article: the right approach to software development.
Tactical vs. Strategic Programming.
There are two contrasting approaches to software development: tactical programming and strategic programming. These approaches differ fundamentally in their goals, priorities, and long-term impact on software quality. Below, I’ll explain both approaches in detail, provide examples in the context of embedded software development, and discuss their implications.
Tactical Programming.
Tactical programming is a short-term, results-oriented approach. The primary goal is to get features working as quickly as possible, often at the expense of long-term maintainability and design quality. Here are its key features:
Focus on immediate results: Developers prioritize delivering functionality over investing time in good design.
Quick fixes: Problems are patched with minimal changes to meet deadlines.
Neglect of design: Little thought is given to how the code will evolve or how changes will affect the system in the future.
Technical debt: Tactical programming often leads to messy, unorganized code that becomes harder to maintain over time.
Let's see an example in Embedded Software: Imagine a team developing firmware for a smart thermostat. They need to implement a feature to adjust the temperature based on user input. A tactical approach might look like this:
The developer writes a quick function to handle temperature adjustments directly in the main control loop.
The function is tightly coupled to the hardware and user interface, making it hard to modify later.
No abstraction is used for temperature control, so adding new features (e.g., scheduling or remote control) requires rewriting large parts of the code.
The code works for now, but it becomes a maintenance nightmare as requirements evolve.
Strategic Programming.
Strategic programming is a long-term, design-oriented approach. The primary goal is to create a clean, maintainable system that can adapt to future changes and requirements. Here are its key features:
Focus on design: Developers invest time in designing modular, well-structured systems.
Deep modules: Functions and modules are designed to be simple on the outside (minimal interfaces) but powerful on the inside (encapsulating complexity).
Reduced complexity: Strategic programming minimizes dependencies and obscurity, making the system easier to understand and modify.
Sustainable development: While it may take more time upfront, strategic programming reduces technical debt and makes future development faster and less error-prone.
Let's see how the previous example of embedded software becomes with a strategic approach:
The developer designs a modular architecture with clear separation of concerns: a "TemperatureController" module handles the logic for adjusting the temperature, a "UserInterface" module manages user input and displays, an "HardwareAbstraction" module provides a clean interface to the hardware (e.g., sensors and actuators).
Each module has a simple interface and encapsulates its internal complexity.
The system is designed to be extensible, so adding new features (e.g., scheduling or remote control) requires minimal changes to existing code.
While this approach takes more time upfront, it results in a system that is easier to maintain and extend over time.
Distinction between tactical programming and strategic programming highlights a critical choice in software development. While tactical programming may deliver quick results, it often leads to technical debt and long-term pain. Strategic programming, on the other hand, invests in good design upfront, resulting in systems that are easier to maintain, extend, and adapt.
With over three decades of experience in embedded software, I am certain that strategic planning is crucial in this field. Embedded systems often have long lifecycles and strict reliability requirements. By prioritizing clean and modular design, developers can build systems that last and adapt to evolving needs.(5.),(6.)
The figure below highlights how at the beginning, a tactical approach to programming will make progress more quickly than a strategic approach. However, complexity accumulates more rapidly under the tactical approach, which reduces productivity. Over time, the strategic approach results in greater progress. This pattern raises an important question: where is the crossover point between the strategic and the tactical curves? The answer depends on the type of system (sooner for an embedded system, later for an accounting system) and its size. However, in my opinion, the return on investment will not take too long.
The key takeaway I want to leave you with is that good design isn’t free it requires continuous investment to prevent small issues from growing into big ones. Fortunately, good design pays off sooner than you might expect.
It’s crucial to stay consistent with a strategic approach and see this investment as something to make today, not tomorrow. In times of crisis, there’s always a temptation to postpone improvements, but this is a slippery slope. One crisis will be followed by another, and delays can easily become permanent, pushing teams toward a purely tactical mindset.
The longer design problems are ignored, the bigger they become, making solutions feel overwhelming and easier to postpone again. The best approach is for every engineer to make small, continuous investments in good design.
You’ve read Robert Martin’s classic bestseller Clean Architecture. Do you remember the concepts of Behavior and Structure?
Great! I’d like to end this article by revisiting these ideas and exploring their connection if any to Tactical and Strategic Programming.
Behavior vs. Structure.
Robert Martin distinguishes between behavior and structure as two core aspects of software:
Behavior refers to what the software does its functionality, features, and how it meets the requirements of the users and stakeholders. Behavior is the primary reason software exists. Users and stakeholders care about whether the software solves their problems, performs tasks correctly, and delivers value. As an example in an e-commerce application, behavior includes features like adding items to a cart, processing payments, and generating order confirmations.
Structure refers to how the software is organized its architecture, design patterns, modularity, and the relationships between components. Structure determines how easy it is to understand, modify, and maintain the software over time. A good structure ensures that the software remains flexible, scalable, and adaptable to change. In the same e-commerce application, structure includes how the code is organized into layers (e.g., presentation, business logic, data access) and how components like the shopping cart or payment processor are designed.
Which is More Important? Behavior or Structure.
Robert Martin argues that structure is more important than behavior in the long term. Here’s why:
Behavior is easy to change: If the software is well-structured, adding or modifying features (behavior) is straightforward. Developers can make changes without unintended side effects.
Structure is hard to change: Poor structure leads to a rigid, fragile system where even small changes can break the software. Refactoring a poorly structured system is time-consuming and risky.
Long-term impact: While behavior delivers immediate value, structure ensures that the software can continue to deliver value over time. A well-structured system can adapt to new requirements and technologies, whereas a poorly structured system becomes a maintenance nightmare.
Martin uses the analogy of a house: behavior is like the furniture (easy to rearrange), while structure is like the foundation and walls (hard to change once built). If the structure is flawed, the entire house becomes unstable. (1.)
Is there a connection between behavior/structure and tactical/strategic programming?
The concepts of behavior and structure (Robert Martin) and tactical programming and strategic programming are two sides of the same coin. They highlight the tension between delivering immediate functionality and ensuring long-term maintainability. By understanding this connection, developers and managers can make better decisions, balancing short-term goals with long-term sustainability. Ultimately, the key is to prioritize structure and strategic programming while still delivering behavior that meets user needs.
I’d like to end this article with this wise advice:
"It’s important to use moderation and discretion. Every rule has exceptions, and every principle has limits. Taking any design idea to the extreme will likely lead to a bad outcome. Great designs find a balance between competing ideas and approaches."(John Ousterhou)
That's it for this topic.
In the next article, we’ll continue our journey into the deep, hidden and intricate world of Android OS. As I mentioned earlier, this is going to be exciting!
Many Play Store apps declare the android.intent.action.BOOT_COMPLETED intent in their manifest to launch automatically when the system starts. On our embedded devices, this can be a problem. For example, if apps like Netflix and YouTube start at boot, they can interfere with the multi-user implementation we discussed earlier (2.).
So, in the next article, we’ll create a dynamic blacklist in AMS to block certain system intents from reaching specific apps. Stay tuned!
I remind you my newsletter "Sw Design & Clean Architecture": https://lnkd.in/eUzYBuEX where you can find my previous articles and where you can register, if you have not already done, so you will be notified when I publish new articles.
Thanks for reading my article, and I hope you have found the topic useful,
Feel free to leave any feedback.
Your feedback is very appreciated.
Thanks again.
Stefano
References:
1. Robert C. Martin, “Clean Architecture - a craftsman's guide to software structure and design” Prentice-Hall, November 2018 (pages 3-10)
2. Robert Martin, “Clean Code: A Handbook of Agile Software Craftsmanship” - Pearson, August 2008.
3. John Ousterhout, “A Philosophy of Software Design” - Yaknyam Press, July 2021(pages 1-18).
4. S.Santilli: The Software Entropy.
5. S.Santilli: The "Zero Principle"of software design.
6. S.Santilli: How can we manage a software that is becoming more and more complex?
7. S.Santilli: Coding style: The Meaningful names