Design Principles
In this lecture we'll take a look at two sets of high level design guidelines, GRASP and SOLID. Grasp provides more abstract, and more generic guidelines, while SOLID targets concrete code properties that can be easily verified. Common to both is them providing guidance for the decision-making on how to structure your code into smaller modules, notably classes.
Lecture upshot
In this lecture you'll learn a selection of design criteria, that help you structure your code to comply to OO-principles.
GRASP
In 1997, Craig Larman coined the GRASP term, short for GRASP stands for "General Responsibility Assignment Software Patterns". GRASP aims to be a general aid for decision-making on class design, i.e. general recommendations to provide methodical, rational and explainable grounds for decision-making.
In the first segment of this lecture, we iterate over a selection of GRASP patterns, and illustrate their meaning on visualizations and code examples.
Terminology note
The GRASP author uses the term "Pattern" for individual guidance elements. I do not really like the term "Pattern" as in the context of Object-Oriented development the term almost always refers to "Design Patterns", which we will be intensively studying in an upcoming lecture. To avoid confusion I will refer to GRASP elements as "principles" or "guidelines", although in common literature you might find the term "GRASP patterns".
Information Expert
The "Information Expert" pattern (or short "Expert" principle) is about defining one and only one class responsible for a given task.
Following the Information Expert principle, this should be the class holding all information needed to perform the
task (expert).
Example
In a Library, late return fees often depend on the book. Popular books cost more, if returned late. The information is
likely stores in the Book class itself. Following this logic, it should be the Book that computes late return fees,
not any other class:
classDiagram
%% Classes
class Book {
+title: String
+author: String
+dailyLateFee: Float
+calculateLateFee(daysLate: int): Float
}
class LibraryMember {
+name: String
+memberId: String
}
class LibraryController {
+checkOutBook(book: Book, member: LibraryMember)
+returnBook(book: Book, member: LibraryMember)
}
%% Relationships
LibraryController --> Book : uses
LibraryController --> LibraryMember : uses
Creator
"Creator" aims to answer the question "Who should be responsible for creating objects of a given class A?". or
simply: "
Who calls new A?"
The Creator principle guides by pointing to several question. A reasonable creator is any other class which already does any of the following:
- Aggregate instances of
A - Contains instances of
A - Records instances of
A - Closely uses instances of
A - Already holds the data needed to initialize instances of
A
Example: in a board game online platform, this likely is a session-manager, which tracks running, and persisted game sessions.
Controller
Heads up: Despite the same name, GRASP's Controller principle is not related to the MVC pattern that we've seen in the last course unit.
- "Controller" gives directions on who, i.e. which class, is responsible for upcoming events, e.g. "user submitted a form".
- It is good practice to group handler methods for common a common use case into one controller class.
- But careful: controller classes are not supposed to do all the heavy lifting themselves. They are rather meant to serve as coordinators, defining sequences of consecutive actions, delegated to other system parts in the right order.
Controller should delegate
"[...] a controller should delegate to other objects the work that needs to be done; it coordinates or controls the activity. It does not do much work itself."
-- Craig Larman - Applying UML patterns (
Example
Most UI handles fall into this category. Imagine you have a pane with many buttons - the class receiving interaction events is usually not the class dealing with triggered functionality. It just delegates calls to the right destination.
For example if a UI has a button to sort a list, the controller just delegates sorting responsibility, but does not itself contain a sorting algorithm implementation.
High cohesion
intra-class metric: how well does the content of one class fit together.
Image credits: Based on Wikipedia
Low coupling
Coupling: To which extent software modules (e.g. classes or packages) are interdependent.
Ideally, you want low coupling, that is, the modules should be sufficiently self-contained to fulfil their purpuse without constantly refering outside module boundaries.
Image credits: Wikipedia
Why do we strive for loose coupling, not zero coupling ?
Zero coupling means "no inter-module connections at all": The are multiple modules, but they are perfectly segregated. Unfortunately zero coupling implies there must be dead code, because some modules (i.e. fractions of the codebase) are never referred to.
Polymorphism
Polymorphism refers to the practice of replacing type based decision making to object delegation at runtime.
We have already discussed polymorphism in a previous lecture, therefore here only a short illustration of the class layout needed for a polymorphic Duck concert:
classDiagram
class Duck {
<<Class>>
+String swim()
+String quack()
}
class RedheadDuck {
<<Class>>
}
class Mallard {
<<Class>>
}
Duck <|-- RedheadDuck : extends
Duck <|-- Mallard : extends
Protected variations
Coding against interfaces is a concrete example for this principle: if the production code makes no assumptions on the class implementing an interface, that class can be swapped by an alternative without conflicts. By coding against an interface rather than a concrete implementation the caller is protected from variations in the implementing class.
Before
- Let's imagine a simple game where an item can be moved around in a 4x4 grid.
- Valid moves are placing the item on any adjacent field.
- The
Modelinternally uses a 2d-boolean array to represent item position. - The controller modifies item position, by directly accessing the array and modifying state.
public static void main(String[] args) {
// In this example the controller makes implementation assumptions on the model, and
// therefore hinders model implementation variations.
ModelWithoutVariationsPossible model = new ModelWithoutVariationsPossible();
System.out.println(model);
// Moving the item somewhere else requires directly messing with internals:
model.board[0][0] = false;
model.board[0][1] = true;
System.out.println(model);
}
After
Not only that there are no consistency protections (nothing prevents us from suddenly having multiple items), we also
cannot change the Model implementation, without also modifying the Controller.
- It would suffice to just store the item position, rather than storing a 2D boolean array, but we cannot make this change, because the implementation does not protect variations.
- Better would be a simple interface, that reveals nothing on internals:
Now we can easily replace any implementation by any variant:
classDiagram
%% Interface
class PositionModel {
<<interface>>
+moveUp()
+moveDown()
+moveLeft()
+moveRight()
}
%% Implementations
class TwoDimensionalArrayModelImpl {
+moveUp()
+moveDown()
+moveLeft()
+moveRight()
}
class PositionAsTwoIntValuesModelImpl {
+moveUp()
+moveDown()
+moveLeft()
+moveRight()
}
%% Controller depends on interface
class Controller {
+changePosition()
}
%% Relationships
TwoDimensionalArrayModelImpl ..|> PositionModel
PositionAsTwoIntValuesModelImpl ..|> PositionModel
Controller --> PositionModel : changePositionWithoutAssumingDetail
When is coding against an interface a means to protecting variants ?
Only when the interface conceals implementation details to a point that alternative implementations are possible.
Pure fabrication
When no class has a natural responsibility, create an artificial class to handle it.
Example: Instead of making an Invoice class handle file storage, create an InvoiceRepository class, specifically for
the job of persisting
invoices.
Having this "fabricated" new class avoids cluttering Invoice with database logic.
Before
sequenceDiagram
participant Launcher
participant Invoice
Launcher->>Invoice: new Invoice(42, "Max", 123, [1,2,3])
activate Invoice
deactivate Invoice
Note right of Invoice: Constructor initializes fields
Launcher->>Invoice: write()
activate Invoice
Invoice-->>Launcher: write complete
deactivate Invoice
Sequence diagram illustrates class with mingled responsibilities (representing data and persisting).
After
sequenceDiagram
participant Launcher
participant Invoice
participant InvoiceFileSystemPersistor
Launcher->>Invoice: new Invoice(42, "Max", 123, [1,2,3])
activate Invoice
Note right of Invoice: Constructor initializes fields
deactivate Invoice
Launcher->>InvoiceFileSystemPersistor: new InvoiceFileSystemPersistor()
activate InvoiceFileSystemPersistor
Note right of InvoiceFileSystemPersistor: Creates persistor instance
deactivate InvoiceFileSystemPersistor
Launcher->>InvoiceFileSystemPersistor: write(i1)
activate InvoiceFileSystemPersistor
InvoiceFileSystemPersistor-->>Launcher: write complete
deactivate InvoiceFileSystemPersistor
Revised design, sequence diagram shows data representation and persistance are handled by separate classes.
Indirection
Indirection aims at reducing strong coupling between classes. The gist is to introduce an intermediate object that " decouples" the original direct coupling.
Example: We're directly dealing with database handling, using a class InvoiceRepository. We can make
the point that the caller should not care how exaclty persistance is realized. Using a general persistance interface
can decouple the direct link.
Before
classDiagram
class Launcher {
+createInvoice() Invoice
}
class Invoice
class InvoiceFileSystemPersistor {
+write(invoice: Invoice)
}
Launcher --> Invoice : has
Launcher --> InvoiceFileSystemPersistor : uses
After
classDiagram
direction TB
class Launcher {
+createInvoice() Invoice
+persistor: InvoicePersistor
}
class Invoice
class InvoicePersistor {
<<interface>>
+write(invoice: Invoice)
}
class InvoiceFileSystemPersistor {
+write(invoice: Invoice)
}
Launcher --> Invoice : has
Launcher --> InvoicePersistor : writes
InvoiceFileSystemPersistor ..|> InvoicePersistor : implements
SOLID
SOLID is a second acronym for five core object-oriented design principles. In contrast to GRASP, each letter represents one concrete, actionable principle:
- Single responsibility
- Open / closed principle
- Liskov substitution
- Interface segregation
- Dependency inversion
In the following we'll take a look at each element
Single Responsibility
In essence, the Single Responsibility Principle (SRP) comes down to:
Design your classes so each class deal with exactly one issue:
- Don't mingle multiple responsibilities in one class.
- Make sure no fraction of the class responsibility is outsourced elsewhere.
- Make sure the "how" a class deals with an issue is concealed.
Uncle Bob's advice
"Gather together the things that change for the same reasons. Separate those things that change for different reasons."
So at the end of the day single responsibility is a class-level principle, advocating for high cohesion and low coupling.
Do you see a conflict between the SRP and a GRASP principle ?
Information expert: it states we're supposed to put computations into the expert holding the data needed for computation. This can be in conflict with giving a class a single purpose (see book example above). Which principle outweights depends on the context, you may have to find a compromise.
Open/Closed
The open closed principle states that software modules (classes) should be:
- Open to extension (allow subclasses when additions are needed)
- Closed to modification (don't allow changing code when additions are needed)
Illustration
Any switch statement over a fixed set of options is an example for a violation of the Open/Closed principle, since
adding a new option requires modification of the existing switch statement:
class PaymentProcessor {
public void processPayment(String type) {
if (type.equals("credit")) {
// logic for credit card
} else if (type.equals("paypal")) {
// logic for PayPal
} else if (type.equals("crypto")) {
// logic for crypto
}
}
}
Where's the issue
We cannot add a new payment method "gold", without changing the else/if or switch cascade, i.e. the code is closed to modification. Gold also cannot be added as subclass to an existing payment method, i.e. the existing payment methods are not open to (the required) extension.
A cleaner solution would be to use a common payment interface and subclasses for individual payment methods:
classDiagram
class PaymentMethod {
<<interface>>
+pay()
}
class CreditCardPayment {
+pay()
}
class PayPalPayment {
+pay()
}
class PaymentProcessor {
+processPayment(PaymentMethod)
}
PaymentMethod <|.. CreditCardPayment
PaymentMethod <|.. PayPalPayment
PaymentProcessor --> PaymentMethod
Adding another payment method, e.g. Crypto would be perfectly compliant to the Open/Close principle:
- The existing modules are not modified: "Closed" respected.
- Additional functionality is reached by subclassing: "Open" applied.
Liskov Substitution
The Liskov substitution principle
Methods that use references to base classes, must be able to use objects of derived classes without knowing it.
To paraphrase, the Liskov substitution principle means: in terms of functionality, subclasses must provide at least everything the parent class provides.
- Subclasses are allowed to provide additional functionality.
- Subclasses are not allowed to reduce functionality.
Careful, Liskov includes behavioural compatibility!
"Use" in the original definition does not restrict to method existence (that is already enforced by Java's class hierarchy rules). Liskov also includes behavioural compatibility, i.e. a subclass must not differ in outcome, e.g. by throwing an exception where the original class applied silent error handling.
Illustration
The diagram below illustrates the Liskov substitution principle:
classDiagram
class Bird {
+fly()
}
class Sparrow {
+fly()
+singSparrowSong()
}
Bird <|-- Sparrow
Overcoming principle violations
Next we'll expand the previous Bird example to include a clear violation to the Liskov principle: Penguins, while
obviously Birds cannot fly, so the class diagram will showcase a reduced subclass functionality:
classDiagram
class Bird {
+fly()
}
class Sparrow {
+fly()
+singSparrowSong()
}
class Penguin {
+dive()
}
Bird <|-- Sparrow
Bird <|-- Penguin
While semantically accurate, our Penguin class now no longer adheres to the Liskov principle: A caller invoking
Bird.fly() cannot switch to the Penguin subclass without noticing a difference.
The design solution is to extend the class hierarchy, so our concrete birds (Sparrow, Penguin) showcase purely
additional functional changes to their respective superclasses.
What would be the resulting class diagram?
classDiagram
class Bird {
}
class FlyingBird {
+fly()
}
class FlightlessBird {
}
class Sparrow {
+fly()
+singSparrowSong()
}
class Penguin {
+dive()
}
Bird <|-- FlyingBird
Bird <|-- FlightlessBird
FlyingBird <|-- Sparrow
FlightlessBird <|-- Penguin
Interface Segregation
The Interface Segregation Principle (ISP) aims at breaking down larger interfaces, especially to avoid forcing the implementation of unnecessary functions.
Illustration
Let's assume we have an interface for printers, providing multiple common functionalities (print, scan, fax)
classDiagram
class Printer {
<<Interface>>
+print()
+scan()
+fax()
}
class OldPrinter {
+print()
}
class MultiFunctionPrinter {
+print()
+scan()
+fax()
}
Printer <|-- OldPrinter
Printer <|-- MultiFunctionPrinter
The problem is: as you know an implementing class must implement all interface methods.
- So the above example is actually not possible:
OldPrintermust also implementscanandfaxmethods - even if the device does not even support these features ! - We might work our way around by introducing some dummy methods, that actually throw an exception when invoked:
class OldPrinter implements Printer {
public void print() {
System.out.println("I might be old, but I can print !");
}
public void scan() {
throw new RuntimeException("Surprise, surprise: scan is actually not supported.");
}
public void fax() {
throw new RuntimeException("Surprise, surprise: fax is actually not supported.");
}
}
So while our printer pretends to provide all interface functionality, it actually does not.
Who's to blame?
The interface, because it assumes too much common functionality for any printer. The interface should be segregated, to better reflect reality.
Remedy
The common way to overcome this situation, is to segregate the existing (often massive) interface into multiple smaller interfaces, where each interface is tailored to a specific use-case:
classDiagram
class Printer {
<<Interface>>
+print()
}
class Scanner {
<<Interface>>
+scan()
}
class Fax {
<<Interface>>
+fax()
}
class MultiFunctionPrinter {
+print()
+scan()
+fax()
}
class OldPrinter {
+print()
}
Printer <|-- MultiFunctionPrinter
Printer <|-- OldPrinter
Scanner <|-- MultiFunctionPrinter
Fax <|-- MultiFunctionPrinter
What has changed?
The previous Printer (previous) interface, offering three methods has been split into three interfaces for specific purposes: Fax, Scanner, Printer (new). The concrete devices implement only the interfaces that align with their actual functionality. No more dummy methods!
Dependency Inversion
Classes depend on other classes all the time. An often overlooked question is: How do classes obtain their dependencies?
Essentially it comes down to two options:
- Actively claim your dependencies:
sequenceDiagram
participant Launcher
participant ClassicFlipper
participant ConsoleInputProvider
participant ConsoleOutputPrinter
%% Launcher creates ClassicFlipper
Launcher ->> ClassicFlipper: new ClassicFlipper()
activate ClassicFlipper
%% ClassicFlipper constructor creates dependencies
ClassicFlipper ->> ConsoleInputProvider: new ConsoleInputProvider()
activate ConsoleInputProvider
ConsoleInputProvider -->> ClassicFlipper: return
deactivate ConsoleInputProvider
ClassicFlipper ->> ConsoleOutputPrinter: new ConsoleOutputPrinter()
activate ConsoleOutputPrinter
ConsoleOutputPrinter -->> ClassicFlipper: return
deactivate ConsoleOutputPrinter
ClassicFlipper -->> Launcher: return
deactivate ClassicFlipper
%% Launcher calls flip method
Launcher ->> ClassicFlipper: flip()
activate ClassicFlipper
%% (Optional internal calls during flip can be added here)
ClassicFlipper -->> Launcher: return
deactivate ClassicFlipper
- Receiving dependencies:
sequenceDiagram
participant Launcher
participant IocFlipper
participant ConsoleInputProvider
participant ConsoleOutputPrinter
%% Launcher creates dependencies first
Launcher ->> ConsoleInputProvider: new ConsoleInputProvider()
activate ConsoleInputProvider
ConsoleInputProvider -->> Launcher: return
deactivate ConsoleInputProvider
Launcher ->> ConsoleOutputPrinter: new ConsoleOutputPrinter()
activate ConsoleOutputPrinter
ConsoleOutputPrinter -->> Launcher: return
deactivate ConsoleOutputPrinter
%% Launcher creates IocFlipper with injected dependencies
Launcher ->> IocFlipper: new IocFlipper(ConsoleInputProvider, ConsoleOutputPrinter)
activate IocFlipper
IocFlipper -->> Launcher: return
deactivate IocFlipper
%% Launcher calls flip method
Launcher ->> IocFlipper: flip()
activate IocFlipper
%% Optional: internal calls to input/output during flip could be shown here
IocFlipper -->> Launcher: return
deactivate IocFlipper
Active dependency claims
- Intuitively one might search to satisfy all class dependencies (what a class needs to function) in its constructor.
-
For example, if a classes purpose is to flip (inverse) strings, it needs something to get inputs from, and something else to send outputs to.
- The "naive" implementation is to satisfy both dependencies by actively claiming corresponding objects in the constructor:
package inversion; /** * The ClassicFlipper class obtains inputs from an InputProvider, and sends output to an OutputPrinter. */ class ClassicFlipper implements Flipper { // Two dependencies (we don't care how they function internally) private InputProvider inputProvider; private OutputPrinter outputPrinter; /** * Default constructor, has no arguments. The class is in control: The class saturates its * dependencies by itself creating the corresponding objects. */ public ClassicFlipper() { // We're in control, we actively saturate our dependencies: this.inputProvider = new ConsoleInputProvider(); this.outputPrinter = new ConsoleOutputPrinter(); } /** * A ClassicFlipper method. Required something providing input and something the output can be sent to * (the two object dependencies satisfy this need). */ public void flip() { String input = inputProvider.getInput(); String flipped = new StringBuilder(input).reverse().toString(); outputPrinter.printOutput(flipped); } }- The main class does not pass any arguments to
Flipper:
There are multiple issues with this approach:
- Implementation depends on concrete instances of dependencies.
- Testing in isolation is not possible.
- Replacement of one dependency by an alternative implementation affects Flipper implementation.
Receiving dependencies
The Inversion of Control alternative is to...
- eliminate all active dependency claims in the
Flipperimplementation - receive constructor parameters instead
/**
* Inversion of Control variant of ClassicFlipper. Receives all dependencies via constructor. Only
* depends on interfaces.
*/
public class IocFlipper implements Flipper {
// Two dependencies (we don't care how they function internally)
private InputProvider inputProvider;
private OutputPrinter outputPrinter;
/**
* Default constructor, has no arguments. The class is in control: The class saturates its
* dependencies by itself creating the corresponding objects.
*/
public IocFlipper(InputProvider inputProvider, OutputPrinter outputProvider) {
// We're in control, we actively saturate our dependencies:
this.inputProvider = inputProvider;
this.outputPrinter = outputProvider;
}
/**
* A ClassicFlipper method. Required something providing input and something the output can be
* sent to (the two object dependencies satisfy this need).
*/
public void flip() {
String input = inputProvider.getInput();
String flipped = new StringBuilder(input).reverse().toString();
outputPrinter.printOutput(flipped);
}
}
The main class now injects all required dependencies via the Flipper constructor:
void main() {
// IoC flipper, with parameter argument injection
// IocFlipper is coded against interface, will accept any InputProvider / OutputPrinter
Flipper iocFlipper = new IocFlipper(new ConsoleInputProvider(), new ConsoleOutputPrinter());
iocFlipper.flip();
}
Advantages:
- Flipper only depends on interfaces: saturating classes can be easily changed.
- Flipper can be tested in isolation (passing mocks).
Outlook
For completeness
Technically there's a third possibility, which is autowiring: using annotations and reflection to "magically" saturate all dependencies, although neither claimed, nor requested. Some reflection-heavy frameworks, like Spring even mandate autowiring as principle way of saturating dependencies, which can be overwhelming for novices. Autowiring is a topic by itself going beyond the scope if this course. If interested, read-on here (Chapter 2).
Literature
Inspiration and further reads for the curious minds: