-
Notifications
You must be signed in to change notification settings - Fork 57
Design Principles
🖥️ Slides
📖 Required Reading: None
Software design is the process of defining, architecting, and creating an application. The primary goal of any application is to satisfy a customer's requirements. With a firm focus on the customer, you then apply the principles of good software design to identify the important actors, objects, and interactions necessary to represent the application's domain. This naturally leads to a code architecture that is easy to understand, debug, enhance, and maintain as requirements change.
As you seek to design software you should focus on the following high level goals:
- It does what the customer wants it to do
- It is easy to understand, debug, and maintain
- It is extensible to requirement changes
Using these goals we can discuss the methods that commonly lead to successful software designs.
In order to build an application that a customer wants you need to understand the domain that the customer lives in. This helps you to properly define the application in terms that the customer understands. This approach is often referred to as Domain Driven Design.
As software engineers, it is tempting to focus on computer science algorithms and data structures instead of the objects and actors that a user is familiar with. With Domain Driven Design you reverse the thought process and instead think of the following:
- Who are the actors in the system?
- What tasks do the actors want to accomplish?
- What are the objects that the actors use?
- What are the interactions between actors and objects that are necessary to complete the tasks?
Once you have the actors, tasks, objects, and interactions defined you can then think about the data structures, devices, and protocols that will best support the domain. Basically you think about retail stores, employees, SKUs, and credit cards before you worry about hashmaps, protocols, tables, and networks.
Be careful to consider all of your users, not just your target customers. Oftentimes internal corporate, or governmental, customers are just as important. That means you need to consider security, regulatory restrictions, data privacy, administration, reporting, and metrics as primary pieces of the domain design.
Sometimes it is helpful to assign personas to your primary actors and have a role play conversation with them. Creating a persona that gives a name and backstory to an actor allows you to walk through a story with them to validate the assumptions of your design. It changes the conversation from a shallow statement like:
"A user buys a car"
to something closer to the reality of the user's domain:
"Perry is a student from rural Utah who is short on cash. He needs to buy a car so that he can get to his part time job. He is willing to spend a lot of time finding and negotiating the best deal possible. However, he finds interacting with sales people intimidating and would prefer an automated process. He is going to need to finance his car with a cosigner on the loan."
Being thoughtful about the background of your customer will make it easier to avoid incorrect assumptions in your design. The more real the persona becomes, the better the result will be. In the end, intentional introspection of this type will save you time because your earlier design iterations will be closer to what the customer wants.
Before you dig into the details of your design you want to create a couple of diagrams that capture the vision of what you are building. This is not meant to be an exhausting diagram, but it should make it so your team has a common vision of the most important pieces of the application architecture. It should represent both the high level UI pieces and the major components of the underlying application.
The following is an example of a top level design diagram for the Chess application.

It is important to realize that the complexity of software increases exponentially with the size of the application and the team working on it. One method for dealing with increasing complexity is to execute a series of simplified iterations. Each iteration becomes a deliverable by itself in a journey towards a larger goal. With the understanding that you are going to take an iterative approach to your design you then break each iteration into three distinct steps. First consider the design for some foundational piece of the application. For example, start with a nonfunctional client that displays hardcoded placeholders. Next, you build a minimal implementation that satisfies the design. Finally, you verify that your iteration satisfies the design by examining the test coverage, and soliciting user feedback that the implementation of the design is correct. You then repeat the process.

Using an iterative design is important because it will break the application down into manageable pieces, incrementally introduce complexity, and allow you to correct bad design decisions early in the process.
The size of your iteration will depend on the size of your team and the complexity of the project, but work that can be completed in one to two weeks is a common measure. Iterating for more than four weeks will often lead to wasted or inefficient efforts.
In order to understand the world we use abstraction. When we see a person, we don't see organs and DNA. When we think of a university, we don't think about databases of scholastic records, cleaning crews, pipes, and department budgets. Likewise, when we think of a software application, we don't consider all of the layers of complexity that make the application work. We abstract away many layers of detail and instead focus on the pieces necessary to complete our current task. Without abstracting away things like the hardware, operating system, application interface, threads, user interface, rendering engine, network communication, persistent storage, and memory we would never be able to keep even the simplest of programs in our heads.
When we create abstractions in our applications we begin by defining abstractions that represent real world objects. We call these the objects of our application domain. For example, a bank, customer, account, and loan. We then add an additional level of abstraction to represent the data structures and algorithms necessary to support the domain objects. For example, database schemas, network protocols, hash tables, and events.
In object oriented programming Interfaces and Objects are used to provide the bulk of abstraction.
Objects abstract details by differentiating between private and public methods. Public methods can be accessed by other objects. Private methods can only be accessed by the object that defines them.
classDiagram
class car {
- private engine
- private driveTrain
- private suspension
+ public gasPedal()
+ public steeringWheel()
}
An interface is a public description of functionality that provides no implementation. Think of it as a description without the ability to actually do anything. The interface description hides how the actual work is done. To use an interface, an object must first implement the interfaces definition. However, you can refer to the implementing object by any interface that the object implements. This hides not only how the functionality is implement, but who is implementing it.
classDiagram
class vehicle {
<<interface>>
+ public gasPedal()
+ public steeringWheel()
}
class electricCar {
- electricEngine
}
class dieselTruck {
- dieselEngine
}
electricCar --|> vehicle
dieselTruck --|> vehicle
Whenever you program you should try and abstract things into the following parts.
- What are the input interfaces
- What are the output interfaces
- What interface does the my abstraction need to provide
- What class will implement the interface
Note that sometimes it is not necessary to create an interface when a single class representation can simply expose public methods and abstract away the details. Interfaces are useful when there are multiple different algorithms that can be used to satisfy the interface, or when there are classes that implement multiple interfaces.
The important thing to remember about abstraction is that you hide all implementation details of domain and system objects until those details are required. Think of everything on a purely "need to know" basis. This makes the current system easier to understand and allows for enhancement in the future.
Some of the benefits of abstraction include:
- Comprehension - Less details makes it easier to understand how the objects interact and form a complete mental model.
- Extensibility - When we are not aggressive with exposing details, we can expose those details later, or we can expose new operations that might have conflicted with previously exposed operations that were unnecessary at the time.
- Evolution - Hiding how the object gets things done means that you can change the implementation without changing anything that depends on the object.
- Security - Anything that is hidden by an object is less likely to be subject to attack through the object's interface.
One common mistake with abstraction is to think that it only applies to the public methods that you include in a class. You can also provide data hiding by implementing interfaces that restrict the view of what an object can do to a small set of methods. For example, you might have a class that represents a person. In order to provide abstraction of the class, the person might represent an Object, LivingEntity and Animal interface. By exposing different aspects of the person, the consumer of the object only needs to know about the aspect that is of interest to them. This provides all of the benefits of comprehension, extensibility, evolution, and security.
Encapsulation is a form of abstraction that takes an object that provides some functionality and encapsulates, or hides it, in another object. For example, a car encapsulates an engine, drive train, and suspension. The driver of the car does not need to know any of those details because the driver never interfaces with those components.
classDiagram
class car {
- private Engine
- private DriveTrain
- private Suspension
+ public gasPedal()
+ public steeringWheel()
}
However, the driver does need to be able to accelerate the car by pressing on the gas pedal which interfaces with the engine and drive train, but the car only exposes the gas pedal, not the engine or other encapsulated objects.
Inheritance is another form of abstraction where one object can inherit the functionality of another object without knowing the details of how the parent object provides the functionality.
For example, a Car could inherit a WheeledVehicle object that provides the wheels and suspension. The wheeledVehicle could inherit a Vehicle object that provides a place for passengers to sit.
classDiagram
car --|> wheeledVehicle
wheeledVehicle --|> Vehicle
When you are creating your classes you need to carefully consider the different meanings and implications of using inheritance instead of encapsulation. However, by favoring encapsulation you can create composable objects that have the benefits of multiple inheritance without all of the complexity that multiple inheritance incurs. Encapsulated objects can demonstrate polymorphic behavior by exposing interfaces that are implemented by the contained objects. As long as interfaces are used to access the encapsulation, the containing class can replace the encapsulated objects without impacting any users of the objects. In short, when combined with interfaces, encapsulation can provide:
-
has-aandis-arelationships - Benefits of multiple inheritance without the complexity
- Decreased coupling
- Better hiding of details
- Increased interface segregation
This suggests that in many cases Encapsulation should be preferred over inheritance.
The basic idea of decomposition is to create abstractions that represent layers of generalization and specialization. Each layer has a specific task to do and it accomplishes it by using the layers beneath. The idea is that you start at the top with a very general representation. For example, a chess game. You then decompose, or factor out, each layer of the higher level into increasingly specialized pieces. For example, a game is made up of participants, pieces, and a board. This process continues until the smallest necessary level of decomposition is completed. Continuing our example, this could include decomposing participants into players and observers, pieces into piece types, and a board into squares.
The advantage of decomposition is that you only need to think about the details of the layer when you are actually working on it. This includes defining its interfaces, implementing the details, and writing tests for that layer. For example, when defining the Participant layer, you only need to think about how a participant interacts with the Game and is represented by a Player or Observer. At the player level, you don't need to worry about what a Board is comprised of, or what the rules for moving a King are.
classDiagram
Game *-- Board
Game *-- Piece
Game *-- Participant
Board *-- Square
Participant <|-- Player
Participant <|-- Observer
Piece <|-- King
Piece <|-- Rook
Piece <|-- Pawn
Programming languages themselves utilize decomposition to represent different parts of a program. When using Java we use the following decompositions:
| Decomposition | Purpose |
|---|---|
| Application | The top level that we hand to the operating system for execution |
| Jar | A zip file containing packages of classes |
| Package | A directory of classes |
| Class | A domain or system object |
| Method | An action of an object |
| Expression | Logic of a method |
Using decomposition at the program level helps you so that you don't have to keep the whole code base on your screen at the same time. You just need to open the files that represent the current task.
High cohesion means that an object only represents highly related data and functionality. You don't include tangentially related methods or fields in an object. Instead you create a cohesive object that executes in concert with other related objects.
Low coupling means that objects do not strongly rely on each other. High coupling occurs when an object that cannot be used without understanding the specific implementation details of another object, or when two objects require each other to operate. Generally, low coupling means that you are using interfaces appropriately and that objects do not have bidirectional bindings.
Simplicity is a core design principle. When given a choice between the simple and the complex always choose the simple. Dropping functionality is often preferable to introducing complexity that will make the system less usable and maintainable.
Simplicity is such an important principle that it is easy to find quotes from every thought leader on the subject.
Controlling complexity is the essence of computer programming.
— Brian Kernighan
Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away.
— Antoine de Saint-Exupéry
Any fool can write code that a computer can understand. Good programmers write code that humans can understand.
— Martin Fowler
When complexity cannot be avoided then make it more manageable by using decomposition to break the complexity into simpler parts, or by using abstraction to hide the complexity until it can be replaced by a simpler solution.
Objects that do not change after they are constructed are referred to as immutable. In order to understand that value of immutability, consider the String class. If String was not immutable then you would never be sure you still had the same string value after a sub method was called. The following example demonstrates an unintentional side effect of calling an imaginary operation named String.setText.
void printList(){
String prefix = "- "
var items = list.of("a", "b", "c");
for (var item : items) {
printWithPrefix(prefix, item);
}
}
void printWithPrefix(String prefix, String text) {
prefix.setText(prefix + text);
System.out.println(prefix);
}
// Output:
// - a
// - a- b
// - a- b- cIn reality, because String is immutable, you never have to worry about its value being changed and you can safely pass it to any function.
Immutability also guarantees thread safe code because it eliminates the possibility that one thread can be modifying an object at the same time a different thread is reading it.
If your code contains multiple copies of the same code then it is violating the Do not repeat yourself, or DRY, principle. Code duplication creates maintenance problems when you want to alter the code, increase the impact of errors, and makes it more difficult to correct the problems. It also makes the code unnecessarily complex because the reader has to read the same blocks over and over again to make sure they don't contain subtle variations.
You can reduce duplicated code by:
- Using inheritance and encapsulation to represent a single version of the functionality.
- Using utility methods for common operations.
- Using generics to represent objects that only differ by type.
The SOLID principles of clean code were promoted by a popular software design consultant named Robert Martin (AKA Uncle Bob).

