Skip to content

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 Model internally 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:
interface Model {

  void moveLeft();

  void moveRight();

  void moveUp();

  void moveDown();
}

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: OldPrinter must also implement scan and fax methods - 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:
    void main() {
    
      // Classic flipper, without any injection.
      Flipper flipper = new ClassicFlipper();
      flipper.flip();
    }
    

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 Flipper implementation
  • 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: