Everything You Need To Know About Software Design In Ten Minutes

Software design is hard to talk about. We often rely on metaphors from structural engineering and other disciplines to describe this process. We might use terms like “architecture” to describe the components of our system, how they fit together, and why we put them together that way.

In general, we can say that software design is the process of putting together working systems with repeatable patterns in a way that results in a flexible system.

Software Is Flexible

“Architecture” is actually a terrible name for what we mean when we talk about our process of designing software. The word “architecture” brings to mind building blueprints worked out in great detail with concern for both functionality and aesthetics.

Early attempts at designing software fell prey to this illusion. We spent decades trying to write detailed architecture specifications for software projects. The Agile movement restored sanity by reminding us that software needs to change and the design process must reflect that.

In reality, real working software is almost never planned out in detail ahead of time. We don’t know the full requirements in perfect detail on day one. At best, we have a general outline. So blueprints are out the window.

Software must be malleable and adapt to new, unknown requirements. So it’s very important that our system remain flexible. Let’s look at how we can ensure that.

Architecture by Design Principles

Instead of blueprints, we have design principles that define how components of our system should be structured to ensure that our system remains flexible. These aren’t hard-and-fast rules, but rather concepts that we need to apply judiciously.

Some design principles you may have heard of include the Single Responsibility Principle. This is a simple enough edict that tells us the components in our system should do one thing, and do it well. We should not have a class that handles both HTTP requests and database calls, for example.

I could bullet point a list of design principles, but I think it’s more useful to understand some broader concepts first. Namely, we need to look at the foundations of object oriented design.

What Is Object Orientation?

Quite simply, object orientation means that we try to model the world of our system in memory (specifically, on the heap). An object is simply a set of data and a set of functions to operate on that data.

Objects are a powerful conceptual framework for designing software that utilize metaphor to design programs that are easy to develop and evolve.

Object Oriented Design By Example

Let’s say we’re writing a program that simulates patrons of a restaurant ordering their favorite foods from the restaurant’s menu. This is a silly example, but bear with me.

We create a couple classes to model these different objects in our system. We have a Customer class to represent a customer. We create a class for each food item as well. RoastBeefSandwich, FrenchFries, Hamburger, TunaMelt. And so forth. (I apologize in advance to those reading this article right before lunch.)

Now let’s go and write a function that models a customer eating one of our delicious food items.

def eatRoastBeef(customer: Customer, rbs: RoastBeefSandwich) =  {
// Do something
}

Seems reasonable enough. But there are a couple issues with this approach right off the bat.

One problem is that we’re going to have to write a new function for every item in our menu. After we write this one, we’ll have to go and write eatHamburger, eatTunaMelt, and so on. That's a bummer, because the eating process is pretty similar across different types of foods.

Another problem is that a programmer who is looking for this function might not know where to find it. In other words, we might have a Customer and a Hamburger and no idea how to put these two together. It would be nice if we could attach the function to these objects somehow.

Let’s tackle these two problems one at a time.

Liskov Substitution

So we’ve identified that there is, in fact, a commonality between our Hamburger, RoastBeefSandwich, and TunaMelt. In fact, you can probably imagine a litany of other object types that follow this pattern. Let's call this a Food.

Food is a category, not an individual thing. If I asked you what you wanted for lunch and you answered "food," I would have to follow up that question with "what kind of food?" By contrast, a RoastBeefSandwich is a valid answer to that question. In object oriented design terms we say that a RoastBeefSandwich is concrete and a Food is abstract. It's also common to call an abstract class an interface.

The pertinent difference between an abstract class and a concrete class is that we can’t create an instance of an abstract class (or an interface). To do so would be meaningless. It’s the compiler equivalent of saying you want to eat “food” for lunch.

Now we can start to imagine a generic eat method that doesn't care about the details of the food item in consideration. There's just one catch. Every sub-class of Food must take the same arguments and return the same type. As long as that contract is upheld, we can safely call our eat method with any kind of Food.

We’ve now described the basics of a technique called Liskov substitution. It’s a simple idea that is key to making maintainable software.

Encapsulation

Now for our second problem. This eatRoastBeef method is just kind of floating in space. We have to remember to use it when we want to make a customer eat a sandwich. Worse still, how do we know this function is the right one?

This is the equivalent of a carpenter going to a work site with the nails in a separate toolbox from the hammer. We want our tools to be close at hand when we’re working with an object. We don’t want to accidentally reach for a drill when we want a hammer.

Almost everyone with any programming background is familiar with the resulting design. Instead of having our eat method exist in a separate place, we instead make it a method of the Customer class.

class Customer {
def eat(food: Food)
}
someCustomer = new Customer()
rbs = new RoastBeefSandwich()
someCustomer.eat(rbs)

Now we have this Customer class and there's an eat(food: Food) method on it. We're now in a position where we create the generic definition of eating a food item.

Notice that although we haven’t written any of the implementation, we already know everything we need to know about what the method does without knowing anything about how it works.

We know eat is a way for a Customer to operate on some Food data. And we know it will work with any Food.

In other words, we hide the implementation details behind the interface.

Creating Stable Interfaces

Now let’s say that we want to write a class that simulates eating a whole meal. We’ll call it Meal, appropriately enough. We're going to have a Customer and some Food items in our Meal. It might look something like this:

class Meal {
customer: Customer
foods: Set[Food]

def eatMeal(customer Customer, foods: Set[Food]) = {
for (food in foods) {
customer.eat(food)
}
}
}

Notice that the structure of our program is starting to make more sense. Just based on having that Food class and putting the eat method on Customer, we can start to imagine a whole world of objects in our system: Meals, CustomerOrders, and so on. We can do this with confidence because we know from Liskov substitution that any Food will be compatible with any Customerno matter what concrete Food we actually have.

One way to express this is to say that the eatMeal method provides a stable interface between Customer and Food.

When To Not Choose Stability

Sometimes stability isn’t what we want, though. If we had a special method on the Hamburger class called withPickles we probably wouldn't want this to be part of every Food class. So we leave that one method on just the Hamburgerclass, like so:

abstract class Food {
def calorieCount() // We may define some default behavior here
}
class Hamburger(pickles = false) extends Food { override def calorieCount() = {
1000
}

def withPickles() = {
new Hamburger(pickles = true)
}
}

We don’t want users of the Food class to build their program around the withPickles method because we know it's not something applicable to every Food. We don't want to have someone write a component of the system with the assumption that withPickles is going to be there for every Food and then have to go remove it later.

In general, we want our methods to be as concrete as possible. We don’t want to make a method abstract just because we can.

The reason is simple. When we’re programming with interfaces using Liskov substitution, a stable interface signals to users of the class that these methods are not likely to change very much. We know that we should build the foundations of our program using those stable methods on the abstract class (a.k.a. interface) where possible.

Putting It All Together

By now we have a couple powerful tools in our toolbelt:

  • Abstract classes that allow us to create useful conceptual abstractions and define operations a class supports without saying anything about how it works.
  • Liskov substitution which allows us to define stable interfaces between components, confident that our very smart modern compiler will properly match the abstract with the concrete at runtime.
  • A notion of when to define stable interfaces. Sometimes we don’t want stability, e.g. when we don’t want other system components to be built around a particular operation.

Combining these tools together, we can design vastly complex software systems with expressive abstractions and still maintain the flexibility we need to evolve our system as it changes.

For example, what if we come up with a new amazing implementation of the eat method? How much work would be required by our engineers to update every use of the eat method in our entire codebase? Well, if we've applied these concepts correctly, it will only require updating the eat method itself.

I contend, dear reader, that these concepts alone are enough for 98% of your software design needs. There are lots of pattern books and design philosophies and techniques like dependency injection that offer different ways of applying these concepts. But conceptually, this is all you need to know.

What Happens If We Mess Up?

Inevitably, we will make some kind of mistake in the design process. We might have a poor abstraction that makes it difficult to work with some class. Or we might forget to put a stable interface in for a common method call and suddenly our system has become verbose and repetitive.

In these cases, it’s sometimes worthwhile doing what we call refactoring. Refactoring is simply a process of fixing design problems. This subject deserves its own article, or even a book.

We’re always better off if we get the design right the first time. But the reality is that no system is perfect and design problems will creep in. Refactoring is always an option, and often it’s worth commiting the time to refactor problem areas if they’re creating a lot of extra work for the engineers.

Conclusion

Software is nothing but abstractions upon abstractions. The process of designing software is simply deciding what those abstractions should be.

Of course, doing it correctly takes a degree of practice. Wise programmers will always look for ways to make the system easier to work with by fixing design problems. In the end, these small improvements in the design of the system will make future changes cheaper to implement, having potentially enormous impact on the productivity of the team.

The truth is that programs are subject to the forces of entropy. As we have change requests come in for the system, we make small alterations to the design of the program. Over time, these changes can erode the abstractions in our system, so that eventually it may become too expensive to work with anymore.

Our goal is to avoid that fate. We want the systems we design to last as long as possible without needing to be rewritten. Using abstractions wisely is key to accomplishing this goal.

Robert is a writer and software engineer. robert@rquinlivan.net - www.rquinlivan.net

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store