Complexity: Readability and discoverability
Poor readability and discoverability of what the code does, increases the cost of understanding.
Readability is a commonly used term in the software industry to mean how well you can read the code, but I am including discoverability to add the importance of being able to find the code easily. If your code is perfectly readable but is hidden away in a fashion that devs never actually read it, then it still adds to complexity.
- before you can understand something, you must be able to read it
- before you can read something, you must be able to find it
Is documentation a solution?
When readability and discoverability is so poor that devs struggle to understand the code they have to spend long hours investigating. This often leads to the solution of asking devs to write documentation and code comments. For most cases, I don’t like this as a solution for a few reasons:
- Documentation takes time - It requires more time on an already time-hungry problem.
- Documentation can't be trusted - Since it has to be maintained separately from the code it can quickly drift on active projects.
- Documentation can also be unreadable - The dev that wrote the unreadable code, is more than likely going to write unreadable documentation.
- Documentation does not improve the code - You may read the documentation and understand it but when you look at the code it is still difficult to work with.
While documentation is not a silver bullet to all readability and discoverability issues, and should not be used to cover up bad code, it is useful as an addition to inherently complex problems.
How to reduce complexity
Here are some simple ideas to help you keep readability and discoverability in your code:
1. Meaningful Names which convey functionality
Complexity Smells when naming is poor:
- “What does this class/function do?”
- “What does that acronym stand for?”
- “How do you spell that?”
- When the spelling of a name is different in many places
- Some one has have to explain the joke/history of a name
- “I did not know that functionality was there.”
- “What does this app do?”
If a certain part of the system is named poorly it will increase the complexity, since it will make it difficult to understand the system. For example, naming a service “YoDwag” makes it difficult for everyone to understand what the service does, and can also add extra confusion around spelling. Instead, a clear name, such as “AuthorisationService”, can be used because it clearly describes what the service does.
If a service contains functionality, which does not fit within the service name’s expected bounds, it will increase the complexity around understanding the system. For example, if our “AuthorisationService” authorises users, but also has the extra functionality of emailing invoices to users, this makes it difficult for everyone to keep track of what the service does and the extra functionality will often be forgotten about. To fix this, you can either change the name of the service to be inclusive of all its functionality, or you can pull some of the functionality out into a new service. In this example I would prefer to move the invoicing functionality into a separate service named “InvoiceService”, so that “AuthorisationService” does authorisation and the “InvoiceService” does invoices. This creates clear visibility and bounds on the functionality of the service. When doing a code review, first read the name and ask yourself, “What does this do?”. If it does what you expect, then it is named well.
"Type names, method names, and argument names all combine to form an INTENTION REVEALING INTERFACE."
-Domain Driven Design By Eric Evans
An intention revealing interface is one which tells what it will do, but not how it wil do it. A developer should not have to be bog down by understanding all the details of every implementation in order to work on the project.
2. Verbose code
Many languages support short cuts for ease of writting, and to speed up the development time.
Unfortunatly many of these features are also the cause of complexity and slowing down future development time. A basic example of this is linq in C#, where developers create one line monstrosities like this:
var result = _repo.Where(x => x.IsEnabled && x.Locations.Any(y => y.Address != null && y.Address.Country == criteria.Country) &&((x.StartDate >= dateFrom && x.StartDate >= dateTo) || (x.EndDate >= dateFrom && x.EndDate >= dateTo)) && x.TimeSlots.Any(y => (y.StartTime >= timeFrom && y.StartTime <= timeTo) || (y.EndTime >= timeFrom && y.EndTime <= timeTo) && weekDays.Any(z => z == y.WeekDay))).ToList().Select(x => x.Locations).Where(x => x.Any(y => y.Address != null && (y.Address.State == criteria.Location || y.Address.City == criteria.Location || y.Address.Suburb == criteria.Location)).ToList().ForEach(x => Print(x));
Shortcuts may be easier to write but sacrifice clarity, making it more difficult to read, understand and debug.
3. Trustworthy sources of truth
Complexity Smells when the application may lack readability and discoverability around it’s configuration or setup:
- “What are the dependencies of this app?”
- “What is the public contract of this app?”
- “What does this app do on startup?”
Sources of truth must either automatically change as the code changes, or be forced to change as the code changes. I found that changing my mindset around sources of truth to be my documentation led to improved discoverability. I use these as sources of truth:
- Tests - unit tests, integration tests, ui test, contract tests, etc. can all provide clarity into what your system does. When unit tests are written in a way that shows how you should use the classes, it makes the application more clear overall.
- Swagger provides a clear public interface to the system’s end points.
- Config files / CI/CD files or Docker files - can all provide more clarity into the system’s dependencies and public interfaces / boundaries. Eg if there is an ENV var in the docker file for a connection string, then it’s clear that the application has a dependency on a database.