First Principles
Making a good design
What makes a design "good"? As the saying goes, "you can please some people all the time or all the people some of the time, but you can't please all the people all of the time", so it cannot be a matter of satisfying all the project constraints (because sometimes there are more constraints than can be satisfied in the time allowed with the money, resources, and people available). Nor can it be about mindlessly following a phrase or principle you heard online or from your teacher (or from this website, for that matter). Nor is it simply satisfying one's personal design aesthetic.
A good design identifies several solutions that satisfy the minimum viable requirements, clarifies how alternatives fail to meet those requirements, and selects a "best" or "preferred" solution that maximizes the optional requirements, taking into account their relative priorities. A good design not only addresses the requirements of the specific project, but it also takes into consideration general considerations that should apply to any engineering project.
Although each project is unique, some considerations remain fairly consistent across programming projects:
User Considerations
- Time -- make your software fast, precomputing slow operations where appropriate
- Money -- avoid activity that incurs charges on the user (such as bandwidth usage on metered networks)
- Attention -- don't distract users for things that are unimportant; allow users to opt-out of notifications
- Device Battery -- maximize device sleep times, schedule network events to minimize radio wake time
- Privacy -- clarify how data is collected, obtain consent for collection, use it to benefit the user
- Intuition -- don't surprise the user in a negative way; make it easy for users to guess how to use your app
- Patience -- simplify data input, avoid bugs that cause crashes or data loss, persist app state
- Choice -- provide reasonable user preferences / options, especially around privacy choices
Engineering Considerations
- Complexity -- add complexity only where necessary to satisfy an additional goal or constraint
- Understandability -- ensure that the code can be understood in a month, a year, two years, etc.
- Reusability -- reuse code and design to allow for future reuse (often a tradeoff with complexity)
- Testability -- structure code in a way that allows subcomponents to be tested in isolation
- Iteration Speed -- structure code in a way that allows small incremental changes to be made quickly and easily
- Release Speed -- structure code in a way that allows updates to be released quickly and regularly
- Engineering Costs -- use opensource to avoid tooling and library implementation costs
Production/Support Considerations
- Fault Tolerance -- ensure that code handles error cases in a sensible manner
- Fault Detection -- ensure that errors are actionable and notify relevant parties
- Maintainability -- ensure that code can be updated and is understandable
- Debuggability -- ensure that errors, logging, or other mechanisms facilitate debugging
- Production Costs -- ensure that code is implemented efficiently
There are several mantras or principles that are commonly repeated that address some of the consideraitons above.
Principle of Least Surprise
In short, avoid surprising the user or other developers by choosing the option that is most likely to be expected (or that, if unexpected, results in the least amount of harm or impact). This principle addresses several of the design goals above, including making the code understandable, avoiding annoying bugs for users (which can result from confusion or misunderstandings about the way that code behaves in corner cases), and respecting the user's intuition.
As an example, consider these alternatives:
// version 1 public List<String> computeSomeList() { // ... if (errorCase) { return null; } // ... } // version 2 public List<String> computeSomeList() { // ... if (errorCase) { return Collections.<String>emptyList(); } // ... } // version 3 public List<String> computeSomeList() throws SomeException { // ... if (errorCase) { throw new SomeException(); } // ... }The first version violates the principle of least surprise, because a caller necessarily must handle the empty case (and so returning empty is okay), but getting null rather than a real list can be surprising and is likely to trigger a
NullPointerException
in the caller. If
the caller does not need to differentiate between error cases and empty
cases (we expect that it is okay for handlers to treat them identically),
then option #2 is the best, since it requires the least amount of effort for
callers to handle properly. If we expect that callers need to differentiate
between the error case and the empty case, then version #3 is the best in
that it makes the error far more explicit, reducing the chance of surprise.
Separation of Concerns
This is also sometimes phrased as "do one thing and one thing well". This principle serves the goal of designing for reuse, reducing complexity, improving understandability, increasing choice, and making code testable. The basic intuition behind this principle is that a component that does many logically distinct things is more complicated than several smaller components that each do one thing (or, at least, it is less complexity that must be considered at a time in order to make changes to a part of the system), and that entangling multiple logically distinct operations into a single component makes it difficult to isolate just a part of that logic for testing or for reusing in isolation without other pieces.
An excellent example of this principle may be found in Java's I/O library. In Java, reading/writing raw bytes (done by InputStream and OutputStream) is kept separate from decoding/encoding those bytes in a specific character set (done by InputStreamReader and OutputStreamWriter or PrintWriter) and is likewise kept separate from buffering (done with BufferedReader, BufferedInputStream, BufferedOutputStream, BufferedWriter) and from other considerations such as object serialization, counting line numbers, etc. This structure makes it possible to reuse one piece of functionality (such as counting line numbers) with multiple different data sources (the filesystem, the network, etc.) without duplication.
While the separation of concerns can sometimes make common use cases more complex (e.g. in Java, reading some text from the terminal is far more complicated than the C++ analog which conflates encoding/decoding, buffering, and reading raw bytes), simplicity and separation of concerns are not mutually exclusive; when using separate components, it is still possible to provide a utility component that combines the pieces together to simplify common uses (even though this utility indirectly does many different things, it is still a valid application of "separation of concerns" in that this utility is focused on just one task, integrating separate components; the actual implementation has been separated).
Keep It Simple Stupid (KISS)
This principle addresses a number of fundemental goals. Following the KISS principle helps to avoid wasting engineering time and costs associated with use cases that aren't necessary to handle or that are overly speculative. It also helps to avoid errors that such additional complexity can bring with it. For example, if you are tasked with retrieving the user's email address, don't also write code to retrieve their social security number just because you believe that it might be useful at some unspecified future point in time. (Though if there are plans to do that soon and the two operations are similar/interconnected and it is not too difficult to do both, then maybe it is okay to do that).
One way that this principle is frequently misused/abused is in discouraging reasonable optimizations. There is a fine line between reasonable optimizations and "premature" optimizations. In my view, a premature optimization is one that significantly impairs code understandability, operates at the micro-optimization level (reducing first order constants rather than asymptotic algorithmic runtime complexity), and is not well understood, tested, or justified. For example, using assembly langugage instructions in a C program to obatin a speedup without proving that the section of code being optimized is a critical section and measuring that the change causes a significant speedup would be a premature optimization; however, putting the contents of a list into a map before performing repeated searches instead of repeatedly searching within a list is not a premature optimization and not a violation of the KISS principle.
Fail Fast
For the sake of debuggability, it is useful for errors to manifest themselves close in time and location to the true source of the error (as opposed to propagating an error state that generates a failure at a much later point in time or in a different code location). When fail fast is applied to compile-time vs runtime errors, this principle also lends itself to increasing iteration speed, and reducing errors experienced by users.
As an example, consider these two alternative implementations:
// version 1 class Person { private final String name; public Person(String name) { this.name = name; } public String getName() { return name; } public boolean hasSameName(Person o) { return name.equals(o.getName()); } } // version 2 class Person { private final String name; public Person(String name) { this.name = Preconditions.checkNotNull(name); } public String getName() { return name; } public boolean hasSameName(Person o) { return name.equals(o.getName()); } }With the two versions above, consider how this error manifests itself:
Person p1 = new Person(null); Person p2 = new Person("John Doe"); // ... many, many lines ... System.out.println("p1.hasSameName(p2) == " + p1.hasSameName(p2));In version 1 (the version without checking), the eror manifests itself on the line that calls "hasSameName()" even though the error was actually in the construction of
Person
with a null name; in version 2 (the one with checking),
the code "fails fast", generating the error on the line that was actually responsible for causing this error.