# Design Principles

How should we think about design?

What is good software design? How would you measure it? What practices would you need to follow to achieve it? How can you make your architecture flexible, stable and easy to understand?

These are the great questions; but, unfortunately, the answers are different depending on the type of application you’re building.
-- Shvets, Dive Into Design Patterns (2020).

Shvets (2020) identifies some design principles that are applicable to any software design!

# Enforce Modularity

Well-designed systems are modular. Modularity refers to the logical grouping of source code into related groups. This can be realized as namespaces (C++), packages (Java or Kotlin). Modularity is important because it helps reinforce a separation of concerns, and also encourages reuse of source code through modules.

Modularity follows from the SOLID Single Responsibility Principle. Interface substitution also supports modularity, since it provides for loose coupling based on interfaces.

When discussing modularity, we can identify two related and probably familiar concepts: cohesion, and coupling.

Cohesion is a measure of how related the parts of a module are to one another. A good indication that the classes or other components belong in the same module is that there are few, if any, calls to source code outside the module (and removing anything from the module would necessitate calls to it outside the module).

Cohesion
Cohesion

Coupling refers to the calls that are made between components; they are said to be tightly coupled based on their dependency on one another for their functionality. Loosely coupled means that there is little coupling, or it could be avoided in practice; tight coupling suggests that the components probably belong in the same module, or perhaps even as part of a larger component.

Coupling
Coupling

When designing modules, we want high cohesion (where components in a module belong together) and low coupling between modules (meaning fewer dependencies). This increases the flexibility of our software, and makes it easier to achieve desirable characteristics, e.g. scalability.

-- Diagrams courtesy of Buketa & Balint, Jetpack Compose by Tutorials (2021).

In Kotlin, we use packages to group related classes. To do so, use the package keyword at the top of a class to specify a package (e.g. package graphics). To use a package from a class that is not in the same package, you will need to import that package and class (e.g. import graphics.*)

# Encapsulate What Varies

Identify the aspects of your application that vary and separate them from what stays the same.

The main goal of this principle is to minimize the effect caused by changes.

You can do this by encapsulating classes or functions. In both cases, your goal is separate and isolate the code that is likely to change from the rest of your code. This minimizes what you need to change over time.

The following example is taken from Shvets (and rewritten in non-idiomatic Kotlin).

fun getOrderTotal(order){
    total=0
    for (item in order.lineItems)
        total+=item.price*item.quantity
        if (order.country=="US")
            total+=total*0.07 // US sales tax
        else if (order.country=="EU"):
            total+=total*0.20 // European VAT
    return total
}

Given that the tax rates will likely vary, we should isolate them into a separate function. This way, when the rates change, we have much less code to modify.

fun getOrderTotal(order) {
    total=0
    for each (item in order.lineItems)
        total += item.price*item.quantity
        total += total*getTaxRate(order.country)
    return total
}

fun getTaxRate(country) {
    return when (country) {
        "US" -> 0.07 // US sales tax
        "EU" -> 0.20 // European VAT
        else -> 0
    }
}

Similarly, we can split up classes into smaller independent units. Here's a monolithic class that could be refactored:

Here are the restructured classes.

# Program to an Interface, Not an Implementation

Program to an interface, not an implementation. Depend on abstractions, not on concrete classes. See SOLID Interface Substitution.

When classes rely on one another, you want to minimize the dependency—we say that you want loose coupling between the classes. This allows for maximum flexibility. To accomplish this, you extract an abstract interface, and use that interface to describe the desired behaviour between the classes.

For example, in the diagram below, our cat on the left can eat sausage, but only sausage. The cat on the right can eat anything that provides nutrition, including sausage. The introduction of the food interface complicates the model, but provides much more flexibility to our classes.

Interface example
Interface example

# Favour Composition over Inheritance

