Software Architecture and the Paradox of Choice

Image of Author
December 3, 2019 (last updated March 1, 2023)

(An associated slide deck was presented for Tech & Startups Conference 2019.)

What is software architecture

That's a hard question. Even IEEE and other standards bodies have a hard time with it. The current, post-revision, IEEE standard is vague, open ended, philosophically clean, and a great read.. But, it's impractical and it's definitely complicated, which are two things we don't often need in our lives. So, let's not start with dictionary definitions and standards. Let's do our best to approach this practically and simply.

The three fundamental questions of software architecture

If I asked you how you to describe the software architecture of your company, or of a previous company you worked for, I bet you would present me with one of two things:

  1. A document with boxes and arrows, and maybe more complicated diagrams.
  2. Technical experts drawing boxes and arrows, and maybe more complicated diagrams.

So, what are those boxes and arrows doing? What do they represent? What do they explain? I claim they aim to answer the three fundamental questions of software architecture:

  1. What are the things?
  2. Where are the things?
  3. What are the relationships between the things?

For example, here's a typical software architecture description, followed by form-similar generalized sentence:

  1. We have a React frontend hosted on Cloudflare that talks to a Rails backend on Heroku. We also have a task runner on AWS reading a task table every hour on the shared Heroku PSQL DB.
  2. We got a thing over here in relationship with a thing over there. We also have this whole other thing over in this other place in relationship with yet a whole other thing.

Whatever the most accurate definition of software architecture is, it's clear that the types of conversations had when discussing software architecture revolve around these three fundamental questions. This is important when looking at the job of the software architect. The goal of the software architect is to design and create the answers to the fundamental questions.

Neat. Now, let's figure out how to do that job well.

The paradox of choice

Simply choose at least 10 compatible paradigms: IaaS, CaaS, PaaS, FaaS, DBaaS, SQL, NoSQL, Microservices, Monoliths, Message Queues, Task Queues, Event Driven, XML, JSON, Single-Threaded, Multi-Threaded, Functional, Object Oriented, Reactive, Local Config, Remote Config, Pseudo-Bus, Circuit Breaker, Multilangauge, Monolanguage, Pure CDN, Reverse Proxy, Multirepository, Monorepository, Issue-Centric, Backlog-Centric, Buildpacks, Multipipeline, Monopipeline, Environment Management, A/B Testing, Analytics, Feedback Circuits, Metrics... and I haven't even listed a tool yet!

All the above are (for lack of a better word) (architecture) paradigms, each of which opens up into a world of different philosophies, with each philosophy opening into a world of different solutions. Also, they definitely don't all play nicely with each other. Composing a working system out of all this can be a daunting task.

This is the joy of devising a new solution, but also the pain of it. For eager learners, exploring new ideas and structures is an exciting prospect. But, it's business. You are time constrained. If you are like me, you will succumb to the negative psychological impacts of the paradox of choice, where the unconstrained sea of choices feels daunting and unnavigable. You will doubt your decisions and change them multiple times, pre-implementation. You will feel like you are wasting your time exploring unlikely options just to confirm that they are indeed not worth using. You will sit beneath Syliva Plath's proverbial fig tree, surrounded by possibilities but unable to pick a future.

So, you know, a little good, a little bad. I, for one, absolutely love software architecting. Determining a contextually tuned, healthy solution-set is something I have always enjoyed helping people and teams do. To that end, if you are looking for technical advising in this area, get in contact with me and let's see if we can figure it out together. That said, since it is so contextual, I think it would be ill-advised to analyze individual solutions here. Instead, I want to focus on what is universal across all software architectures.

The universal truth of software architecture

It changes.
-- Mother Nature

The point is... software changes, software architecture changes, teams change, third party dependencies change, everything changes! What are we going to do about it? The short answer is lean in to change.

Evolvability and the path to less pain

Evolvability is the ability of your software system to respond easily to change. It's the ability to evolve and adapt well to new business constraints, and to shifting environments.

Step one of every software system is creation. Once the initial work has been completed, your codebase will likely be an extension of your organization, vis a vis Conway's Law:

"Organizations which design systems ... are constrained to produce designs which are copies of the communication structures of these organizations."

The relationship between software and the organization that creates it needs to be appreciated. Conway's Law does not only apply to initial systems. It's a perpetual claim. The profound effect here is that if your software is evolvable, it will be because your organization is evolvable.

Step two of every software system is evolution. There are tried and true ways to do this well, like 12 Factor.

The Lock-In Monster

Vendor lock-in, solution lock-in, implementation lock-in, etc. 'Lock-in' means that your system cannot perform its functionality without an explicit reference to the utilities involved.

(Sometimes, this cannot be helped. For example, you are always locked-in to the programming language you choose. Switching programming languages means doing a full rewrite. But, in a lot of scenarios the Lock-In monster can and should be avoided.)

The goal is that, while needing external utilities to perform functionality, your code does not need a particular utility to perform its functionality. Instead, it needs any utility that provides the general type of service you need. Remember previously when we were talking about paradigms, philosophies, and solutions? The goal is to code to a paradigm, and not a particular instantiation of that paradigm. Any instantiation of a paradigm is in service to that paradigm. So, in theory, you should be able to use a paradigmatic interface in your code that any solution within that paradigm will adhere to.

Speaking of the word 'interface', one of the easiest ways to escape the Lock-In Monster is via the "I" in "SOLID". Let's look at it in more detail.

The Interface Segregation Principle

