Software Quality - Maintainability
Maintainability is a measure how easily a system can be modified. It plays a major role in defining software quality. When a program is considered to be complete, which rarely happens, it still has to be maintained in some degree as long as it's used.
There are 4 types of maintenance:
- Corrective maintenance: bugs are discovered and fixed.
- Adaptive maintenance: some part of the technology or runtime environment needs to be changed and codebase must be changed to reflect that.
- Perfective maintenance: stakeholders have changed requirements so codebase must be changed to reflect that.
- Preventive maintenance: prevent possible future bugs or increase internal quality.
Guidelines to build maintainable software: We'll cover these in more detail below.
- Write clean code
- Reduce line count in units
- Keep units simple
- Write code once
- Keep interfaces small
- Keep codebase small
- Write and automate tests
- Separate concerns with modules
- Reduce size of modules with an external interface
- Balance components
Unit-level guidelines are more important than component-level guidelines. If they are in clash, go with unit-level guidelines. The guidelines above are in priority order.
Guidelines are just that, guidelines. Guidelines are frequently broken as the context requires it; usually the simplest solution that requires least amount of effort is the best.
Quality profiles help to keep guidelines from becoming strict rules. Each maintainability guidelines has related metric; how to measure it. These metrics are divided into categories for a more realistic analysis. These categories (quality profiles) range from compliant to severe.
Benchmark and optimize only if required. Don't sacrifice maintainability to optimize for performance.
- Write clean code ===========
Clean code is easier to modify and understand. Leave each part of code cleaner than you found it.
How (these depend on the language used):
- Remove bad comments; they should answer "why", not "what".
- Remove commented out code; you can get it from version control.
- Remove unused code; you can get it from version control.
- Rename long identifiers.
- generateConsoleAnnotationScriptAndStylesheet -> split to multiple functions?
- GlobalProjectNamingStrategyConfiguration
- ugh
- Make identifiers more descriptive
- x => totalCount
- Turn magic values into static constants.
- Handle uncaught exceptions.
Metrics:
Almost impossible to get good metrics on this one.
- Reduce line count in units ===========
Small units are easy to understand, test and reuse. A lot of spread out units are easier to read than one big one. A statement is not a line of code, a line of code is anything that ends in a new line, excluding comments.
How:
- Extract related statements into a separate, well-named method.
- Replace functionality with a method object; a class of closely related methods.
- Change the approach e.g. transition from HTML generation in code to using templating.
Metrics:
lines per unit % of codebase
15 or less at least 56.3%
16 - 30 at most 43.7%
31 - 60 at most 22.3%
61 or more at most 6.9%
- Keep units simple ===========
Less branching points make units easier to modify and test. Just 2 same-level conditionals create 4 possible paths through it.
McCabe complexity tells the number of linearly independent paths the execution can take. 1 + the number of control statements like if
, switch
, ?
, &&
, ||
, while
, for
, catch
, return
.
Metrics:
1 + # of controls % of codebase
McCabe 1 - 5 at least 74.8%
McCabe 5 - 9 at most 25.2%
McCabe 10 - 24 at most 10.0%
McCabe 25+ at most 1.5%
- Write code once ===========
Avoid duplicate code like plague. Even unit tests are not an excuse to write duplicate code. A bug has to be fixed at multiple places, which is error-prone and stupid.
Duplicate types:
- Type 1: duplicate code is identical piece of code at least six lines long.
- Type 2: differ only in whitespace, comments, names and literals, hard to detect.
How:
- Rewrite units to be more reusable.
- Extract functionality to or create parent classes.
Metrics:
Less than 4.6% of codebase is type 1 or type 2 duplicate code.
- Keep interfaces small ===========
Makes units easier to reuse and understand. Large interfaces are not a problem themselves but strongly indicate unnecessary complexity of the unit.
How:
- Divide a method into multiple parts, if feasible and logical.
- Extract parameters into passable parameter objects.
private void render(int x, int y, int w, int h, Color c) {}
private void render(Rectangle r, Color c) {}
Metrics:
# of parameters % of codebase
0 - 2 parameters at least 86.2%
3 - 4 parameters at most 13.8%
5 - 6 parameters at most 2.7%
7+ parameters at most 0.7%
- Keep codebase small ===========
Makes codebase easier to test, modify and understand. Avoid codebase growth in the first place. Actively reduce codebase size if possible.
Split up codebase if necessary. Separate codebase to provide well defined set of functionality from the main codebase e.g. using Ruby gems or pip packages.
Codebase size is counted in man-years. Man-month is how many lines of code does an average developer writes in a month. 12 Man-months is one man-year.
Limit codebase size to 20 man-years. For example with Java, 20 man-years is 175,000 lies of code.
- Write and automate tests ===========
Makes development predictable and less risky. Tests should be ran at least once before deployment, on each push or pull request. You still need manual testing but automated tests help to grab the bug early so it's cheaper to fix.
If system is already built without any tests, make a team policy to write tests on each part of code they touch.
Types of tests:
- Unit Test: testing functionality of one unit of code, the output is only affected by the input, not a database or anything
- Integration Test: testing interaction of two modules
- Regression Test: previously erroneous unit, integration or end-to-end test
- Acceptance Test: system interaction, behaves as expected, end-user representative
- Process Test: testing a process e.g. test creating a new user against in a database
- Protocol Test: testing communication e.g. send requests through a headless browser
- System Test: testing the whole system e.g. start the server and check that a request goes through
- End-to-end Test: testing functionality of the whole system as a user would use it e.g. making user, do things on the service, etc.
Unit tests are generally the most important ones.
Both of these testing styles are required:
- Sunny-side testing: behaves right in normal cases.
- Rainy-side testing: behaves sensibly in abnormal cases.
Simulate other modules, mocking, to make test isolated.
Metrics
80% unit test coverage
Use a tool for this, all programming languages have one.
- Separate concerns with modules ===========
Makes modules easier to understand, test, modify and reuse. Hide implementation details behind interfaces so an engineer can lessen the details he needs to keep in his mind while developing.
Avoid large modules in order to achieve lose coupling between them. Coupling means that both parts of a system must be changed at the same time. Loosely coupled modules also allow developers to work on isolated parts of the system. Direct calls, configuration file, database structure, assumptions.
How:
- Avoid large modules.
- Assign responsibilities to separate modules
- Hide specialized implementations behind an interface.
- Replace custom code with third-party libraries or split the codebase.
public class UserService {}
// split to...
public class UserNotificationService {}
public class UserBlockService {}
public class UserService {}
public class DigitalCamera {}
public class SmartphoneApp {}
// hide behind...
public interface Camera {}
public class DigitalCamera implements Camera {}
public class SmartphoneApp {}
Metrics:
Fan-in: number of incoming calls from other modules.
Fan-in % of codebase
1: 1 - 10 no constraint
2: 11 - 20 at most 21.6%
3: 21 - 50 at most 13.8%
4: 51+ at most 6.6%
- Reduce size of modules with an external interface ===========
Makes isolated maintenance and understanding the code easier. For example, if you have one module that all call go through, reading the code top-down is a lot faster as you know all external calls go through that single interface.
Minimize the relative amount of code within modules that can receive calls from modules in other components. Multiple entry-points make code hard to follow and usually indicate poorly defined responsibilities.
Code can be categorized based on it's visibility:
- Internal code: component modules that are hidden and can't receive calls from other components, e.g. behind a namespace.
- Interface code: component modules that are visible and callable from other components.
How:
- Maintain a single entrypoint for external component calls.
- Receiving and processing a request are different responsibilities. This is the most common way to reduce interface code.
Interface code should be at most 14.2% of the codebase.
This means code of modules that can be called from other components.
- Balance components ===========
Makes codebase easier to test, modify and understand.
Balance number and size of top-level components. 9 is good number of top level components Gini coefficiency 0.71 GC at most 6 - 12 is a good number, about the equal size.
Sources
- Building Maintainable Sofware, O'Reilly