CS 346 (W23)
Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage

Software Design

The term “software design” is heavily overloaded, with many different interpretations of what it entails.

  • A UX designer will treat design as the process of working with users to identify requirements, and iterating on the interaction and experience design with them to fine tune how they want the experience to work.
  • A software engineer will want to consider ways of designing modules and source code that emphasize desireable characteristics like scalability, reliability and performance.
  • A software developer may want to consider readability of the code, and compatibility with existing code bases (among other things).

In this course, we’ll treat design as the complete set of low-level implementation decisions that are made prior to coding a system. We’ll discuss some different approaches to design that have been impactful and useful.

Features of Good Design

It doesn’t take a huge amount of knowledge and skill to get a program working. Kids in high school do it all the time… The code they produce may not be pretty; but it works. It works because getting something to work once just isn’t that hard.

Getting software right is hard. When software is done right, it requires a fraction of the human resources to create and maintain. Changes are simple and rapid. Defects are few and far between. Effort is minimized, and functionality and flexibility are maximized.

– Bob Martin, Clean Architecture (2016).

One recurring theme keep cropping up: the notion that software should be enduring. Software that you produce should be able to function for a long period of time, in a changing environment, where adjustments will need to be made over time; defects will be found and fixed; new features will be introduced and old features phased out.

As “Uncle Bob” points out, It’s relatively easy to get something to compile and work once, in a restricted environment; it’s much more difficult to build something that can be extended and modified over time. If you want software that can be useful for a long time, you need to design for that as well.

Of course, first and foremost, we want to design software that performs its intended function, but we also want robust software that is a joy to extend and maintain. Let’s talk about the characteristics of “good” software that support this approach.

Code Reuse

Software is expensive and time-consuming to produce, so anything that reduces cost or time is welcome. Reusability, or code reuse is often positioned as the easiest way to accomplish this. It also reduces defects, since you’re presumably reusing code that is tested and known-good.

“I see three levels of reuse.

At the lowest level, you reuse classes: class libraries, containers, maybe some class “teams” like container/iterator.

Frameworks are at the highest level. They really try to distill your design decisions. They identify the key abstractions for solving a problem, represent them by classes and define relationships between them. JUnit is a small framework, for example. It is the “Hello, world” of frameworks. It has Test, TestCase, TestSuite and relationships defined.

A framework is typically larger-grained than just a single class. Also, you hook into frameworks by subclassing somewhere. They use the so-called Hollywood principle of “don’t call us, we’ll call you.” The framework lets you define your custom behavior, and it will call you when it’s your turn to do something. Same with JUnit, right? It calls you when it wants to execute a test for you, but the rest happens in the framework.

There also is a middle level. This is where I see patterns. Design patterns are both smaller and more abstract than frameworks. They’re really a description about how a couple of classes can relate to and interact with ”

– Shvets citing Erich Gamma: https://refactoring.guru/gamma-interview.

One of the reasons that we like design patterns is that they’re a different type of reuse: instead of reusing the software directly, we’re reusing designs in a way that results in better code. We’ll discuss these in detail below.

Extensibility

Extensibility implies the ability to modify your code, to expand existing features or add new features. e.g. an image editor adding support for a new image type; a plain text editor adding support for code fences and syntax highlighting. Conditions will change over the lifetime of your software, and you need to design in a way that allows you to respond to changes.

In the sections below, we will discuss different approaches to handling these challenges.

Readability

We’re programmers. Programmers are, in their hearts, architects, and the first thing they want to do when they get to a site is to bulldoze the place flat and build something grand. We’re not excited by incremental renovation: tinkering, improving, planting flower beds.

There’s a subtle reason that programmers always want to throw away the code and start over. The reason is that they think the old code is a mess. And here is the interesting observation: they are probably wrong. The reason that they think the old code is a mess is because of a cardinal, fundamental law of programming:

It’s harder to read code than to write it. —Joel Spolsky, Things You Should Never Do, Part I (2000)

It’s very likely that the software that you write will need to be read by someone else: your teammates, the people that follow you on a project, maybe even hundreds or thousands of other developers if you relase it publically.

For that reason, it’s not enough to have code that works; it should work, and be clear and understandable to other people that will need to read it. Keep in mind that the “other people” may include future-you. Will your code still make sense if you have to come back to it a year from now? Five years from now? Code comments (that describe why you made your design decisions), and consistent code structure go a long way to making code readable.

Design Principles

What is good soft­ware design? How would you mea­sure it? What prac­tices would you need to fol­low to achieve it? How can you make your archi­tec­ture flex­i­ble, sta­ble and easy to under­stand?

These are the great ques­tions; but, unfor­tu­nate­ly, the answers are dif­fer­ent depend­ing on the type of appli­ca­tion you’re build­ing.

– Shvets, Dive Into Design Patterns (2020).

We do have some universal principles that we can apply to any situation.

Encapsulate What Varies

Iden­ti­fy the aspects of your appli­ca­tion that vary and sep­a­rate them from what stays the same.

The main goal of this prin­ci­ple is to min­i­mize 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
  foreach 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.

Monolithic order class

Restructured classes.

Destructured order

Program to an Interface, Not an Implementation

Pro­gram to an inter­face, not an imple­men­ta­tion. Depend on abstrac­tions, not on con­crete classes.

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.

Do do this, you extract an abstract interface, and use that 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

Favor 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 it’s 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.

Modeling vehicles through inheritance

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.

Vehicles modeled through composition