The Interface Segregation Principle is quoted on Wikipedia as "no client should be forced to depend on methods it does not use." This negatively-phrased directive expresses the power of interface-based programming, but I think there's an easier, positive directive: Use interfaces!

If your code needs to eat pizza, you could code it to eat Pizza Hut pizza. But, you would then be locked-in to eating Pizza Hut pizza. Instead, have your code eat arbitrary pizza that adheres to the pizza interface. Then, in an isolated file, you can provide Pizza Hut pizza to your code. Now, if you ever swap out Pizza Hut for, say, Domino's, all you have to do is create an isolated file that makes Domino's pizza. It's pizza will also adhere to the pizza interface. Now you supply Domino's pizza to your code and your code is none the wiser, since it consumes arbitrary pizza. Your code is now a pizza-maker-independent pizza eating machine (just like me).

Let's take a look at what this would look like.

class PizzaHutPizza {
  public greasiness() {
    log('7 out of 10')
  }
}

// Bad! Your eat function can only eat Pizza Hut pizza!
function eat(PizzaHutPizza pizza) {
  log('Yum, pizza! Greasiness score: ')
  pizza.greasiness()
}

const PizzaHutPizza pizzaHutPizza = new PizzaHutPizza()
eat(pizzaHutPizza)

Instead, eat an arbitrary pizza, and pass in Pizza Hut pizza as the specific pizza.

interface Pizza {
  public greasiness()
}

class PizzaHutPizza implements Pizza {
  public greasiness() {
    log('Yum, pizza! Greasiness score: ')
    pizza.greasiness()
  }
}

// Good! Your eat function can now eat arbitrary pizza!
function eat(Pizza pizza) {
  takeBiteOf(pizza)
}

const PizzaHutPizza pizzaHutPizza = new PizzaHutPizza()
eat(pizzaHutPizza)

Now, when you switch to Domino's Pizza, you don't have to update your eat function. All you have to do is create a DominosPizza class that implements the Pizza interface. It's a change where nothing changes!

class DominosPizza implements Pizza {
  public greasiness() {
    log('6 out of 10')
  }
}

const DominosPizza dominosPizza = new DominosPizza()
eat(dominosPizza)

When your architecture changes without your code changing, you have achieved evolvability.

Separable code

Another way to address the evolvability question is by talking about separability. Separable code begins with the principles of High Cohesion and Low Coupling, which are contained within the GRASP patterns. But, separable code extends farther than healthy design patterns. If you combine good patterns with Domain Driven Design you can facilitate a system's evolution from Monolith (or Megalith) to fully Microserviced. Even systems that use Functionality Driven Design, like Rails Monoliths, can benefit from domain driven design patterns.

Domain driven design encourages developers to break down code by (business) domain. The benefits are that these componentized subdomains can be isolated, allowing for independent scaling and independent developer focus across subdomains. A good example of this type of evolution is from Pivotal Labs. They have an open-source repository and associated website that will walk you through an evolving application (in Java Spring).

Conceptually easy, practically unseen

Principles, patterns, paradigms, and philosophies are all helpful. But, when you are leading a team, when you are in charge of business sensitive deliverables, and when you need to occasionally crank out features, how do you balance those necessities with keeping your codebase healthy? This is a million dollar question (quite literally, at times).

The secret, in my opinion, is trusting your developers. Give them the freedom to express their opinions. Accept their estimations. They already chronically under-estimate, don't make it worse by pressuring them. Listen to their opinions. If they claim technical debt is debilitating, then let it debilitate your progress! It will one way or another, so at least control it by incorporating it into your timelines.

A quick note on "technical debt"

Technical debt is not bad code-writing. Technical debt is the inherent entropy of a system. Code will fall into disrepair, even if you don't touch it, in the least because it's dependencies are constantly changing. Technical debt is inevitable. In fact, since it is inevitable, the "debt" analogy is not a great one. I don't want to replace it with another not-that-great analogy, so I'll just say, give your devs time to refactor.

Do not be the boiling frog

Developers can complete features as quickly as you want them to. But, they will do it by neglecting the slow, seemingly unproductive contemplation of interfaces, of abstractions, of domain design, etc. If they are never allowed to take the open-ended amount of time necessary to contemplate and enact improved codebase designs, then the tech debt will accrue imperceptibly. Like a frog in boiling water, no one will notice how the whole apparatus has steadily slowed to a crawl. Here's the trade off: you can allow a few features to take twice as long as they could right now, or you can have every feature a year from now takes twice as long as it should.

The problem is not the devs, it's not the business stakeholders, it's not the prioritization, or the product team. It's everyone. It's the whole organization. If your organization exists to create software, then everyone needs to respect the fact that software is more than a set of features. Software is a set of features and a set of designs and a set of interacting structures reaching across complex networks and complex file systems. Even developers will think, in a moment, that turning a button green is as easy as "turning a button green". But, sometimes, it can be a lot more complicated than that.

Conclusion

I can add stores to a mall by setting up large tents in the parking lot. If you are just looking at sales numbers and pretty display cases, you might not notice that we don't have the structural integrity of the mall you envisage in your mind. Rather, we have a flea market ready to blow away in the next storm.

Trust your devs. Give them space to refactor. Encourage them to pursue the popular principles of High Cohesion, Low Coupling, Interface Segregation, continuous refactoring, and all the good practices and patterns stuff. You don't just do these things once. It's a workstyle/lifestyle thing. It affects your whole organization.

Your architecture will inevitably change. It should. That's good. To survive the shifting sands, embrace evolvability.