A Pragmatic Approach to Software Architecture

Eugene Ghanizadeh
Nov 5 · 13 min read
Illustration of the process of designing software architecture for any particular project. Painting by Adriaen Brouwer [Public domain]

Preface

The architecture of a software is typically one of the most important aspects of it. It greatly affects its quality, its maintainability, and its reliability. It is also one of the most contested and debated aspects of software engineering in general, typically inciting heated arguments between contributors of a project, arguments that appear to have no potential logical resolve, as the question of “what is a good architecture [for our current project]?” often-times seems as one without a definite answer.

If you ask experienced software engineers the general question of “what is good software architecture?”, perhaps you will hear something in these lines:

Good software architecture is simple and elegant

(a popular answer which is too vague and subjective to act as a proper basis for objective discussion and decision-making on the matter)

Good software architecture increases maintainability

(a better answer, but still doesn’t help with the question of “how to measure maintainability, aside from hearing in a year or two from programmers working on your architectural design that it is either easy or hard to maintain?”)

Good software architecture incorporates high cohesion and loose coupling

(a much better answer as you can, but coupling is still an unavoidable phenomenon and this doesn’t give you any clue as how much coupling is acceptable for your case or perhaps how much cohesion is too much cohesion)

Good software architecture properly incorporates established design patterns / make good use of this or that paradigm / etc

(ok established practices, paradigms and patterns do help with coming up with a better design, but how can I know which paradigm suites a project best, or how much should I adhere to a particular pattern?)

All of these answers are either too subjective, or merely suggest a little bit more objective tools such as paradigms, patterns and metrics without any objective indication of how these tools should be put in use. With this amount of subjectivity at the core of the question of “what is a good architecture”, it is of course no surprise that the discussion on how to design a good architecture for a project easily leads to subjective discords that may never be objectively resolved.

However, if we take a step back, look at what software architecture actually is and what roles does it play that make it such an integral part of software development, perhaps we can formulate more tangible and quantifiable metrics to base our assessments of various architectural choices and designs on.


What is Software Architecture?

Generally speaking, the architecture of a particular code-base or project is all of the implicit and explicit rules and conventions guiding how every component is designed and how it talks to other components. These rules can affect any aspect of the project, ranging from paradigms or frameworks it is based on or the abstract layers that it is decomposed to, down to naming conventions, structure of folders and modules, etc. It affects which code elements can be aware of existence of which other code elements or when and how these elements should communicate with each other.

What is its purpose?

To figure out the purpose of software architecture, we can imagine what happens to a project without one. Assume a project that does not have any agreed upon rules or conventions for modeling and creating various components or dictating how they inter-communicate.

If multiple people are going to work on this project in parallel, because of that lack of agreed conventions and rules, each one will code their own elements using their own conventions and mental models. This naturally leads to divergent styles, models, patterns and APIs, which not only makes it harder for one contributor to work on the code of another, but also makes it harder for different elements and components to communicate with each other, drastically increasing development time both in the initial phase and for later changes and iterations.

So, basically just consistency?

Nope, and here lies the distinction between good and bad architecture. Imagine a particular code-base with a particular architecture. Overtime, the project will face some perhaps unpredicted changes and iterations, and facing any such change, one of the following (or a combination) happens:

  1. The architecture is perfectly in line with intended changes, if not even guiding them. Everything good, nothing to see here.
  2. The architecture doesn’t say anything about the intended changes. Sometimes developer(s) might discuss, decide and communicate the necessary additions to the architecture with their peers, other times (for example perhaps due to some deadline) they might just implement the change according to models and guidelines inconsistent with the established architecture, contributing to the decline of code-base consistency.
  3. The developer(s) feel the architecture is in conflict of how the intended change should look like, for example because it adds lots of overhead or it makes it pretty confusing while the change itself is pretty simple and straight-forward. They might choose to respect the architecture and implement the change with much increased cost, or might choose to break pattern, in turn drastically contributing to the decline of code-base consistency.

In two of these scenarios, there is a good chance that the code-base will be less consistent after the change, and overtime this inconsistency grows to the extent that it looks like the project did not start with an architectural design to begin with. From a pragmatic perspective, we can now just call a good architecture that mostly faces the first case a good architecture, and one that mostly results in the second and third case a bad architecture.


Measuring Good Architecture

So based on what we discussed above, a good architecture basically should

  1. Try to account for as many future changes as possible
  2. Make these changes easier for developers, not harder

The first item is of-course not really a measurable measure, but it does give us a practical direction to follow: try to envision possible future changes, for example envisioning future phases of the project, parts of the stack that might get swapped out due to external requirements, etc, and see how our architectural decisions and designs would fare facing them.

The second one however leads to much more quantifiable metrics. On the face of it, it might seem that “easy” and “hard” are subjective terms and there is no proper way of measuring them. However, any change to any codebase is in the end of the day an interaction between a human (the programmer) and a computer (which has a keyboard), and luckily enough we have got a whole field of computer science dedicated to measuring ease or difficulty of such interactions, a field aptly named Human-Computer Interaction (or HCI).

For those who are not familiar with the field, HCI is a field dedicated to analyzing various aspects of various interactions between humans and computers, in as quantifiable a way as possible. Most important amongst these aspects is difficulty (more precisely, the index of difficulty of any given interaction). For a lot of basic interactions we’re able to calculate this index, and the fact that we have done so already is why for example the “min-max-close” buttons on the window that you might be reading this on are located at a corner of the screen if you are reading this piece on a laptop or PC (basically the corner causes a huge increase in a parameter that index of difficulty is inversely correlated to). Conveniently enough, index of difficulty also linearly correlates with the time it takes to perform a task (a series of human-computer interactions), which for our use-case can be translated to literal cost of the task itself (literally in terms of money).

What all of this means? Quite simply put:

Good architecture reduces the cost of future changes.

How you can assess that cost for a particular architectural decision? First, imagine some possible changes you might want to make, for example features you might want to add in next phases of the project as mentioned above, or even random changes to the signature of some randomly picked methods/functions/classes. Then estimate the series of interactions that implementing that change might include, from really basic interactions such as how many characters would need to be typed to how many other parts of the code a developer might need to look into to how understandable some particular concept or functionality will be. You can more precisely estimate the difficulty of lower-level interactions (with some basic HCI rules), or even assume arbitrary constants for their difficulty, and ball-park some higher-level interactions (and perhaps take out interactions that are too abstract), and then you’ve got yourself nice estimates of difficulty (and hence cost) of these potential changes.


How Will It Look in Practice?

Lets take a simplified example and put this approach to practice. For the sake of simplicity, let’s imagine that our developers will not have access to any particular IDE (specifically with nice cross-files searching tools, or other refactoring tools). Imagine the project is structured as follows:

src/
| -- module-a/
| -- | -- index.ts
| -- | -- | -- function aOne()
| -- | -- | -- function aTwo()
| -- | -- a-one.ts
| -- | -- | -- function aOneFuncOne()
| -- | -- | -- function aOneFuncTwo()
| -- | -- a-two.ts
| -- | -- | -- function aTwoFuncOne()
| -- | -- | -- function aTwoFuncTwo()
| -- module-b/
| -- | -- index.ts
| -- | -- | -- function bOne()
| -- | -- | -- function bTwo()
| -- | -- b-one.ts
| -- | -- | -- function bOneFuncOne()
| -- | -- | -- function bOneFuncTwo()
| -- | -- b-two.ts
| -- | -- | -- function bTwoFuncOne()
| -- | -- | -- function bTwoFuncTwo()

Now imagine changing of name or signature of src/module-a/a-one/aOneFuncOne() . Without any established rules, I would need to check the body of eleven other functions to see if they use aOneFuncOne() and if they need to change. Note that the difficulty of this check is not the same for functions of src/module-b/b-one compared to functions of src/module-a/a-one , since functions of a-one.ts are already in the same file as aOneFuncOne() . Listing the interactions, for the former I just have:

  • read the body of aOneFuncTwo() , see if changes are required, do them if they are

For the latter, the list of interactions is like this:

  • list all submodules of src/
  • list all files in src/module-b/
  • open src/module-b/b-one.ts
  • read the body both bOneFuncOne() and bOneFuncTwo() , see if changes are required, do them if they are

These interactions each take some time (and cognitive effort), since they each involve at least one click (and perhaps some scrolling and some typing). Similarly, checking other files in src/module-a is easier than checking files in src/module-b , as it requires fewer raw interactions, though it is more difficult than just checking other functions in src/module-a/a-one.ts , as then it would require more interactions.

Now if the architecture enforced the rule that files in modules other than src/module-a can only use functions defined in src/module-a/index.ts , the difficulty of the change would be reduced drastically. This of-course would have the downside that if some day I need aOneFuncOne() somewhere in module-b , I would need to also change module-a/index.ts to comply with that rule, an overhead whose probability and interaction cost we can again estimate pretty precisely.

I could even go one step further and require src/module-a/a-one.ts to explicitly mention the functions that it exports to other modules (as Typescript would require this of you regardless of your architectural design), then I could check if aOneFuncOne() is an exported function, and if it is not, the change would again be drastically cheaper to make.

