CMP-220 · Lesson 10 of 12 · 20 min

SOLID principles and creational and structural patterns

By midterm, the Hub has a model of nodes, detections, stations and classifiers, and Jonah has learned the mechanics of classes, inheritance, interfaces, exceptions and generics. Mechanics are not design, though. Two programmers with the same language features can produce a system that is easy to change or one that fights every change. Over decades, practitioners have distilled what tends to work into principles and named, reusable solutions called design patterns. This lesson covers the five SOLID principles, the advice to favour composition over inheritance, and the first group of classic patterns: those for creating objects and those for structuring them. Lesson 11 covers the patterns for behaviour. The wider topics of coupling, cohesion, architecture and refactoring at team scale are treated in CMPUT-301 Software Engineering.

The SOLID principles

SOLID is an acronym popularized by Robert C. Martin in the early 2000s for five principles of object-oriented design. They are guidelines, not laws: each one names a force that pushes designs towards being easier to change, and good engineers weigh them against simplicity. Each is illustrated here with a decision from the Hub.

The single responsibility principle says a class should have one reason to change, meaning it should serve one purpose or one group of stakeholders. Tally.java changed whenever the upload format changed, whenever classification rules changed, whenever report layouts changed and whenever the database changed; four unrelated reasons, so every edit risked breaking something unrelated. Splitting it into UploadParser, Classifier, ReportGenerator and DetectionStore gives each part one reason to change and lets each be tested alone.

The open-closed principle says software should be open for extension but closed for modification: you should be able to add new behaviour by adding code, not by editing working code. The Classifier interface from lesson 7 achieved this; adding the camera scheme meant adding a class, while ClassificationService, already tested and deployed, did not change. Polymorphism is the usual mechanism.

The Liskov substitution principle, from lesson 5, says subtypes must be usable wherever their base types are expected. Every implementation of Classifier must honour the interface's documented contract, including thread-safety and never returning null, or code that relies on the interface will break.

The interface segregation principle says clients should not be forced to depend on methods they do not use. An early NodeAdmin interface had sixteen methods, from reboot and reaim to upgradeFirmware and exportLogs, and the NS-3 adapter had to implement upgradeFirmware by throwing an exception, because loop nodes cannot be upgraded remotely. Splitting it into small role interfaces, Rebootable, Reaimable, Upgradable and LogSource, lets each node class implement exactly what it supports and each client ask for exactly what it needs.

The dependency inversion principle says high-level policy should not depend on low-level details; both should depend on abstractions. The upload service, which embodies the Hub's business rules, originally created a PostgresDetectionStore directly. Now it depends on a DetectionStore interface, which it owns, and the PostgreSQL implementation depends on that interface too. The arrow of dependency, which used to point from policy to database, now points from the database code to the policy's abstraction, hence inversion. In practice this is achieved with the constructor injection of lesson 4, and it is what lets tests replace the database with a fast in-memory store.

Composition over inheritance

The book that introduced design patterns to most programmers, Design Patterns: Elements of Reusable Object-Oriented Software, published in 1994 by Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides, often called the Gang of Four, gave two pieces of overarching advice: program to an interface, not an implementation, and favour object composition over class inheritance. Lesson 5 showed why: inheritance fixes behaviour at compile time, couples subclasses to superclass internals and explodes into many classes when several things vary independently. Composition lets an object hold collaborators behind interfaces and swap them, even at run time. If a node's sensor, power supply and modem each vary, a Node composed of a Sensor, a PowerSource and a Link needs one class per option, not one per combination: three sensors, two power sources and three links need eight small classes by composition but eighteen by inheritance.

What a design pattern is

A design pattern is a named, general solution to a recurring design problem, described with its intent, its structure, its participants and its trade-offs. The value of patterns is partly the solutions themselves and partly the shared vocabulary: saying 'wrap the legacy client in an adapter' communicates a whole design in five words. The Gang of Four catalogued 23 patterns in three groups: creational patterns, which control how objects are created; structural patterns, which compose classes and objects into larger structures; and behavioural patterns, which distribute responsibility and communication among objects. Patterns should be applied when the problem they solve is actually present, not sprinkled in advance; an unnecessary pattern is just complexity.

Creational patterns: factory and builder

The factory idea appeared in lessons 3 and 7: Classifier.forContract(scheme) decides which concrete class to create, so callers depend only on the interface. The Gang of Four's factory method pattern goes one step further and lets subclasses decide which class to instantiate by overriding a creation method; an abstract ReportGenerator could declare createFormatter() and each report type return its own formatter. Factories centralize creation decisions, hide concrete classes and are the natural place to read configuration.

The builder pattern solves a different problem: constructing an object that has many parameters, several of them optional. A StationConfig has a name, location, up to eight lanes each with a direction and width, a time zone, a reporting interval, a classifier scheme, alert thresholds and contact addresses. A constructor with fourteen parameters is unreadable and invites swapped arguments; several overloaded constructors, sometimes called telescoping constructors, multiply quickly. A builder collects the values with named methods, checks the whole set once in build(), and returns an immutable object.

A builder makes construction with many optional parameters readable and validates the result once.
StationConfig cfg = StationConfig.builder("Princess St at Division")
        .location(44.2312, -76.4860)
        .lane(1, Direction.EAST, 350).lane(2, Direction.EAST, 350)
        .lane(3, Direction.WEST, 350).lane(4, Direction.WEST, 330)
        .interval(Duration.ofMinutes(15))
        .classifier("axle13")
        .silenceAlertAfter(Duration.ofMinutes(30))
        .build();                       // validates everything, returns an immutable StationConfig

The singleton pattern and its problems

The singleton pattern ensures that a class has exactly one instance and provides a global access point to it, typically through a static getInstance() method. It is the most famous pattern and the most criticized. A singleton is global state in disguise: any code anywhere can reach it, so dependencies are hidden, tests cannot easily replace it with a fake, and tests can interfere with each other through the shared instance. Making a lazily created singleton thread-safe in Java also needs care. The Hub's original Config.getInstance() made every unit test read the production configuration file. Today the advice is to create one instance at start-up and pass it to whoever needs it, by dependency injection. If exactly one instance is truly required, that is a decision for the code that assembles the application, not for the class itself. In Java, a single-element enum is the safest way to write a true singleton when one is unavoidable.

Structural patterns: adapter, composite and decorator

The adapter pattern converts the interface of an existing class into the interface clients expect, letting classes work together that otherwise could not. The 600 NS-3 loop nodes upload a fixed-width text format from 2009, while the Hub's ingestion code expects Upload objects. Rather than teaching the ingestion code about the old format, an Ns3UploadAdapter wraps the legacy parser and presents the modern interface. Adapters are also the standard way to isolate third-party libraries, such as a weather service used to explain unusual counts, so that switching providers touches one class.

A UML diagram of the adapter pattern. An IngestionService box points to an UploadSource interface with methods nextUpload. An Ns3UploadAdapter class realizes UploadSource with a dashed line and has a composition line to a LegacyNs3Parser class with methods readFixedWidthLine and parseCounts. Inside the adapter, a note shows nextUpload calling readFixedWidthLine and converting the fields into an Upload object. A separate RadarUploadSource also realizes UploadSource.
An adapter wraps a class with an incompatible interface and presents the interface clients expect.

The composite pattern lets clients treat individual objects and groups of objects uniformly by arranging them in a tree in which groups and leaves share an interface. Traffic engineers think in hierarchies: a corridor contains intersections, an intersection contains stations, a station contains nodes. Each of these can answer totalCount(interval). A leaf node answers from its own detections; a group answers by summing its children. Reports and dashboards call totalCount on any element of the tree without caring whether it is one node or a whole city.

A tree diagram for the composite pattern. At the top a box labelled corridor King St has two children, intersections at Princess and at Brock. Each intersection has station children, and each station has node leaves labelled with node numbers. Leaf boxes are white and group boxes are shaded. Beside the tree, a small UML diagram shows a CountSource interface with totalCount, realized by a NodeCounts leaf class and a Group class, which also has an aggregation line back to CountSource labelled children.
The composite pattern: groups and leaves share one interface, so a whole tree answers the same question as a single node.

The decorator pattern adds responsibilities to an object dynamically by wrapping it in another object with the same interface, which does something before or after delegating. A CachingClassifier wraps any Classifier and remembers recent results; a TimingClassifier records how long each call takes; they can be stacked in any combination, which inheritance could achieve only with a subclass for every combination. Java's I/O streams are the textbook example: a BufferedInputStream wrapping a GZIPInputStream wrapping a FileInputStream.

# Builder, adapter, composite and decorator in a few lines each
class StationConfig:
    def __init__(self, name, lanes, interval_min, scheme):
        self.name, self.lanes, self.interval_min, self.scheme = name, tuple(lanes), interval_min, scheme

class StationConfigBuilder:                       # builder: named steps, one validation
    def __init__(self, name):
        self._name, self._lanes, self._interval, self._scheme = name, [], 15, "simple5"
    def lane(self, number, direction):
        self._lanes.append((number, direction)); return self
    def interval(self, minutes):
        self._interval = minutes; return self
    def classifier(self, scheme):
        self._scheme = scheme; return self
    def build(self):
        if not 1 <= len(self._lanes) <= 8: raise ValueError("1 to 8 lanes")
        if 60 % self._interval: raise ValueError("interval must divide an hour")
        return StationConfig(self._name, self._lanes, self._interval, self._scheme)

cfg = StationConfigBuilder("Princess at Division").lane(1, "E").lane(2, "W").interval(15).build()
print(cfg.name, cfg.lanes, cfg.interval_min, cfg.scheme)

class LegacyNs3Parser:                            # the class we cannot change
    def read_fixed_width(self, line):
        return int(line[0:5]), int(line[5:6]), int(line[6:10])
class Ns3UploadAdapter:                           # adapter: presents next_upload()
    def __init__(self, lines):
        self._parser, self._lines = LegacyNs3Parser(), iter(lines)
    def next_upload(self):
        node, lane, count = self._parser.read_fixed_width(next(self._lines))
        return {"node": node, "lane": lane, "count": count}
print(Ns3UploadAdapter(["0031120147"]).next_upload())

class NodeCounts:                                 # composite leaf
    def __init__(self, node_id, count): self.node_id, self.count = node_id, count
    def total(self): return self.count
class Group:                                      # composite group
    def __init__(self, name, *children): self.name, self.children = name, children
    def total(self): return sum(c.total() for c in self.children)
corridor = Group("King St",
                 Group("Princess", Group("PS-1", NodeCounts(17, 412), NodeCounts(18, 377))),
                 Group("Brock", Group("BR-2", NodeCounts(44, 296))))
print("corridor total:", corridor.total(), "| Princess:", corridor.children[0].total())

class LengthClassifier:
    def classify(self, length_cm): return "car" if length_cm < 600 else "truck"
class CountingClassifier:                         # decorator: same interface, extra work
    def __init__(self, inner): self.inner, self.calls = inner, 0
    def classify(self, length_cm):
        self.calls += 1
        return self.inner.classify(length_cm)
clf = CountingClassifier(LengthClassifier())
print([clf.classify(x) for x in (455, 1720, 510)], "calls:", clf.calls)
Output
Princess at Division ((1, 'E'), (2, 'W')) 15 simple5
{'node': 311, 'lane': 2, 'count': 147}
corridor total: 1085 | Princess: 789
['car', 'truck', 'car'] calls: 3

