For the past few months, I have been reading the book Code That Fits in Your Head: Heuristics for Software Engineering by Mark Seeman. It covers a variety of topics related to software development, including software complexity, working in a team, and how to solve problems of varying character. These topics feel very relevant for a new computer science graduate such as myself, so in this short series of articles, I will introduce some of the points I have found especially useful. Where it is relevant, I will use some simple code examples and some of my personal experiences to illustrate.
Software engineering is all about keeping complexity down to keep the code base comprehensible and focused. This often requires one to take a step back and think about how to solve a problem instead of jumping head-first into the code base and writing mindlessly. It is about finding the most sustainable solution that will keep the system manageable over time.
This will most likely not provide immediate value, but it will shine at a later time, when a stakeholder requirement changes, or a new feature is wished for. Using sustainable solutions should make it easy to modify the existing code base. If not, it could take hours to even figure out how a system works and where something should be modified. And there might even be several places where code must be modified.
Low complexity is achieved by placing tings that relate to each other close together. For example, an interface should not have knowledge of business logic. Likewise, the business logic should not care about how it is presented nor how it is persisted.
The human brain is not like a modern-day computer which can keep track of millions of things at any given time. The human brain can only keep track of 4 to 7 at a time. Trying to make sense of what happens in a method with 100s of lines can be nearly impossible. Low complexity is therefore about making code readable and easily understandable. Let’s look at an example:
public float GetPrice();
Looking at this method, you would expect to get a numeric price value in return. But would you also expect it to save an entry in a database? Uncertainties like that can be minimized by using Command Query Separation, which, as the name helpfully says, is all about keeping Commands and Queries separated. A method that returns a value is classified as a Query and should only return values and not alter states. Methods that alter states, in terms of saving an entry to a database or updating a variable outside its scope, are classified as Commands. If a method alters the state it is said to have side effects. Hence Queries should have no side effects.
Thinking about coding this way will help you create code in chunks that you can fit in your head.
You can easily spend days upon days working away on a central part of some application, adding features that seem like a good idea and might come in handy later on. Doing this can easily result in overengineering and in the software not matching the stakeholder’s wishes. Instead, you should work in vertical slices; a slice that covers the entire application from end to end. This makes it possible for the stakeholder to get their hands on the application early on and you are able to test the entire application including external connections.
One might ask what value this adds, and the answer is hardly any at first glance. However, it does establish a running system and most likely a pipeline for deploying the application. As the entire application is running, it becomes easier to add new features and have them immediately available (Continuous Delivery). Furthermore, it can be placed in the hands of the stakeholders for evaluation from the beginning.
Implementing features should be done using the simplest means so as to avoid creating unneeded functionality. If it can be done using a primitive type, then do that instead of creating an entire class with a single property. When adding features down the line, a primitive type may no longer be enough, and you can then replace it with a class or other solution that, again, is the simplest solution to the problem at that time.
If the current feature is too big to tackle at once, break it down into smaller, more manageable features. This will also reduce the time it takes for the feature at hand to be implemented.
Over-generalizing software might seem smart, but it will most likely come back to bite you. It will cause problems at run-time instead of at compile-time, as the compiler does not understand that something does not make sense. Instead, you should consider preventing problems by mistake-proofing your software. This is known as the Poka-yoke technique (from the Japanese word for “mistake-proofing”). It is best described using physical analogies, such as the different shapes of computer ports that prevent you from connecting a cable to the wrong port, or low-hanging bars before low bridges. These create physical restrictions making it impossible to perform the intended action, thus preventing a problem from occurring in the first place.
Let’s look at a software example of the technique:
public int GetInsurance(string licensePlate);
public int GetInsurance(LicensePlate licensePlate); ... public record LicensePlate(string Value);
In the above example a primitive type is replaced with a record that wraps the same primitive type. This ensures that only the type
LicensePlate can be given as argument to the method, preventing a simple string from being given.
When something is running in production, you may sometimes identify unexpected behavior. One way of tackling this is to simply throw code at it in the hope that some of it will solve the problem. But even if this solves the current problem, what happens next time you encounter a similar problem? Instead, try to understand why the problem is happening in the first place. If you are in deep water, try asking someone else for help.
To identify the problem, the scientific method can be applied. First, make a prediction – a hypothesis – of what the problem might be. Second, make an alteration to the code. Third, compare the output to the hypothesis. Continue doing this until you have identified the problem.
A problem is not always solved by adding more code. Sometimes problems can be solved by removing code, thus also reducing complexity. Sometimes simply explaining it to a co-worker can help you understand and possibly fix a problem. If you don’t want to bother a co-worker, or you don’t have one handy, use a yellow rubber duck instead. The duck will always listen to your rambles. This is, not surprisingly, known as rubber-ducking.
Sometimes all it takes is a change of scenery. Go for a walk, go get a cup of coffee. If you are sitting down, stand up. If you are standing up, sit down. Do not underestimate the value of a break from the current task. Knowing when to take a break is a valuable skill that can give you a new perspective.
Having identified the cause of a problem and a possible fix, write a test case for the problem. This will serve both as a tool for fixing the problem now, and also as a guard so the problem does not go by unnoticed in the future. The test will help you validate your hypothesis. If you have a correct hypothesis, then the test will fail. If it does not, then you have yet to identify the problem at hand.
That concludes the first part of this little series of tips from Code that fits in your head. In the second part, I will look at some helpful hints for testing and lowering the complexity of your code.
(Background header image courtesy of Ross Sneddon)