How do you build software that can gracefully adapt to change and extension?
A few months ago I would have gone off about SOLID principles and design patterns. OOP, DDD, and the like. While those provide solid advice in today's OOP-crazed world, I have a different perspective on things now.
My new perspective is that communication is the foundation for building composable architecture, to illustrate my point I'll present a few examples of highly scalable systems, and references to materials from people smarter than I am.
Many of these systems seem simple but are highly capable. Also, we mostly never reason about them much.
1 - The Internet
The internet is probably currently the most used technology. Highly ubiquitous, and so present we do not always wonder - how is it that the internet can be so highly available and so fault-tolerant?
The obvious answer is decentralisation, of course. And it is. But how is this achieved?
The internet is composed of billions of independent clients communicating over a simple interface. If we think of it in terms of software the internet is made up of billions of objects - each with an address, and a way to send messages over to these addresses. The interface is simple, consistent, and well-understood.
NB: There are many interfaces for communicating on the internet, let's stick with HTTP.
To add to the internet you simply create a new object with the capability to communicate over this well-understood interface and give it an address.
You can see how this simple and well-understood communication channel enables a high degree of composition, you don't even actively think about it, it just works.
Of course, this is a simplification, work needs to be done around figuring out where each address points to and the best way to send messages to it. This is a point of failure, the internet achieves tolerance by distributing machines that can do the job of resolving these addresses.
The point is - the internet has proven to be highly scalable, highly adaptable, highly capable, and highly composable. it achieves this level of composition because at its core it's about communication, and it's about communicating in a simple manner.
Object-oriented programming
In the past few days, I've listened to two talks from Alan Kay, which you can find here and here. In the second video, people seemed to think he spent 50 minutes talking about nothing, but for me, he just fixed the final piece of my puzzle. For months prior I've been searching, and that search has been on one theme - software simplicity. I was searching for the key to creating software that looks simple, and feels simple, yet is highly complex.
Ironical that the final piece of the puzzle would be fixed by Alan Kay; because, you see, my search was prompted by an urge to find better (and simpler) ways of doing something, a way that isn't OOP. Alan Kay, as you may or may not know, coined the term object-oriented programming. He also created Smalltalk.
It turned out that Alan Kay's idea of OOP is not about language or classes, or all these things Java, C++, and many object-oriented languages today focus on. It can be argued if his idea should hold any salt to how these languages are implemented as they're mostly influenced by another language. But his idea certainly resonated with me. You see, objects are not about the language or the objects. Yes, it's not about objects, it's actually about how communication happens. In Kay's idea of OOP, messages, and the mechanism for passing messages is the fundamental part. Objects are just a way to represent independent processes which perform independent operations, they have a mechanism for receiving messages; the actions they choose to perform in response are up to the process.
The big idea is "messaging" -- that is what the kernal of Smalltalk/Squeak is all about (and it's something that was never quite completed in our Xerox PARC phase). The Japanese have a small word -- ma -- for "that which is in between" -- perhaps the nearest English equivalent is "interstitial".
The key in making great and growable systems is much more to design how its modules communicate rather than what their internal properties and behaviors should be. Think of the internet -- to live, it (a) has to allow
many different kinds of ideas and realizations that are beyond any single
standard and (b) to allow varying degrees of safe interoperability between
these ideas. Alan Kay on Messaging
When I say objects here I'm referring to Alan Kay's idea of objects.
2 - Microservices
How do you scale your software system across multiple teams and departments? It doesn't take much genius to know monoliths can only take you so far. As you scale you want your teams to work as independently as possible without being an obstruction to each other. But at the same time, you want coordination and communication between teams when necessary, this is how you scale.
Organisations have long settled on service-oriented architecture to scale, recent strides in containerisation and cloud infrastructure have brought this architecture style to even smaller companies. When it works it works well, when it doesn't it's a mess.
What is the difference between a SOA that works and one that doesn't? Communication.
What enables microservices to scale so well? communication.
Microservices compose well and allow organisations to extend, adapt, and add services without much change to existing services. The only reason this works is that in a well-setup architecture, there is always a simple and consistent communication interface and the communication around this interface is fundamental.
Let's take our example down a bit to something more personal
3 - Unix operating system
And its look-alike, Linux
Unix revolutionised computing and how we interact with computers. Today it is still regarded as being exceptionally simple, but this is only an illusion of the fact that the way unix works is very easy to conceptualise.
Unix started by nailing a fundamental step down, at the very early stage. Does anyone want to guess? Yup, communication.
How does Unix communicate? files. At the time Unix was created reading and writing to files were not quite as simple as it is today. Creating files required too many round trips and too many back and forth with the OS. Also, files had a lot of metadata containing a lot of information about each file. Unix did away with all these things, file is a sequence of bytes, what is inside is frankly irrelevant, doesn't matter, and isn't important. Anything can be written to a file, Unix doesn't impose a structure.
With the concept established, Unix made everything a file. If you own a Unix or Linux machine every hardware is mounted to a file. So in Unix, the file is the communication channel, easy to understand. The API to read and write to these files is even simpler. These systems provide C functions to open a file, which returns a file descriptor (basically an unsigned integer). Using this file descriptor you can read or write to the opened file. These seemingly simple design choices have had an enormous impact on how Unix has adapted to change. The OS was developed for teletype, you can still see references; the terminal is still referred to as tty on MacOS. Digital terminals were easy to add, you just needed to mount it to a file. Monitors? Mouse?
If you own a MacOS (and I presume Linux as well) you can test the theory of everything being a file by writing to your teletype (ehm, I mean terminal) now. Just get the device file your terminal is mounted on (use the command tty), then write to this file.
$ tty
/dev/ttys010
$ cat {some file} > /dev/ttys010
some content
Congrats, you just echoed to your terminal by writing directly to it.
I could write an entire book chapter about the degree of composability this simple, consistent, and well-understood interface brought to Unix, but I'll not.
4 - Concurrency-oriented languages
Let's talk about enabling composition on the hardware and software level.
In 1973 and 1978 two concurrency models appeared - the Actor Model and CSP. What these two both described is how you could implement a highly concurrent system without encountering all the problems already plaguing attempts at parallel computing - resource contention, deadlocks, race conditions, etc.
Can you guess what the fundamental aspect of these two models is? Yup, communication again.
The main difference between these two approaches is how the communication happens. In the actor model communication happens asynchronously, while in CSP it's synchronous.
Erlang (the BEAM virtual machine actually) is built around the actor model. Funny story there, the team developing Erlang had apparently never heard of the actor model when they developed Erlang, but they wanted to solve the same problems. Erlang's concurrency model turned so strikingly similar to the actor model that it's today used as a practical example of the actor model. Erlang is a highly scalable, highly tolerant, and highly concurrent language. It works by spawning tiny independent processes (green threads) which communicate via asynchronous message passing.
Go is a highly scalable and highly concurrent language developed around the CSP model. Go processes (called goroutines) do not communicate directly with each other, but instead communicate over a channel. When a goroutine sends data over a channel it must wait until another goroutine reads from that channel, and vice-versa. This way the channel can act as a platform for co-ordinating goroutines.
The result of this is that these languages can scale from a single-core, single-thread execution environment to a multi-core environment while requiring little to no change to written code. In the case of Erlang the communication is designed to happen cross-machine so not only can the system scale to multiple cores without much code change, it can scale to multiple machines - each process communicating like the other process is still on the same machine.
NB - While I use ErLang here, the messaging, concurrency, and control primitives are built into the BEAM virtual machine, so also applies to other languages that run on BEAM, like Elixir.
Let's go a little lower and talk about communication in the code we write.
5 - Composition over inheritance
Program to an interface, not to an implementation
I have written a lot about communication and composition, starting from the most general example I can think of and down now to the languages we write.
Structuring your programs in a way that allows them to depend on each other without tight coupling is essentially what we call composition in software development. And can I call it a dependency if tight coupling is not involved? anyway...
Many modern languages allow the programmer to define some sort of abstract interface, a set of attributes other objects must implement before they can be taken as a value wherever the interface is expected.
Thinking about it now it's clear this interface acts as a communication channel. OOP has actually been about communication, even in languages like Java and C++; methods calls are sometimes referred to as message sending.
The important thing about composing with abstract interfaces is that it makes messages rather than objects the fundamental part. It does not matter what an object is, or what it does; what matters is that it defines a set of properties that are important for communicating intent in the current context. When you have a function that uses an interface as an argument type rather than a base class, you can see how that eliminates the need to make future objects couple themselves to the base class.
You can immediately see that as soon as we make objects less important than communication (and the messages) it becomes trivial to make changes to these objects, replace them with other objects, or change the underlying implementation - so far the interface remains consistent.
What am I talking about here?
Composition is all about communication. It's not about objects, or classes, or methods. It's about providing a consistent pattern of communication such that independent processes can communicate without really knowing who/what they're communicating with.
I started by speaking about the internet to make it clear that I'm not limiting composition to just written software. Almost every large structure or organisation is composed of small independent but highly coordinated parts - which communicate over well-defined, simple, and very consistent interfaces. In fact, success is largely defined by how well these small independent parts communicate with each other. If you build without figuring out communication between components you're putting the cart before the horse.
A lot of time should be spent early on determining how objects and components should communicate - how addresses should be resolved, how responses should be handled, how miscommunication should be handled, and how failures should be handled. Many problems you'll encounter, as well as many complications, will directly or indirectly be a result of the decisions (or the lack) made in this early stage. Communication should not be an afterthought, it should be fundamental.
The result of not factoring this decision early is an introduction of ad-hoc short-term fixes to solve just the current problem but not the next, or the previous.
Designing interfaces that are simple and consistent
I debated with myself about whether I should include this section as I'm not in any way an expert on this topic. I decided finally to share an observation, rather than a guide.
It seems the secret to making a simple but consistent interface is to impose as little structure on the interface as possible. Interfaces shouldn't be burdened with metadata about what the data is, nor should any runtime which resolves the address of the objects be concerned about whether those objects can answer the message or not.
How does this work in practice? Well if we go back, the reason Unix has worked so well is because the operating system decided not to impose any structure on what sort of content can be saved in a file. The file can contain anything, unicode characters, binary characters, doesn't matter.
If metadata is important it should be a part of the message, not the interface. This allows the interface to stay consistent across multiple unrelated applications. How does this work in practice? The Socket implementation in Unix is one of the many things that takes advantage of the Unix file API. Like ordinary files, no structure is imposed on the data (packets) sent over a socket. TCP builds upon Socket but adds guarantees about how messages are structured. It particularly specifies what the first few packets sent over the socket are, and attaches a way to reconstitute packets that arrive out of order. It still didn't impose a structure on what type of data can be sent over TCP. HTTP is built on top of TCP, it specifies data around headers and adds an important rule - HTTP connections are not long-lived, ordinarily socket connections are long-lived. HTTP connections are disconnected after every request/response. HTTPS specified a way to encrypt the payload sent over HTTPS. HTTP still did not impose a structure on what type of content should be sent over it; this has allowed gRPC, GraphQL, JSON, raw strings, HTML strings, etc to be sent over HTTP. All the meta-information necessary is sent as part of the message. It should be noted that HTTP headers are also part of the message.
What this means in essence is that the task of determining whether the contents of a message are valid should be determined by the communicating objects. Two communicating objects can decide on a message format and use it between the two of them without requiring other objects to change how they communicate as well. If HTTP had imposed a structure on the payload sent over HTTP (say only HTML) it likely would not have allowed extensions like GraphQL to exist. At least they would have had to define another protocol on top of TCP.
I think an early mistake usually happens at the start of the project where, thinking that all information has already been provided, decisions are always made to provide tight integrations with just the current objects/components. Sometimes assumptions are made regarding future objects as well, there is an expectation that they'll also conform to this early standard. This early mistake always tightly couples the message being sent over an interface with the interface. It has a form of specifying various meta-information surrounding what makes a message valid along with the message. Another problem is where the task of checking the conformity of message specifications lies. Objects should be determining whether they can handle a message, the API should not be making that decision or it'll not remain consistent in the face of change.
In Go, there are io interfaces that embolden this general-purpose simplicity. Here is one:
type Reader interface {
Read(p []byte) (n int, err error)
}
This interface expects an object that has a Read method, which takes in a byte slice; it returns how many bytes it was able to read, and/or an error. A slice in Go is a dynamic array. And a byte, as you may know, is the length of one character. So this expects an object that can read an arbitrary number of characters. The profound thing here is that an object can decide based on say, the first few bytes read, that it doesn't understand the message and then quit.
Two completely unrelated components of your program can decide on the structure of their message without imposing it on other components using this interface. This allows the interface to remain consistent. it allows you to plug in other components, or to remove them. This simple Go interface has been used in a multitude of ways, with too many different objects. This interface and a few other similarly simple interfaces in Go's io package have been the de facto language for input/out in Go - application and project notwithstanding.
Of course, your implementations and final decisions still have to be decided by what you're building, these are just general observations.
Conclusion
Ehh, I don't know what to conclude with, I'm out. Hopefully, somebody will find this useful.