The 7 Virtues of Good Software Design
In this article, I'll explain what each one of these virtues is about and cite some of the well-known good practices involved.
Join the DZone community and get the full member experience.Join For Free
There are plenty of good practices, design patterns, code checks, and more, that you must learn and follow to produce high-quality, reliable, and extensible software.
But each and every one of them serves to one or several of these seven golden virtues:
In this article, I'll explain what each one of these virtues is about and cite some of the well-known good practices involved.
Why Is This Order Important?
I have written these seven virtues ordering them by necessity, the more imperative first. But I'll explain it in backwards order.
Let's suppose I (a costumer) request a program and I'm given a piece of software which is granted to be technically perfect.
Oh, OK: It is said to be perfect, but I know that in the real world nothing is. So, if it has to fail, I'd rather that it fails at efficiency, for two reasons:
- If the program owns the other six virtues, it would be a lot easier to develop eventual optimizations.
- Although efficency fails, if the program is still scalable, I'd have the chance to increase the power of the underlying platform to get the desired throughput.
If the scalability fails but the program has reusable parts, I could identify the bottlenecks in the code and replace them with better components while still reusing the rest of them.
If the program is not reusable but it is flexible, I could adapt my data so it can still be processed by the existing routines, as long as they are flexible enough.
If the program is neither flexible, I'd wish it is at least maintainable, because then I could explore it, identify the problems, and do the necessary changes and build another fixed version.
If the program is a whole mess of code lines with no readability, organization, nor documentation, I'd put all my hopes that it at least is robust, so there would be few probabilities that it would arise the need to be fixed.
If it is not neither robust, I'd hope it at least is effective, because an effective program would run for some time before some weakness is revealed. And, even in this case, there might be other parts of the program which may still work.
Finally, if the program is not even effective, then there is nothing to do but coding another new one, probably from the scratch. And this time, making sure it accomplishes the seven virtues of design.
From the point of view of planification, there are some slightly different approaches:
- If you are designing a mere prototype with no certainity about its future, then focus on effectiveness, robustness, maintainability, flexibility, and reusability. This should be enough to produce a reliable, deployable piece of software with good prospects of success. And if the demo is successful and thus your software shall evolve, you should be able to make it scalable and efficient with minimum effort.
- If you plan to design a program to stay stable just in the short term (with a known, near end-of-life), then focus on effectiveness, robustness, maintainability, scalability and, if required, efficiency (yes, mantainability too, because corrective fixes can always be required).
- And if you plan to develop further evolutions in the long term, then put your attention in all the seven virtues together.
It might seem obvious, but the first (and main) virtue of code is that it is functionally apt, i.e. it behaves as it is expected to. You cannot expect that your program shall be appreciated for its rapidness or reusability when it isn't even useful to the user.
However, being this a matter of functionality, it can't be specified through good technical practices. This is more a testing issue.
Robustness is the safety that a program is auto-protected against errors, or any condition that will make it run outwards of the intended flow. Missing data, arising nulls, format errors, or data inconsistence are some usual causes. Consequences might turn out to abruptly end the running thread, run out of memory, enter into an infinite loop, or even jump to a valid routine in an undesired moment.
Some rules to get a robust program:
- Minimize visibility of members within a class: Private by default, unless access by subclasses is needed (then it should be protected), unless access to every package is needed (then it should be public).
- When sharing data between threads, protect it properly to avoid data collision.
- Always keep variables consistency: Each variable must contain at every moment a valid value. This means that you should not load any value into a variable until that value is valid. This is especially meant not to store default values (like null) at initialization.
- Class member variables: Should be initialized most preferrably at constructor, providing all required values.
- Local variables: Within a method, better delay variable declaration until there is a valid initial value.
(I admit there are some situations, like coding a state machine, in which storing null values is worth it to avoid excessive program complexity).
- Be careful with big object creation not to exhaust memory, especially those which imply contiguous memory allocation (arrays and array-based structures:
ArrayDeque, etc.). Instead, consider using a linked-based alternative implementation. And when processing big files, always choose a streaming-based algorithm.
- When coding loops, try to establish in the declaration a clear and reliable exit condition. Avoid initially infinite loops with an exceptional exiting condition inside.
- Always choose strong typing: Do not use
Stringtype but for storing transparent user data (and for internal IDs). For every other data, chose the most specific type available:
- Follow Postel's rule: Choose the most generic type for input parameters and the most specific one for output parameters and return values.
- Try avoiding recursion if not necessary: There is a risk that a high number of iterations turns out in stack exhaustion.
- When using recursive structures, avoid the possibility of pointer cycles (for example, providing an input parameter feeded in each iteration to track the traversing state).
- Provide a complete set of unit tests with a maximum of code coverage (read my forthcoming articles about unit testing).
Recommended patterns: Stateless, enumeration.
Almost self-explained. Mantainability is the program's easiness to be read, edited, and modified by a developer. Since (almost) no program is 100% perfect from the first version, sooner or later it will need manual editing to correct bugs or make improvements.
Some rules to make your code mantainable:
- Minimize the number of declarations within each scope:
- Minimize lines in a method (distribute them among other methods if necessary).
- Minimize members in a class/interface (distribute them among other classes if necessary).
- Minimize classes/interfaces in a package (distribute them among other packages if necessary).
- Minimize packages in a library (distribute them among other libraries if necessary).
- Minimize libraries in a deployment (consider a multi-deployment distributed application).
- Stick to standard naming style.
- Avoid repeating code. Instead, give each designed abstraction a concrete responsibility, and a public/protected API which shall be reused at any place on demand.
- Auto-documentation is the best documentation, but inline comments are always welcome (as long as they are well-written and helpful).
Recommended patterns: Abstraction, inheritance, composition.
A program is flexible when it does not require rigid parameters or preconditions. Among other things, that allows the same routine to be used in different ways, or to process different kind of data.
Some practices that help gaining Flexibility are:
- Follow Postel's rule: Chose the most generic type for public declarations (input parameters, return values).
- Always make a design based upon interfaces: Model the main functionalities of a module through one or several public interfaces, and establish dependencies between modules through these interfaces.
- When combining classes, favor composition over inheritance.
- Every module should contain, at least:
- For each functionality, one public interface, with as many public methods as services are required.
- One abstract implementation of the interface to help developers subclass it.
- One public exception with as many subclasses as exceptional situations might occur.
- One public factory to produce instances of the interface.
Flexibility must not be pushed too far. Its limitations are:
- To mantain always a high cohesion between methods/classes/modules.
- To mantain always a low coupling between methods/classes/modules.
Recommended patterns: Abstraction, factory, abstract factory, composition, inheritance, listener, mapped processes.
An API is reusable when it can be executed from a variety of programs or environments. Most important rules when designing reusable code is to ensure it is interface-based and it has low coupling with other programs/modules:
- Most preferrably, core interfaces should have no upper hierarchy.
- Its methods should have low coupling: Their input/output parameter types should belong preferrably to standard runtime libraries.
- Their use should be as simple as possible.
If the core functionality is designed upon these terms, then we could build as many façades as required by simply wrapping the core interfaces. For example: If you plan to code a command line utility, do not put the core functionality in the
main class. Instead, code your own interfaces, classes, and factories as explained, so you'll get a reusable core module. Then, add a CLI module with a
main class façade from where to call the core. In this way, you could add another façades in future.
Note: Façades, as set here, must be used only for polymorphic assignment, and its responsibility is usually no more than converting and passing parameters and errors.
Recommended patterns: Abstraction, façade, adaptor, composition.
Scalability is defined as a program's capacity to increase its throughput in the same (or acceptably similar) ratio as its underlying plattform power gets increased.
The underlying platform is usually measured in terms of hardware power (CPU speed, memory speed and size, network bandwidth), but as you are developing higher-level applications, you might take in account also the power of lower level parts: cache sizes, thread pools, disk quotas, software containers, indexing capabilities of your database, etc.
Usual obstacles to scalability:
- Bottlenecks in your program: When dealing with multi-threadring design, try to reduce at maximum the number and length of critical regions. A typical technique to avoid entering in a critical region is to reuse objects from a cache.
- Memory abuse: When processing large amounts of data, avoid the need for contiguous memory allocation. Always prefer streaming+buffering, or at least, segmentation.
- Heavy resources dependency: Reduce at maximum the number of times and duration of heavy resources utilization.
If scalability has to be measured in relation to hardware, your program and the underlying platform (operating system, virtual machine, and so on) have to be evaluated together (you cannot build a scalable program over a non-scalable platform!).
Recommended patterns: Pool, cache, singleton, streaming.
Efficiency (or performance) of a program is its ability to produce the most amount of results and still consuming the least amount of resources (typically CPU time and memory). Stepping from effectiveness to efficiency is called optimization.
These are the two most important advices about optimization (read on a book):
- First: Do not do it.
- Second: OK, if you have to do it, leave it until the end.
As you can see, I am following these wise advices when I decided to put efficiency as the last virtue to be achieved in software design/development. The reason is simple: Optimization can be the most hard and complex task in software design, and so it can be very error prone. The "do not do it" wise advice is kind of a try to decourage you from your first intention of introducing Optimization as a main requirement from the very beginning.
Instead, the second wise advice instructs you to build first a program that works fine, and could be eventually optimized. How? My answer: Ensure first that it is effective, robust, maintainable, flexible, reusable, and scalable. In this way, introducing optimizations might become very simple (for example, by replacing an unefficient implementation by a better one, which should be transparent to existing code thanks to the use of interfaces). See how the seven virtues are related?
(Read my forthcoming article: "The seven golden rules of software optimization".)
Recommended patterns: Singleton, Pool, Cache.
Recommended anti-patterns (handle with care!): Intrusive coding.
Thank you for reading.
Opinions expressed by DZone contributors are their own.