Inheritance is a useful tool for reusing code. In principle, it sounds great—derive from a base class, and you get all of its behaviour for free! Unfortunately, it's rarely that simply. There are sometimes negative side effects of inheritance.

  1. A subclass cannot reduce the interface of the base class. You have to implement all abstract methods, even if you don't need them.
  2. When overriding methods, you need to make sure that your new behaviour is compatible with the old behaviour. In other words, the derived class needs to act like the base class.
  3. Inheritance breaks encapsulation because the details of the parent class are potentially exposed to the derived class.
  4. Subclasses are tightly coupled to superclasses. A change in the superclass can break subclasses.
  5. Reusing code through inheritance can lead to parallel inheritance hierarchies, and an explosion of classes. See below for an example.

A useful alternative to inheritance is composition.

Where inheritance represents an is-a relationship (a car is a vehicle), composition represents a has-a relationship (a car has an engine).

Imagine a catalog application for cars and trucks:

Inheritance example
Inheritance example

Here's the same application using composition. We can create unique classes representing each vehicle which just implement the appropriate interfaces, without the intervening abstract classes.

Composition example
Composition example

# Write Classes That Are Deep

John Ousterhout discusses the importance of reducing complexity in A Philosophy of Software Design (2018). He recommends writing "deep," extremely rich classes.

This is related to information hiding [Parnas 1972]. We have been taught to hide information in our code (encapsulation). However, the reason to do this is often not due to security concerns, but rather as a way of reducing complexity for the programmer. We want to package up functionality in a way that reduces the programmer's need to understand how it works.

The interface of a class is the complexity cost of the implementation (again, complexity refers to the programmer's mental model—what they have to know to use the class properly). We want an interface that hides a LOT of complexities to make the cost of using the class worth the benefit.

![Shallow vs. Deep Classes [Ousterhout 2018]](/images/shallow-vs-deep-classes.png)

Here's an example of a shallow class that has a negative benefit: it costs more mental effort to determine what it does than it does to just write the underlying code yourself (and it would be fewer keystrokes!)

	private void addNullValueToAttribute(String attribute) {
		data.put(attribute, null);
	}

You cannot always eliminate shallow classes, but a shallow class doesn't help you much in the fight against complexity.

"Classes and Methods Should Be Small" -- Many CS textbooks

I absolutely disagree. Classes and methods should be as large as they need to be to appropriately abstract their functionality.

Example: shallow functionality, complex interfaces (which is a poor design) Look at the Java SDK for the consequences of class "explosion," where each class adds an almost trivial amount of functionality. The class hierarchy is far too broad and deep, and requires a huge amount of effort to figure out.

e.g., Java File libraries. You need multiple classes to open a file with buffering, with serialization. The common case requires three classes (!). See Kotlin, which wraps all of this in a single class.

Example: deep functionality, simple interface (which is a fantastic design!) Unix File I/O handle the same problem much more simply and elegantly.

int open(const char* path, int flags, mode_t permissions);
int close(int fd);
ssize_t read(int fd, void* buffer, size_t count);
ssize_t write(int fd, const void* buffer, size_t count);
off_t lseek(int fd, off_t offset, int referencePosition);

This abstracts: on-disk representation, disk block allocation, directory management, permissions, caching, device independence.

# Avoid Side Effects

At a high-level, functional programming emphasizes a number of principles:

  • Functions as first-class citizens
  • Pure functions, with no side effects
  • Favouring immutable data

Although Kotlin is not a "pure functional language" (however, you care to define that), we can certainly adopt some of these principles.

The easiest, and perhaps the most important to program stability is the idea of avoiding side effects in your code. What is a side effect? It is an unintended change to your program's state that is not expected when you call a function. In other words, it's the characteristic of a function that does more than it should.

An obvious example of this is the use of global variables, when are then mutated in a function. As your program grows, you may find more and more functions that update these variables, to the point where it is often challenging to determine what caused them to change! Side effects make your code brittle, more difficult to debug and much more difficult to scale.

The opposite are functions that do just what they are designed to do. A function should:

  • Use parameters as its only inputs (i.e. don't look to any external state when determining a function's behaviour)
  • Mutate that data and return consistent results based solely on the input.

We'll discuss functional approaches in Kotlin a little later in these notes.

# Last Word

XKCD The General Problem
XKCD The General Problem