One more structural pattern deserves a mention because it appears in almost every large system: the facade, a single simplified interface in front of a complicated subsystem. The Hub's reporting subsystem involves a query builder, a gap checker, several report generators, a template engine and a file store. The customer web portal does not need to know any of that; it calls ReportFacade.monthlySummary(stationId, month) and receives a file. A facade does not hide the subsystem from code that genuinely needs its details, but it gives everyone else one obvious, stable entry point, which reduces coupling across the system and makes the subsystem easier to restructure later.

Worked problem

Applying the principles to a new requirement

A provincial contract requires counts to be sent nightly to the ministry's server in its own XML format, by secure file transfer, with an email to the ministry if the transfer fails twice. A first draft adds an exportToMinistry() method to ReportGenerator containing the XML formatting, the transfer code and the email code. Critique the draft with SOLID and propose a design.

Show solution
  1. Single responsibility: ReportGenerator would change for formatting, transport and notification reasons. Split them.
  2. Open-closed: the next customer will want another format or transport; adding methods to ReportGenerator each time means editing tested code.
  3. Design: a MinistryXmlReport extends the ReportGenerator template (lesson 7) for formatting; a Transport interface with an SftpTransport implementation sends files; a Notifier interface with an EmailNotifier reports failures.
  4. Dependency inversion: a NightlyExportJob depends only on ReportGenerator, Transport and Notifier abstractions, injected at start-up, so tests use an in-memory transport that fails on purpose.
  5. A RetryingTransport decorator wraps any transport to try twice before reporting failure.

Answer: Four small classes and two interfaces replace one overloaded method. Each piece has one reason to change, new formats and transports are added without editing existing code, and the retry policy can be reused anywhere.

Common mistakes

  • MistakeApplying patterns because they exist rather than because the problem needs them.

    InsteadStart simple; introduce a pattern when the problem it solves actually appears.

  • MistakeUsing singletons for configuration, databases or clocks.

    InsteadCreate one instance at start-up and inject it; tests can then substitute their own.

  • MistakeBuilding wide interfaces that force implementers to throw for unsupported methods.

    InsteadSplit them into small role interfaces (interface segregation).

  • MistakeLetting business logic create its own database or network classes.

    InsteadDepend on abstractions owned by the policy and inject the details (dependency inversion).

  • MistakeUsing inheritance to combine independent variations.

    InsteadCompose objects from interchangeable parts, and use decorators to add behaviour.

Summary and key terms

  • SOLID: single responsibility, open-closed, Liskov substitution, interface segregation and dependency inversion.
  • Favour composition over inheritance and program to interfaces, the Gang of Four's two general principles.
  • Design patterns are named solutions to recurring problems; the classic 23 are creational, structural or behavioural.
  • Creational: factories hide which class is created; builders construct complex immutable objects; singletons are usually better replaced by injection.
  • Structural: adapters convert interfaces, composites treat groups and leaves alike, decorators add behaviour by wrapping.
Single responsibility principle
A class should have one reason to change.
Open-closed principle
Software should be extendable without modifying existing, working code.
Dependency inversion principle
High-level policy and low-level details should both depend on abstractions.
Design pattern
A named, reusable solution to a recurring design problem.
Builder
A creational pattern that constructs a complex object step by step and validates it once.
Adapter
A structural pattern that wraps a class to present the interface clients expect.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Name the SOLID principle violated by an interface whose implementers must throw for half its methods.

    Show answer

    The interface segregation principle.

  2. How many classes does an inheritance design need for 3 sensors, 2 power sources and 3 links? A composition design?

    Show answer

    18 combination classes by inheritance; 3 + 2 + 3 = 8 part classes plus one Node by composition.

  3. Why do singletons make unit testing harder?

    Show answer

    They are global state reached directly, so tests cannot substitute fakes easily and can affect each other.

  4. Which pattern lets a corridor and a single node both answer totalCount()?

    Show answer

    The composite pattern.

  5. Which pattern would add logging to every classifier without changing any classifier class?

    Show answer

    The decorator pattern.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz