What makes a good software?
Writing a piece of software is a damn hard task. Writing a piece of good software is Sisyphus work - once you think you've nailed it, everything falls down into pieces. So is there a way to actually create something that could be awarded a "good software badge"? I don't know if you can ever guarantee it, but I think there are some ways to make it a little bit more likely. But before we will get to those, let's see what this badge actually means.
Seven qualities of the good software
Just like a teacher can assess whether a child is behaving well at school, we can assess whether we are dealing with a good software or not. Unlike the child though, misbehaving software isn't really a sign that we may be dealing with a genius or future revolutionist - we want the software to work exactly as we think it should, side effects excluded. So how it should look and behave like? Here are (some of) the points that I consider characteristics of a good software:
- It does what it's supposed to do and nothing else. The purpose of the code should be clear enough to grasp within minutes or even seconds.
- Navigating through the code is easy, even without sophisticated tools. That includes project, folder and file level organization. Any domain-related aspects should precede any technical details.
- Reasoning about its behaviour is easy enough to not require a debugger to understand what should happen in given case. All the inputs and the output of given part of code should be easy to figure out without deep system knowledge.
- Naming across the different parts of the same domain is consistent. Decide how given thing should be called and stick to it. Prefixes/suffices can be used to differentiate multiple values of the same property in one scope.
- All the cases that can generate an error (side effects) are properly managed. There should be no way for an error to leak outside of the designated path.
- When something goes wrong it does all it can to provide the most meaningful information to figure out what actually happened, and possibly why. That includes well-designed logging and/or auditing of everything that happens within the application.
- When something really bad happens and it cannot operate any longer, it gracefully shuts down and notifies all the interested parties. Supervisors of the running application should be able to easily figure out that it is no longer operating.
There are also several other aspects, not directly related to code but to the testing, deployment and runtime, that are not mentioned here. They are already covered in some other checklists, so I decided to skip them here. Usually, they are less generic and depend more on the kind of the application, environment and so on.
Ways to improve the quality
Knowing what makes a good software should help us to come up with ways to achieve it. Depending on the platform, language or frameworks we decide to use it may require different approach or tools, but some of the things should be common regardless of those.
1. Don't lie
If you name your function or endpoint updateName
, make sure it does exactly that, and nothing else. If you're using statically typed language (which you should do!), make sure that the function/method signature tells the truth. Take this method as an example:
public Person UpdateName(Person person, string newName) {
person.Name = newName;
return person;
}
If you look at the signature, you could expect that it gets a Person
and a Name
and returns a new version of provided Person
with updated Name
. As you can clearly see it doesn't fulfil the promise. It actually mutates the input value, so it clearly has an unexpected side effect. Evil.
Let's look at an improved version:
public void UpdateName(Person person, string newName) {
person.Name = newName;
}
Things are a bit better now. It still has a side effect, but at least that's what you can expect from a signature. It's good to know the important things without a need to dig into the details.
So what would be the desired approach? Using immutable data should be the preferred approach:
let updateName (person: Person) (newName: string) : Person =
{ person with Name = newName }
2. Solve the domain problem in a first place
When I first open some project the most important thing I want to know is what it's supposed to do. How things are solved is much less important, at least on the technical level. I want to know what are the key domain objects the software is dealing with. On the level of APIs (e.g. in web API or shared library) I want to know what are the key operations it is exposing. On the level of data structures, I want to know what is the root object and what it consists of.
I don't care where are the services, proxies or interfaces. Group things by domains, not by technical details. Use pattern names with consideration, and if possible avoid them. I want to know the entry point and steps that are required to solve given problem, not which pattern did you learn about yesterday. When it comes to dependencies, keep the structure flat. I rather read longer method than dig through multiple classes to figure out what is going on. It's OK to hide (some) less relevant details, but be explicit with the important actions. You and your team will be thankful for that, especially when coming back to the code after some time.
3. Be consistently explicit
Probably most of the functions require some dependencies to do the work they're supposed to do. Whenever it's possible, prefer explicit parameters over implicit ones (like dependencies injected by the container). It makes signatures more elaborate, and while it may seem to be extra overhead at the beginning, it isn't actually that hard. If your language supports partial application then it's probably not a deal at all. By having the explicit dependency on a function instead of class/interface, you can quickly figure out what it actually does, and unit testing will be way easier (if it needs to be unit tested).
Making function dependencies explicit enables composability on the higher level than it is possible with implicit ones. You're no longer tied to components that need to be designed to work together (usually via interfaces) - you can mix and match functions as it makes sense for you. Any function signature becomes its interface, and lamdas become adapters whenever required. Welcome to the world of programming LEGO. And the outcome? You can say more about your code at the first glance, without a need to jump between places. Reasoning about the code will be much easier.
4. Write code, not prose
This is a tricky one. You should write code for people to understand it, and it sometimes feels like you should start using what you've learned in the literature classes. And while some of the hints from the literature may be helpful, others may be really bad ideas. Take the synonyms as an example. When writing prose you are supposed to use them to not repeat yourself, so that the outcome will be more artistic and less monotone. But when writing code, being boring and repetitive is actually a very good idea.
When you'll decide to call the customer number, well, CustomerNumber
, don't call it ClientNumber
or CustomerId
in other places. Be consistently boring with the naming. That's especially important when you're within one domain, but even across domains, it's good to keep the names consistent - unless they are actually different values. That will save you a lot of memory resources when reasoning about the code and will help to reduce the number of bugs caused by confusion. Even when you're dealing with many values of the same property within one scope, make sure to be consistent, e.g. you can use currentEmailAddress
and newEmailAddress
as a parameter names of the function updating an email address.
5. Treat the symptoms in the early stage
It's usually most effective approach to fix an issue before it develops into some serious problem. You fix the leaking tap before it floods your house, right? At the same time, most programming languages by design let the small problems kill your application - unless you deal with the problems at some stage. If you try to connect to the database and there is some network glitch, you usually don't want to serve the user some 500 error page, right? That's why exceptions come with a set of tools to handle them. And while very often you can handle them in some general way (e.g. by a global exception handler), this should be your last resort.
I would highly recommend handling any possible exceptions at the earliest possible stage. That will force you to think about the value you return from a function with side effects. In some cases, you may just want to use some default value, in other you want to have some other value (maybe of a different type) or meaningful error description. Many functional languages have built-in solutions for this. Haskell forces you to use IO monad whenever you deal with side effects. Others, like F#, may be more tolerant and let you ignore the error if you want to, but also provide helpful tools, like Result or Choice/Either types. You may also create such types by yourself, or use some external library (like my Monacs library for C#). The point here is to have explicit notice that something may go wrong so that the consumer is (more or less) forced to deal with the possible problem. Of course, it requires discipline, and won't prevent all of the possible exceptions, but the overall quality should go straight up.
6. Don't be ashamed of the problems
Sometimes things go wrong. Even if you will handle all the possible problems inside your code, there may be some external dependency that will fail. When such things happen, it should be very easy to find it out. The most obvious solution for this is logging, and your application should log extensively. You should also make sure that the information that makes it way into the logs is as helpful as it can be (maybe just don't expose sensitive data, please). Good description of why you decided to put something into the logs, what were the conditions that caused it and where it actually happened (e.g. stack trace) are some of the key elements.
Another one is the ability to track the whole process. For example, in a web app you may want to use Request-id
header across the calls and in the logs, so you can see what was the sequence of events that caused the particular problem.
One thing which may be hard to assess is the log level to use. I have two hints here. First, keep it low. There's no reason to raise an alarm when some insignificant problem occurred (e.g. app wasn't able to send the newsletter to one of the customers). Second, judge the impact. If something blocks the user from performing a critical operation, it needs to go loud.
The last tip in this section is the approach to application design. Most applications use logging as a useful way to find out what's happening inside, but the problem with logs is that they are temporary and very often too laconic. This is where event-based storage really gives you an advantage. Having all the operations stored in your database you can track very precisely when some unexpected behaviour occurs. Of course, it comes at a cost, but when done properly (e.g. with help of tools designed for the job) it can save you a lot of time (and money) in the future. And it will reduce the need for logging and tracking (e.g. user action tracking), which can also reduce the complexity and cost of the system.
7. Complain
When you break your leg, how long do you wait until you go to the hospital? Probably not so long. Whenever something really wrong happens to your application you should also let "the doctor" know about it, sooner than later. That's why whenever some critical exception occurs the application should log it in the most detailed manner, as explained above. I would recommend leaving dedicated log level for such critical situations - the ones that may need to wake you up in the middle of the night.
As your application may get into the state when it cannot inform anyone that it's gone, you should have some monitoring as well. There are multiple ways to do it, depending on application type. The most common may include health check endpoints for web apps or error reports delivered by e-mail or IM. The more critical the app is, the more such tools you may need to incorporate. You may want to use the actor frameworks with supervisors to keep your app alive, or even choose the platform that supports it natively, like Erlang's OTP. Those are aspects that are not directly solved in the code, but they often require some modifications to the application code as well. Be sure to prepare for those.
Final words
Think. Let it sink in. Don't hurry. Rush may get you somewhere faster, but it may be the wrong path. Instead of spending time on overengineering your code, spend it on bulletproofing it. Focus on making the software that not only your users but also your admins and fellow developers will love. And that's the software that does it job right. That's the software that doesn't get in their way. That's the software that helps everyone get their job done.
The code you write is not only for you. Even if you're a lone developer, in weeks or months there will be different you, richer in new experiences. And while every piece of software becomes legacy code sooner or later, don't let it be legacy which no one wants to deal with, yourself included. Deliver less but better quality software and everyone will be grateful*. Less is more, more is less. Less rush is more quality. More quality is less hassle. It's a win-win situation if you're brave enough to look under the surface of time. Stay on the quality side, and the good software will become a reality.
- If you work for organization that thinks otherwise, quit today!