While high level design principles (like an emphasis on low coupling and high cohesion) provide broad guidance while considering design decisions, lower level design is about deriving concrete designs that can be directly implemented in programming languages. To accomplish this, low level design relies on more specific design guidance, although this guidance can be easily associated with the high level design principles that they codify. Three commonly used low level design principles are:
- Encapsulate what varies: A central low level design goal is to identify parts of the system prone to future modification, likely to need future extension, or could be reused in other contexts and encapsulate them to ease these future tasks.
- Design to interfaces: Coupling to concrete elements is the primary inhibitor to the above encapsulation; by designing our systems around interfaces, evolving or replacing concrete implementations becomes much more tractable.
- Favour composition over inheritance: While it is tempting in object-oriented languages to reuse functionality through inheritance, this means types must extend other concrete classes. While inheritance is a powerful tool and is often the best mechanism for a problem, composition and delegation is frequently a better choice for supporting long-term evolvability.
One of the most commonly used mechanism for creating low level designs are design patterns. While catalogs of software design patterns are relatively young (e.g., the Gang of Four published their seminal work on this subject in 1999), the power of using patterns for understanding diverse sets of data have been widely understood. For example, in Christopher Alexander's et. al.'s seminal book A pattern language on architectural patterns, the authors note that:
“At this final stage, the patterns are no longer important: the patterns have taught you to be receptive to what is real.”
This is notable because it emphasizes that the important part of the pattern is not the pattern itself, but the commonly occurring problem the pattern solves or the commonly desired benefit the pattern imparts through its use. By focusing on these problems/benefits we are able to understand the key design problem we are trying to solve, rather than being distracted by the mechanism used to solve them.
Blindly forcing design patterns into inappropriate contexts is often worse than the code without the pattern applied. This is because most patterns work through additional abstraction layers which can make the system harder to understand and maintain.
Below we will describe a number of design patterns from a high level. For more details, a vast collection of resources can be found online.
When a user makes a change to a program, they expect the change to be consistently reflected across the entire system. For example, if you delete an image file from a photo viewer, one would expect the thumbnail to disappear, the photo to be removed from the gallery view, and the total number of files in the status bar to be updated to reflect the deletion. A poor initial design for this problem can be seen in the figure below. While for this one action this design might not seem like a big deal, for a real application with hundreds of actions and dozens of views, the design could require dozens of classes to be modified each time a new view was added.
Ultimately a designer would like to decouple the state changing actions in the system from how they are reflected by the other components. The observer pattern enables this decoupling by leverages dependency inversion to implement a mechanism commonly known as inversion of control. While a traditional program calls the components it uses (e.g., your program calls methods in a library), inversion of control provides a means for a library to call into your code.
For our image deletion example, Image::delete()
calls Observable::update()
which iterates through its observers
list and calls notify(this)
on all of them so they know that an object they are interested in has changed state. Each Observer
can then react accordingly. This design means that the Observable
classes are not coupled to any concrete Observer
objects, making it so new Observer
classes can be added to the system at any time. The system is also dynamically efficient because Observer
objects can be dynamically added (and removed) from observable objects as needed by the system.
In this system, many different model elements would likely be Observable
: PDF files might render differently in the gallery view when they are selected (to allow access to different pages of the document) or folders might give some hint about their contents in their thumbnail representation. The value of the observer pattern becomes clearer as the number of observable and observing classes grows.
It is important to note that there is still some coupling in this design: observable objects must know about the Observer
interface so they knows how to notify their observers, and all observers must know about the objects they are observing. But one crucial aspect of the coupling is removed: the Observable
objects do not know anything about the concrete subtypes of Observer
. This means new observers can be dynamically added to an object at runtime (for instance if a new view was opened in a user interface), or the system could be extended by adding a new subtype of Observer
without changing any of the model elements that the new observer might want to watch for state changes.
The Strategy design pattern enables encapsulation of algorithms. This lets client programs depend on the algorithmic interface without having to depend (or know about) the concrete underlying implementation being used. This allows new algorithms to be easily defined and added to a system without changing any client code.
The strategy pattern is often used to avoid subclassing the client. In our example below, you could imagine Client
being extended by CelsiusStrategy
, KelvinStrategy
, and FahrenheitStrategy
. While this would work, it would mean that Client
would have to be changed to add a new form of temperature conversion. The pattern also supplants the even simpler approach whereby the code would have a series of conditional statements to choose the right temperature multiplier (which would also require Client
changes to extend):
if (tempScheme === 'C') {
...
} else if (tempScheme === 'F') {
...
} else if (tempScheme === 'K') {
...
} else {
...
}
In general, strategies are fairly constant at runtime (e.g., the concrete type of the underlying strategy will not frequently (or ever) change once it has been set). One challenge with the strategy pattern is that the client needs to know about the available strategies to be able to instantiate the one they are to use, although factories or dependency injection can play a role here to help insulate the client from this instantiation step.
The state design pattern provides a composition-based approach for clients to manage their behaviour dynamically as their internal state changes. The current state of the system is dictated by a reference to a state object; the reference is dynamically updated as conditions change. Rather than having one large if
or switch
statement controlling state transitions, transition decisions are left to the state objects which only need to reason about their valid transitions, not all global transitions.
In the diagram below, TCPState
objects use their reference to TCPConnection
to call setState(TCPState)
as the state of the system changes. In this way the client (TCPConnection
) always knows its current state without being responsible for making sure it is correct. As the client performs actions on its state
object, that object can itself update the client's state
in response to any action. In this way the client delegates the responsibility for managing state transitions to the state hierarchy.
The state pattern isolates state decisions which makes reasoning about how or why these transitions took place much easier (for example because one could add logging to setState(..)
in a way that would be opaque if the state was determined by examining values in fields within the system). This typically simplifies state management as well as from any given state there is a subset of valid other states that the program could transition to; this means the transition code is much simpler than a global block which must consider all possible transitions.
For example, the client could avoid change-prone brittle control flow like the following (this is a subset of what would be required in the example):
if (last == null || last == '') {
handleClosed();
} else if (last == 'listen' && isOpen()) {
handleOpen()
} else if (last == 'established' && isClosed()) {
handleClosed();
} else if (last == 'listen' && isClosed() {
close();
}
Clearly the Sate and Strategy patterns look structurally identical. And, except for the setState
method and the fact that every state object has a reference to its context (so it can call setState
) they are identical. The difference lies more in the intent of the pattern. Strategies are fixed at the start of execution, whereas the States change repeatedly and often during runtime. This distinction further reenforces that the most important aspect of patterns is not their structure and form, but what they do and how they promote encapsulation and evolution within the system.
The Facade is a structural pattern to provide a unified set of interfaces for a subsystem. Subsystems can contain a large amount of code that even if well designed can be difficult for a client to learn to correctly use. Facades provide coherent simplifications of modules for performing common tasks. It is not uncommon for a subsystem to have multiple facades for different client use cases. Facades are usually easy to implement once you have a complex subsystem that you want to provide a more unified high-level interface to.
One important note is that while a facade can simplify a subsystem, it does not prohibit clients from accessing features within the subsystem directly. Facades are mainly a pattern of convenience to make it easier for clients without restricting their options; however, if a client does only use the facade to access the subsystem they are also more insulated from structural changes within the subsystem as only the facade itself should have to be updated to support these, rather than the client themselves. One way to think about facades is that they essentially insert a layer into the design between the client and the subsystem. In architectural terms this is a 'non-strict' layer, since the client can bypass the facade to access the internals.
Consider the following WebmailClient
. This class is tightly bound to all of the subsystem code; if it wants to compose an email with an attachment or an appointment it needs to collaborate with many different classes. The author of WebmailClient
is almost certainly a different developer than the creator of all of those classes so they need to learn a large set of APIs (both which APIs to all, and in what order) to complete their task. Additionally, any changes to those APIs could impact their code; since there are so many direct dependencies the chances of a change impacting their system is not small.
To ameliorate this, they talk to the developers responsible for the PIM code and ask them to create a Facade that is easier for them to use for these common tasks. The PIM owner creates PIMFacade
that hides the internal details of the PIM subsystem and allows WebmailClient
to have only a single dependency. This decreases coupling between the client and the PIM classes, and adds a layer of abstraction so the PIM subsystem owner can simply update the PIMFacade
if any of their internal classes change in a way that could propagate to the client. This both simplifies modification tasks for the owner of WebmailClient
as they are insulated from these changes, but also for the owner of PIMFacade
because they know they can make larger changes as long as they do not need to change the facade API.
The Decorator pattern is another structural pattern that provides a means to dynamically augment an object's responsibilities. With the decorator pattern it is important to distinguish between an object and a class. A class is the structural template from which object instances are created. That is, an object is a single instance of a class and a class can have many different instances. Each object can have different field values, but the fields, methods, and parent types they have are all defined by the class they are instantiated from.
The decorator pattern exists to add new responsibilities to objects, instead of to their whole class. This means that two objects instantiated from the same type can be modified at runtime to behave differently. Decorators work by enabling objects to be wrapped in other objects and using composition to treat the wrapped object as if it were a single object.
For example, consider the following simple system where we can have a Car
or three special versions of with additional features:
One day a new customer asks for a car with both nav and adaptive cruise control. Planning ahead, the team realizes it is only a matter of time before customers ask for any subset of these features and set out to extend their design in the way that best preserves their existing design:
While the above approach is conceptually consistent with the initial design, having seven subclasses of Car
is not optimal and will surely cause extreme resistance to any new feature being added (for example CarAutoLights
) as this will have to be mixed in with every existing subclass. Instead, the team decides to move to a system using a decorator, which enables a Car
to be 'wrapped' in instances of CarDecorator
to add additional features; this is great, because adding a new features means just adding a single extra class meaning the development team can go home for Christmas after all:
It can be hard to visualize what this means from the class diagram alone. To create a version of a car with Nav and AutoBrake, one only needs to do the following:
var car = new Nav(new AutoBrake(new BaseCar())));
Even at runtime this could allow for additional features. For instance:
// create car with Nav off
var car = new AutoBrake(new BaseCar()));
// ... sometime later:
// turn on Nav, wrap existing object
car = new Nav(car);
The decorator does have some downsides: it is impossible to control the 'order' of the wrappers with the pattern. This also means that the wrappers cannot interact with one another directly (e.g., above we could wrap a BaseCar
with Nav
twice, which doesn't make any sense). Also, decorator objects tend to be fairly small resulting in a large number of classes. Decorators also interfere with object identity, so code that relies on checking identity (e.g., with instanceof
) will behave differently with wrapped and unwrapped objects.
Ultimately the decorator pattern provides excellent support for maintaining the flexibility and extensibility of the system. Base classes can be kept simple focusing on their core responsibilities (single responsibility), while additional functionality can be implemented in decorators (open/close). This also means adding new decorators is easy and does not change the base classes. This is a textbook demonstration of the flexibility of composition over inheritance.
Composites provide a mechanism for treating groups of objects the same as individual objects (often known as part-whole hierarchies). Systems often start with individual objects, but over time gain the ability to group objects together. Adding logic to differentiate individual objects from group objects adds unnecessary complexity to code. The composite pattern, through the composite (Manager
in the example below) uses composition to maintain a list of children while still itself being the parent component type (Employee
below).
The introduction of the composite means any client can treat both managers and developers as employees (e.g., by asking for their names or ids uniformly), whether they have reports or not. This frees client code from checking if the Employee
reference they have is a Manager
or a Developer
, and enabling a Manager
to appropriately traverse all of their reports appropriately (even if some of their reports are themselves a Manager
).
In the example below, the default implementation of Employee::getBudget()
would just be:
public getBudget():number {
return this.salary;
}
But the implementation of Manager::getBudget()
would be:
public getBudget():number {
var budget = this.salary;
for (let report of this.directReports) {
budget += report.getBudget();
}
return budget;
}
But to the client whether an employee is a Manager
or Developer
would be totally transparent.
// employee 1233 has no reports
var e1 = getEmployee(1233);
Log.info(e1.getBudget());
// employee 1234 has 4 direct and 35 indirect reports
var e2 = getEmployee(1234);
Log.info(e2.getBudget());
The visitor pattern enables operations to be performed on an object hierarchy without directly modifying the hierarchy itself (either by adding new classes or methods). The primary motivation for the pattern is that given a large set of objects it is often necessary to perform tasks on them that is not a part of their core responsibilities; this pollutes their classes and adds non-essential code to their classes that is spread across all classes. By providing an external mechanism for performing these tasks, the visitor extracts the code from the class hierarchy itself, while also bringing together all of the code for that task that would otherwise be spread across the object structure.
The visitor does require one new method be added to every class in the structure being traversed, which is a method called accept(visitor: Visitor): void
. While this is a change to the objects, all future visitors will work with this interface, enabling additional visitors to be added transparently to the system. The pattern acknowledges that the tasks we want to perform on a set of objects vary much more often than the core responsibilities of the objects themselves, so paying this one-time cost to enable future extensibility is often worthwhile.
For instance, in the diagram below one could imagine adding numReports
or topLangs
methods to Manager
and Developer
, but instead we have created a TopLangsVisitor
and NumReportsVisitor
which both traverse the hierarchy directly. Each accept(v: Visitor)
method immediately calls v.visit(this)
which uses dynamic dispatch to call the right visitor method. The method within the visitor can then interrogate the provided object to retrieve the required information and maintain a running tally of the answer that can be reported after the traversal is complete (the visitor can accumulate state in its own fields). Note, Manager::accept(Visitor)
would be slightly different (e.g., each object will ensure that its correct children (or composite components) are visited appropriately):
public accept(v: Visitor): void {
for (var r of this.directReports) {
r.accept(v);
}
v.visit(this);
}
While adding new visitors is easy, adding new concrete types to the type hierarchy is hard. This is because every visitor needs a visit
method for every type that is being traversed which could result in many visitors being impacted. Also, due to the runtime operation of the visitor being dictated by dynamic dispatch, it is often challenging to understand how the visitor works, if a problem is ever encountered.
There are a vast set of resources about design patterns, the following are only a rough starting point:
-
Great overview of most design patterns with concrete examples.
-
Repository of many design patterns implemented in TypeScript.
-
Interesting article on language-specific support for decorators.
-
Nice state pattern article.
-
Design Patterns: Elements of Object-Oriented Software (Gang of Four Book).