source: SmarterMSP.com
“Truth can only be found in one place: the code.”
— Robert Martin
SOLID represent five key principles.
- Single Responsibility - An actor has only one reason to use you
- Open Closed - Open for extension, closed for modification
- Liskov Substitution - Actually implement the interface
- Interface Segregation - Keep interfaces cohesive
- Dependency Inversion - Make dependencies parameters
Let's look at each of these in detail.
The Single Responsibility Principle represents the desirability of high cohesion. The idea here is that an actor only has one reason to use an object. You don't have a Person class that represents everything associated with a person. You have a Person class that represents the distinct attributes of a person such as name and birthDate, and then you have other classes that represent things associated with a Person.
classDiagram
Person <-- FoodConsumption : uses-a
Person <-- OutdoorActivity : uses-a
Death --* Person : has-a
Birth --* Person : has-a
class Person{
name
birth
death
}
class FoodConsumption {
eat(Person, Meal)
}
class OutdoorActivity {
play(Person, Game)
}
Date <|-- Death
Date <|-- Birth
OutdoorActivity --> Game : uses-a
class Death {
}
class Birth {
}
Following the single responsibility principle makes it so there is only one reason to manipulate the class. You manipulate the Person class to represent the person and the Death class to represent a death. If you find yourself making a FrankenObject that represents multiple objects, or responsibilities, then you should consider refactoring your code into multiple classes.
The Java String class is a frequently cited example of violating the single responsibility principle as it not only represents an immutable string but provides operations for manipulating and converting the string. This makes the String class both a data container and a data mutator.
Classes are not the only places where you need to consider the single responsibility principle. Methods and variables can also fall prey to confusing and conflicting responsibilities. For example, the following method has been overloaded with multiple responsibilities and interpret the parameters and return value in contradictory ways.
If you find yourself changing a class for different reasons, functionality vs representation vs mutation vs display vs persistence, then you are probably in violation of the single responsibility principle.
public interface FrankenPerson {
public void drive();
public void sleep();
public void eat();
public void work();
public void die();
public void play();
public void setAlarm();
public void planRoute();
public void shopForFood();
public void buyGymPass();
}public interface SRPViolation {
/**
* i < 0 delete the key and the empty string if successful
* i == 0 return the old value if different
* i > 0 replace the value and return the old value
*/
public String dbAction(String key, String value, int i);
}Classes should be open to extension and closed for modification. The core concept is that you should generalize the functionality of a class so that you don't have to internally modify it in order to provide a desired extension of its functionality.
A common example for the open closed principle involves passing in interfaces that control how the class works. This is in contrast to modifying the classes methods to provide new functionality.
As an example, the following code forces you to create a new method for every different type of format that you want the class to support. Additionally, the class has a constructor that represents a specific type of data. If you want to provide a different type of data, you must modify the class to include an additional constructor and internal data type.
public static class OpenForModificationList {
final private String[] items;
public OpenForModificationList(String[] items) {
this.items = items;
}
public String formatCommaSeparated() {
return String.join(",", items);
}
public String formatQuotedCommaSeparated() {
var formattedItems = new ArrayList<String>();
for (var item : items) {
formattedItems.add(String.format("'%s'", item));
}
return String.join(",", formattedItems);
}
}We can improve the previous code by using interface parameters and Java generics to open the class to extension without ever modifying the code.
public interface Formatter<T> {
String format(T s);
}
public static class OpenForExtensionList<T> {
final private List<T> items;
public OpenForExtensionList(List<T> items) {
this.items = items;
}
public String format(Formatter formatter, String separator) {
var formattedItems = new ArrayList<String>();
for (var item : items) {
formattedItems.add(formatter.format(item));
}
return String.join(separator, formattedItems);
}
}In this example the Formatter interface extends how the class formats and the generic type extends the supported types.
Dependency inversion and inheritance are both examples of the open closed principle.