Similarly, if every other file had to explicitly import aOneFuncOne() via an explicit import statement (i.e. import { aOneFuncOne } from 'src/module-a/a-one ), I would have a much easier time checking which functions I need to change. Now if I had another (unmentioned file) with that import command at the beginning, which had 10 other functions within it but only one of them actually using aOneFuncOne() , I would be better off putting that one outlier in its own file, as it would reduce the number of interactions required for detecting and making the change again.

Note that putting all of my 12 functions in src/module-a/a-one.ts would also not be a good idea, since though it would reduce the difficulty of checking all other functions, it is still a sub-par solution to checking perhaps one or five other function bodies.

With all of this in mind, we can now make much more objective judgements when deciding between two architectural designs, depending on how inter-dependent my functions would be in each design, and how close or far these inter-dependent functions would be structured. Note that this is just another way to say we should prefer an architecture that is more cohesive and loosely coupled, but this time around we have more tangible metrics to assess how much more cohesion or loose-coupling are we talking about.


What about readability/intuitiveness?

In our example, we glossed over a key factor in calculating difficulty of some of the interactions: in reality, the difficulty of reading and navigating some code is not constant. It is instead greatly affected by readability of the code (locally) and intuitiveness of the underlying model of the architecture (on a larger scale). A stream of random characters is much harder to read through and find something in, and an unintuitive structure turns the interaction of navigating the code-base into a blind and highly error-prone brute-force, as opposed to easy and quick navigation you would get with an intuitive understanding of the underlying model.

Fortunately, for readability, there are of how to measure and improve them, alongside a plethora of more that can be useful on the matter ().

Intuitiveness might seem more elusive as it definitely is more subjective. However, we only need to consider intuitiveness as in “the architecture makes sense” so that it reduces cost of changes, which in turn means “the architecture makes sense to people who are going to make those changes”. Besides this, we also know that something makes more sense to someone (or at least takes much less time and effort on their part to understand it) if (and almost but not always only-if) it relates to what they already know and/or have experienced, with more weight given to more repeated experiences or more recent experiences. Combining these two facts gives us a much more objective perspective on intuitive architectural design:

An architecture that more closely resembles what your team already knows and has experience in is more intuitive.

Note that an implication of this fact is that the best architectural design and choices for a particular project IS NOT just determined by the project itself, but also by the team that is going to work on it. If your choice of architectural style is one that has some noticeable learning curve (a famous example of which is the famous MVC), then it might be a really good choice for a team already familiar with the style, but not so much for a team without such an experience. Furthermore, if the future team composition changes from one with the aforementioned experience to one mostly without it, you MUST consider the cost of refactoring the whole architecture accordingly compared to the learning cost that each new team member will have.


Take Aways

This example was too simplistic, will this work IRL?

Although our example was relatively abstract and simplistic in nature, it touches one of the most essential aspects of software architecture: coupling and cohesion. More elaborate and complex examples mostly boil down to the same considerations and optimizations in the end of the day (alongside perhaps intuitiveness of the model for the problem domain). For example, MVC architectures break code-bases into three layers on the assumption that code in one layer (for example view) is more dependent on other code in the same layer (other view logic) than code in other layers (for example, the model layer). This is for example true when your view logic is highly dependent on some rendering framework/library, more than it is dependent on the controller layer logic, and the separation drastically reduces the cost of changes such as swapping out the rendering framework/library (which is why it was a more popular pattern at a time that such a change was more probable).


Does this mean I no longer need to rely on established paradigms and patterns?

Nope. This is an approach for objectively and quantitatively analyzing various architectural designs / decisions / even patterns and paradigms, not coming up with them. If the architecture of a software is the problem (“With which rules should I design my code-base to reduce the cost of future change?”), then various architectural paradigms and patterns offer solutions / solution-templates for that problem, and the aforementioned difficulty-measuring approach allows you to analyze and compare these solutions.

Furthermore, the established concepts, patterns and paradigms ARE solutions designed with the same considerations (directly or indirectly). As discussed above, MVC (or any other layer-based architecture) tends to contain most probable changes to a particular layer to reduce its potential cost, and cohesion and coupling are basically emergent from change-difficulty optimization, as we saw above. However, this approach allows you to properly verify which patterns and paradigms are most applicable to your case, and which might actually cause more overhead in the end of the day.


I realize that in the end of the day, software architecture still is a really hotly debated topic among software engineers. A lot of zealotry goes around with respect to strongly held opinion of what an ideal software architecture is, not deeming programmers not sharing these opinions worthy of the title of programmer to begin-with. The sentiment, even among more flexible engineers is that for each problem (hence project), there is a unique, beautifully elegant, borderline work-of-art architectural design that is future-proof until the end of time. But I fear most of the time these radical notions stem from the fact that people are not in touch with the pragmatic reasons of why we need a good architecture for a project, let alone what that means. However, if we drop these irrational biases and look at software architecture in the most detached and pragmatic manner possible, it all becomes quite clear:

Good architecture reduces the cost of future changes, for the people making those changes.

CONNECT platform

Guides on CONNECT platform and general thoughts about asynchronous programming, service composition, future of the cloud, etc. https://github.com/CONNECT-platform

Thanks to Mustapha Ben Chaaben

Eugene Ghanizadeh

Written by

Programmer, Designer, Product Manager, even at some point an HR-ish guy. Also used to be a Teacher. https://github.com/loreanvictor

CONNECT platform

Guides on CONNECT platform and general thoughts about asynchronous programming, service composition, future of the cloud, etc. https://github.com/CONNECT-platform

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade