About Software Architecture
So in the last few months I've had the pleasure to do research about software architecture. I've had the problem that the codebase I was working in turned into spaghetti with each commit added to it. You usually had to make commits that changed multiple hundered lines of code. The strain of what interacted how had to be kept in mind because everything was so spaghettified that you could basically not make a single change without thinking of the consequences in every other part of the entire system.
If you added something here, you had to change something over there and don't you DARE touch that struct! It will break the whole codebase!!! (even though it was only used for one subsystem)
If that sounds familiar I might have some insight to share with you on how to create and maintain software that is extendable, understandable and doesn't require at least 200 lines of code in changes for each new thing that's being added.
The Issue
The issue was plain and simply: not having a good understanding of how to architect the code. The codebase had no clear boundaries as in no good usage of modules (and thus no real modularity) and as a result, everything was allowed to talk to everything. This lead to a sort of spiderweb situation where if you pulled on one string, the whole thing would move. Frontend templates (not some http handler, no: The actual fucking html templates) would make direct calls to the database, modules that had - per their name - absolutely different concerns were suddenly instantiating structs that had nothing to do with their original job and things would just be all over the place.
When I saw this I took a couple of steps back and asked myself what caused this mess and what the solution would be. I started green fielding multiple times and came to some interesting insights.
Separation of Concerns
Separation of concerns can make sense in some circumstances and make life hard in others. In my case, separating different functional units of the codebase made a lot of sense and was a pattern that I intuitively did for most of the codebase...most but not all.
As I previously mentioned I had some html templates that for some reason made database calls. It looked somewhat like this (Go programming language and templ templating):
package main
templ ShowPeople() {
people := db.GetPeople()
<ul>
for _, person := range people {
<li>{ person.Name }</li>
}
</ul>
}
This should be a red flag. The templates should have only been given the data they need by their handlers like this:
package main
// Templating
templ ShowPeople(people []Person) {
<ul>
for _, person := range people {
<li>{ person.Name }</li>
}
</ul>
}
// Handler logic
func ShowPeopleHandler(w io.Writer) {
people := db.GetPeople()
ShowPeople(people).Render(context.Background(), os.Stdout)
}
That way the templating logic and the data retrieval logic are separated and changes in the data retrieval don't require changes in the templating logic. Another thing that might be useful is to fully separate the rendering logic from the data retrieval logic by using structs whose purpose is to only be used in the templates:
package main
// This struct is only to be used for templating
struct Person {
Name string
}
// Templating
templ ShowPeople(people []Person) {
<ul>
for _, person := range people {
<li>{ person.Name }</li>
}
</ul>
}
// Handler logic
func ShowPeopleHandler(w io.Writer) {
// Here people is a struct returned from the db
people := db.GetPeople()
var templatePeople []Person
// translate the database struct to the templates struct
//...
ShowPeople(templatePeople).Render(context.Background(), os.Stdout)
}
This has the advantage of being independent of the database struct which means
that changes to the databases internal logic would not break the template since
a translation is done beforehand. Say you would change the struct that is used
in the db so that a person has now a FirstName and a LastName field instead
of just a Name field; Yes this would break the translation logic but that is
in one place. You could just say person.Name = dbPerson.FirstName + dbPerson.LastName which is a single change instead of having to change every
place in the template that used the Name field (which is likely to be more
than the single place in translating the structs).
But as with all things this approach does not only have upsides. The obvious
disadvantage being: you now have to maintain one or more structs and their
related translation logic for each template. This adds a lot more code to be
maintained. Maybe you don't even profit from this abstraction because you WANT
the frontend to follow the data model exactly or perhaps the transition from
Name to FirstName and LastName was done to comply with some new regulation
and the frontend needs to be changed either way.
When implementing any architectural change it is important to always consider the positives and negatives of a change and pick the best (or least bad) option.
Modularity
Another problem I faced was bad modularity. I define a module as code that lives in a different namespace, package or some other term your programming language uses for this kind of idea. C header files also define new modules in my definition since they separate code through the interface they define. (This is probably one of my favorite parts of C).
The codebase was already split into multiple modules for the different tasks but the issue was that some modules had no good API, an unstable API with too many depending on it, or were too small to make meaningful use of being their own module. API in this case just meaning 'public' functions or exported Functions in the case of Go (the codebase was in Go). Since it was a relatively small codebase there was practically not a single module that was too large or did too many things.
Modules are very useful for making a logical cut in the codebase. You should use them if you think that part of a program's logic can be viewed independently from the rest of the code. This reduces mental load mostly: you don't need to know HOW the module does it's thing, you just need to know WHAT it does, what it needs in order to do what it wants to do and what it will give you in return...or in short: 'you just need to know the function signatures'.
Be wary of where you make the cut though. I found that modules can become quite large before a logical cut is really needed. But also don't let them grow too large. You'll know a module is too large when it becomes hard to reason about it's internals.
Interfaces (APIs)
I already mentioned that modules provide APIs. API means Application Programming Interface. This leaves a lot of room for what can be an API: some web service can have a web based API for which you would do HTTP calls, a library you're using has functions that you can use and many other types of API. Both the HTTP and the function based API represent two different types of architecture but we'll get into that later.
In addition to different underlying technologies that can be used to talk to APIs there are also internal and external APIs. External APIs are what I already mentioned above: some public functions from a library or public HTTP endpoints from a web service. But what about the internal APIs?
Since I just claimed that function calls represent API calls, calling private functions also means that you're calling an API: an internal API. Creating private functions can make sense if some part of your program does one thing multiple times and the logic for that is quite lengthy. It would then make sense to just put the lengthy, repeating logic into a function that can be called in a loop. Creating such a private function also represents a cut and can be viewed as a miniature module. You're hiding the internals from the caller to make reasoning about the caller easier.
This is also the reason why I don't think setting arbitrary line limits on functions is a good idea. Say you're setting the rule in your codebase that each function may not be longer than 80 lines. This means that larger functions would need to be broken into multiple smaller ones. This DOES make sense: small functions are easy to read and easy to understand. 80 lines of code can be read and understood relatively fast compared to some multi-hundred or even thousand line long functions as sometimes posted on r/programminghorror. But this can also cause function definitions in the codebase to balloon without an inherent reason. Let's say I have a function that does some very simple but very laborious sequential work that is not repetitive and does require a lot of lines of code (Golang makes this easy to achieve since the language is very verbose). Splitting this function into multiple smaller ones then makes it harder to reason about since you have to look into every sub function, understand what that one does and then go back to the caller and so on. Additionally these tiny sub-functions make little sense on their own and would probably not be re-used by other parts of the module.
Depending (less)
Speaking of re-using code, one view that has really changed over the time of my research was code re-use. The "Don't Repeat yourself" (DRY) principle states that you should not every repeat yourself. I think this is one of the biggest pieces of dogshit advice that has ever been given.
Repeating yourself is very good when it comes to lower dependencies in your code. Why would you want to do this? You would want to do this because dependencies often cause the other software component to change if the one that it depends on also changes.
If you're not copying meaning you're not repeating yourself, you're creating functions. Functions are an API as I just made clear in the previous chapter and if that API changes, so must the one using that API.
Let's say you often have to do X. Your programming language sadly doesn't give you an easy way to do X so you have to do it yourself. You decide to create a function that does X and use it all around in your codebase. Turns out: One of the places where you use that function needs to do something that is very very similar to X but is actually Y. You decide to modify that function so that it does Y but you fail to remember that this function was used in other places. This wasn't noticed since doing X and doing Y are really similar but differ in some minute way and now a lot of places in the codebase are buggy.
This could have been prevented in multiple ways:
- Create a new function that does Y instead of modifying the function that did X
- Don't use functions and just copy the required functionality to where it's needed.
Not using functions makes sense when there are really not that many places in the codebase that need to do X. That way the code will remain stable since there is no dependence and you get the freedom of changing the code however you want without fear of breaking things.
The guiding principle here is: "A little copying is better than a little dependency" which is a Go proverb and it works for many things: external systems, libraries, functions, etc.
Monolithic Design
One thing I played on earlier was HTTP (network) calls versus function calls. This entire post is more in line with building monoliths. This is not because I think that monoliths are the shit and if you do microservices you're an idiot: Everything has it's advantages and disadvantages.
I do think that a monolithic application makes a lot more sense for a lot of use cases though. The main difference between a monolith and a microservice is basically function call versus network call. In a micorservice architecture you're building your modules to be their own application running in their own environment and every communication happens over the network.
As the grug brain dev puts it:
grug wonder why big brain take hardest problem, factoring system correctly, and introduce network call too
seem very confusing to grug
Architecture Patterns
Pre-existing architecture patterns are supposed to tell you how to build the software. I generally don't like the idea of saying 'this is the pattern we'll use for our software' and just sticking to it since I believe that software solves problems and the architecture and design of software emerges from the problem you're solving. But this approach does have it's advantages if the pattern works with the software you're building.
An architectural pattern that works with what you're building will most likely reduce time wasted on discussions and require your team to do less thinking about stuff like where a function or file needs to be placed and more about the actual problem they need to solve.
If you're struggling with your codebase I recommend starting from a green field and trying out different things. Be experimental and be sure that you mocked the basic use case of your software. Try moving functions, try creating or collapsing modules, find out which things make your code more understandable and what things make your code harder to reason about.
I did this and found out a lot of things. I then read up some architectural design patterns and it turns out that I was more or less following the grounding principles of the Hexagonal Architecture. I very much believe that the branding and the fancy words around this design pattern don't invent something new. Under the hood it all comes down to interfaces, dependence and modules. But the benefit it does give is that you speak the same lingo. When you learned all the vocabulary and the meaning behind it and someone new comes into your team who by coincidence already knows about your design pattern, their productivity will be a lot higher than if they needed to read into a self written guide of the systems architecture or if they had to read through the codebase to understand how things are structured.
But be careful: following a design pattern means less thinking. Less thinking can be good and bad :)
Final Words
There are many more things to be said about software architecture and maybe I'll extend this post or write another one in the future. For now: these are some things that I've noticed in the past make a big difference on the extendability, flexibility and reasonability of a software system.
I also want to link to Casey Muratori's video "Clean" Code, Horrible Performance which also talks about some software design patterns and what they do to to software performance. The contents of that video fit more or less into this topic but I thought it is interesting so maybe the same goes for you :)
Thanks for reading!