source: SmarterMSP.com
“[be] aware not just of what you understand, but also what you don’t understand”
— Barbara Liskov
If an operation is dependent on an interface, or base class, you must be able to substitute any derived class without altering the operation. This can happen if a base class throws an UnsupportedException for an interface or overridden method, or if the operation does a type cast on the interface.
public class LSPExample extends Object {
public int hashCode() {
throw new UnsupportedOperationException();
}
}void lspViolation2(List list) {
var arrayList = (ArrayList)list;
}Violations of this principle cause unexpected behaviors within the application and require the developer to understand all of the code before they can safely make substitutions.
When you define an interface you only include methods that work together as a cohesive whole. You don't add methods that are related, but not necessary for the consumption of the primary usage of the interface. Put another way, the interface segregation principle states that that no consumer of an interface should be forced to depend on methods it does not use.
Exposing methods to all consumers of the interface, without regard for the use of the methods by all the consumers, creates a significant maintenance problem. If you want to alter the interface then you must examine all uses of the interface. Instead, the preferred approach is to create multiple interfaces that an object uses and only use the interface that is appropriate to the consumer.
public interface ReaderWriter {
byte readByte();
String readString();
int readInt();
// Outside cohesive whole.
void writeByte(byte b);
void writeString(String s);
void writeInt(int i);
}public interface Reader {
byte readByte();
String readString();
int readInt();
}
public interface Writer {
void writeByte(byte b);
void writeString(String s);
void writeInt(int i);
}The dependency inversion principle suggests that low level objects should not explicitly depend on high level objects. Instead of a low level object creating and using a high level object, you should provide the high level object to the low level object. Interfaces enable the core abstraction necessary to enable dependency inversion.At the very least you are exposing a specific implementation, constructor, and potentially extraneous methods that are unnecessary to the use of higher level object.
Put another way, the principle says that dependencies are made on aspects of functionality, not on implementations of the functionality. In the following example, the low level Route object is highly coupled with the instantiation and use of the high level Honda object.
class Violation {
public static void main(String[] args) {
new Route().drive();
}
static class Route {
void drive() {
Honda honda = new Honda();
honda.go();
}
}
static class Honda {
void go() {
System.out.println("bruum");
}
}
}In order to properly apply the dependency inversion principle you invert the use of high level object through an interface parameter. In the following example we use a factory method that uses reflection to load the desired high level object. Now the Route doesn't know anything about the vehicle that is being used. It just calls go. This breaks the coupling between the objects and moves the decision about what vehicle is actually used to be completely out of the code.
class Correct {
interface Vehicle {
void go();
}
public static void main(String[] args) throws Exception {
var vehicleMakerClass = args.length == 1 ? args[0] : "Honda";
Vehicle vehicle = createVehicle(vehicleMakerClass);
new Route().drive(vehicle);
}
static class Route {
void drive(Vehicle vehicle) {
vehicle.go();
}
}
static Vehicle createVehicle(String vehicleMakerClass) throws Exception {
var vehicleClass = Class.forName("Correct$" + vehicleMakerClass);
var vehicleConstructor = (Constructor<Vehicle>) vehicleClass.getDeclaredConstructor();
return vehicleConstructor.newInstance();
}
static class Honda implements Vehicle {
public void go() {
System.out.println("bruuum");
}
}
static class BMW implements Vehicle {
public void go() {
System.out.println("vroom");
}
}
}By inverting the dependencies, you can decouple the code and move the commitment to an algorithm at a higher level. Now you can execute the code with different parameters and completely modify how it works.
- The goals of software design
- Design is an iterative process
- Abstraction
- Single Responsibility Principle
- Decomposition
- Good algorithm and data structure selection
- Encapsulation - Information hiding
- DRY - Avoiding code duplication
- 🎥 Design Principles - Introduction (2:27) - [transcript]
- 🎥 Design Principles - Design Is Inherently Iterative (3:12) - [transcript]
- 🎥 Design Principles - Abstraction (8:29) - [transcript]
- 🎥 Design Principles - Good Naming (4:24) - [transcript]
- 🎥 Design Principles - Single Responsibility Principle (2:44) - [transcript]
- 🎥 Design Principles - Decomposition (5:25) - [transcript]
- 🎥 Design Principles - Good Algorithm and Data Structure Selection (2:25) - [transcript]
- 🎥 Design Principles - Low Coupling (9:37) - [transcript]
- 🎥 Design Principles - Avoid Code Duplication (3:34) - [transcript]