Our Development Philosophy (1): Architecture, Design Patterns and Programming Principles
When software projects become bigger and more complex, you might reach a tipping point: one day, code quality becomes paramount. You can get away with easy fixes and a quick & dirty mindset in smaller projects. But as soon as complexity increases, you'll begin to feel sorry for every compromise you've made so far.
We've certainly experienced these growing pains in our own journey - with Tower steadily growing to now serve 100,000 users. This post talks about some of the important things we've learned along the way.
Be sure to also check out part 2 of this series: Collaboration & Testing
Applications Need Solid Foundations
Building even the smallest of software applications is a major investment for most companies. Your team will spend days, weeks, months, and maybe even years building that application. But not only is this a huge one-time investment - you'll have to continue to put time and effort into it over its complete lifetime. Neither the coding nor the investment will stop when the application launches.
Let's take our own product Tower as an example: we had worked for 12 months to develop version 1.0 and bring it to market. But in the six years since then, we have produced many times more code than the original product contained.
This constant, never-ending maintenance and extension of an application means that its foundation becomes crucial. Much like with a house, it's not a clever idea to save some money by building just a cheap foundation.
Logically, one of our biggest goals is to make extending and improving that application as easy and safe as possible. This is where application architecture comes into play.
Only a really good architecture will help us protect the huge investment that building an application means. We will explore in greater depth what "good" architecture means (in our humble opinion). But the main qualities you'll want to aim for are the same timeless classics in every project: solidity, maintainability, extensibility and scalability.
Improving and refactoring an app's architecture will often seem like luxury - or maybe even like wasted time. But in reality, it should better be seen as a necessity, as a crucial and one of the most important duties of an experienced software engineer.
When we ask ourselves if we should invest into architectural improvements, the answer is usually yes.
Choosing the Boring Solution
When solving a complex problem, it's sometimes very tempting to choose an extraordinary solution. Not only will this solve the problem - but it will also impress your teammates and bring you everlasting glory. 🦄
An even better solution, however, would probably be a boring one. One that is easy to understand, even for your junior colleagues. One that is well-known on your platform and language. One that is absolutely not extraordinary.
Using such a boring solution means that you're using simple vocabulary, which greatly increases the chances of everybody understanding you. This will make it a lot easier for everybody to follow along - including new team members (and yourself, a couple of weeks after you've written that code).
"Software development is complex enough by nature. When in doubt, go with the boring solution."
When in doubt, go with the boring solution.
Coding the Lego Way
Trick question: if you had both modeling clay and Lego bricks available, what would you choose for building your application? Let's say you went with the modeling clay: pretty easy to shape, bright colors, strawberry taste - what more could you want? But the problems are inevitable as soon as you want to correct, extend, or improve something you've already built. There is no way to easily separate individual parts after you've mixed and mingled them.
If you've chosen Lego bricks, on the other hand, subsequent changes are easy: the yellow "authentication" block isn't big enough? Just take it out and replace it with a bigger one. The green "export format" block needs to be extended with a PDF option? Just put an additional light green brick next to it.
Modularity, the concept that the Lego bricks symbolize, is synonymous with extensibility, maintainability and longevity of your application. No matter which framework, language, or programming principle you prefer: always shoot for modularity in your code!
Aiming for Simplicity
Acronyms FTW! Car enthusiasts might now think of BMW, but software developers should think of KISS and YAGNI.
"Keep it simple, stupid" should remind us that the simple solution will always beat the overengineered solution. The reasons why this is true are almost endless. And they might be easier to understand when looking at the opposite: complex code.
- Complex code is a perfect hiding place for mistakes.
- Complex code is hard to understand, for your coworkers and yourself.
- Complex code cannot easily be extended.
- Complex code cannot be reused. And, last but certainly not least, writing complex code will cause your teammates to brand you as an outlaw.
When a simpler solution seems sufficient right now, you should always aim for it.
"Writing complex code will cause your teammates to brand you as an outlaw."
When you realize that your solution isn't really necessary at all, you should drop it. This is what YAGNI is about - "You ain't gonna need it" reminds us to stay modest when planning the volume and scope of our implementations. Will users really need this feature? Will they need this option within a feature? These questions of course will translate to our code: will we really need that class / module / routine?
Constantly Redefining the Term "Edge Case"
This point might not apply to every application in the same extent. But with Tower being used by over 80,000 people worldwide, we constantly had to redefine the term "edge case" for us.
If your application serves a large user base, you will inevitably have to be more thorough when thinking about how people will use it. Things that rarely occur with a thousand users might become a daily event for 100,000 users.
This makes defining the term "edge case" a very individual matter: each and every team has to define for themselves what they consider an edge case. Also, be prepared to constantly redefine this term as your user base grows: Your current edge cases become too common to qualify for that label; and, at the same time, new edge cases will appear.
It pays off to invest a little more time thinking about these things before jumping into implementation. This way, you can include graceful handling of these cases already when writing the original implementation. This is much easier than having to catch up on it a couple of weeks later - when both your memory of the problem isn't fresh anymore and when the innocent little edge case has somehow turned out to be a full-blown bug.
Creating Good APIs
I'm sure you've used a third-party API at some point in your dev career - for example to create new contacts in your CRM, to send emails through a newsletter service, or to virtually do anything else with a third-party service.
If you've interacted with a couple such APIs, you will certainly have noticed some differences between them: using one was probably more pleasant than the other. It's easy to notice which API was designed thoroughly, by an experienced developer, and probably with a lot of effort and thought. And it's just as easy to be frustrated with an API that was designed in a poor and sloppy way. The former was probably a joy to use, while the latter was probably... not.
Since the effects are so obvious, most developers tend to quickly agree that it's almost a duty to design public APIs in a careful and thought-out way. Nobody wants to work with a crappy API - and nobody wants to burden other developers with using their API being crappy.
Modern software design puts great emphasis on the concept of "application programming interfaces". However, as most developers already know, the concept goes a lot deeper and is not exclusive to a public interface. Instead, you should build APIs inside your application, for internal use, too.
Approaching these internal APIs in exactly the same way you'd create a public one can make a huge difference: your colleagues (and you) will want to interact with this part of your application. Making the interaction as easy as possible for these people is one of the best goals you can have.
An easy, thoughtful API is probably the part of your software where quality matters the most. Your colleagues might forgive you a little sloppiness in the internals of this or that method. But they won't (and shouldn't) forgive you for creating a bad API.
Sometimes, a solution is so beautiful, you wish you had the right problem to apply it to. But unfortunately, problems come first. As beautiful as your new screwdriver may be (imagine a handle made of gold, with your initials engraved, of course...), if the problem at hand is to knock in a nail, it makes for only a less than perfect solution.
Now, after teaching you all I know about manual craft, let's return to software development - and consider "design patterns" as your toolbox. Every design pattern you know (and understand) is an instrument in your toolbox. It's certainly great to have many of them!
The problems start, however, as soon as you let the patterns dictate your coding. They should be there to support you, to propose a proven solution - for the right problem! Programming paradigms should be used where they fit and not be enforced. Your components should be designed with your application's requirements in mind - not with a beautiful design pattern.
"Design your software with your requirements in mind - not with a beautiful design pattern!"
In cases where you've indeed found a helpful pattern for your current problem, there's only one more thing: be sure to really understand the pattern and its consequences on your coding.
Embrace Best Practices
It's hard to find a programming problem that hasn't been solved by someone else already. And still, developers around the world are reinventing the wheel countless times, every day. I think it's a mixture of different things that encourages people to do this:
"I hate having 400 third-party libraries in my project." - Absolutely understandable, no one would love this. The thing is: when I'm talking about "solved problems", such a proven solution doesn't necessarily have to take the form of ready-made code. It could also be a mere concept, a design pattern, or simply a discussion with the guy next door that you know has solved something similar. Solutions can take many forms - so don't limit yourself to just "libraries" and other forms of "complete" solutions.
"It's just a small probem. I'll have my own solution in no time." - Every developer with more than a single day of experience has learned a very valuable lesson: Problems are (almost always!) more complex than they first seem. Experienced developers will have learned another lesson: Even with growing experience, it's still somewhere between hard and impossible to see all of the potential complexities that a problem contains. Put simply: we are prone to underestimate problems, again and again.
All of this means that we should thoroughly evaluate if the problem at hand really has to be solved by you on your own.
"I don't like the existing solutions. I can create something better." - This could very well be another form of underestimation. Especially if a solution has been around for a while and used in many projects, you should thorougly check if your evaluation of that solution being bad is really correct. Again, we often tend to underestimate the complexities that hide in even the simplest problems. There's also another dimension to this topic: if the solution we're talking about is a commonly agreed way to handle such a problem - either in your team or on your technical platform - then you should again think twice before you go your very own way. The very least you should do is discuss your objections with your teammates.
All of this means that "best practices" - in the form of proven concepts, conventions, patterns, and high-quality libraries - should always be your first point of reference. After carefully verifying that those best practices aren't suitable for your special case, you're free to go your own way.
Just for a moment, please imagine we'd be stuck in the eighties: we'd be stuck with rotary phones, flared pants, and terrible haircuts. What an unsettling idea...
But thankfully, the world has evolved: technical (and fashionable) advancements have made things possible that weren't possible before. New technology has enabled us to create new things.
But what about "old" technology? What about the software frameworks and libraries that were created yesterday? Are they, as a natural consequence, yesterday's news?
In many fields, especially on the web, it's easy to get this impression: the newer the framework, the better it must be. Everything that was created last week is automatically inferior and, by all means, should be abandoned. But by following every new trend, we chicken out of the hard work to improve the things we already have - and miss out on a lot of quality.
- Quality takes time. A piece of software that is new hasn't reached its peak, yet. It cannot. It will inevitably contain bugs and other problems that one can only hope to correct with time and lots of hard work. New technology, therefore, isn't always better technology.
- Quality needs collaboration. If, over time, we also seek the advice and feedback of others, we can harness another chance to make better software. Note that "collaboration" in this sense can come in many forms: as feedback and direct contributions, but also simply in the form of usage and trial, e.g. when a library is included in many real-world projects.
- Quality grows out of failure. New things haven't been given the chance to fail, yet. Technology is no exception to this rule: you have to show me perfect code that was written on the first go. Software must have failed and been improved before it can be regarded to be mature.
While diving into new technology is important, we need to keep an eye on the criteria we use to evaluate it. Novelty isn't the exciting criteria - but usefulness is. New technology needs to provide actual value over existing solutions.
We don't have to throw older, proven technologies out of the window the same moment we discover something new. Make sure you understand both the old technology and the new one before buying into the next new thing.
"Software must have failed and been improved before it can reach high quality."
Thank God for StackOverflow.com. I seriously can't imagine my (programming) life without it. And with it being one of the most visited sites on the web, I guess I'm not alone.
There's a long friendship between me and Stack Overflow. It helped me countless times when I was stuck on a problem. It gave me an idea of how other people had approached the same problem. Sometimes these other people helped me solve my problem. Sometimes they gave me a hint for a possible solution. And sometimes reading their problems at least let me know that I wasn't alone with a miserable bug...
After hours or even days of searching and trying different things, your shields are down. You are crawling through your code on all fours like a man who's lost in the desert. But all of a sudden your search is successful! Eureka! Searching Stack Overflow has finally yielded a piece of code that works!
That's when you have to watch out and resist the temptation to take code that works for code that's good. What you've found on Stack Overflow is - in all but the rarest cases - not a solution but rather a clue. It can certainly make for a great pointer, but it was not written with your exact problem / requirements / constraints /code base / application in mind. And sometimes, it might simply be a dirty hack.
Embrace Stack Overflow as a good source of guidance for certain problems. But also take the time to thoroughly and honestly evaluate if you've found a real, solid solution.