The Last Introduction to Software Design Principles & Patterns You Need

Kenny Wolf
Geek Talk
Published in
12 min readAug 22, 2024

Introduction

Software engineering is a fast-growing and difficult field.

In contrast to most other professions, you have to keep developing your skills even after your training. There are always new technologies coming out and you have to adapt in order not to lag behind the competition. This pace has also taken its toll on me over the last two years.

That’s why I was looking for a way to build and expand my skills as a software developer in the long term.

It’s certainly not the best way, but it has already helped me professionally and is a good first step in the right direction. That would be knowing and implementing software design principles and patterns. An important focus is on implementation, because just knowing them doesn’t help you much.

In this article, I’ll give you the final introduction to design principles and patterns that you need. Because we will not only get to know some of them together, but also implement them.

Main

First of all, we distinguish between the two terms design principles and design patterns.

Design principles are guide lines to achieve an extendable, maintainable, efficient, reliable, reusable, usable decomposition of our system into classes.

Sounds a bit vague right now, but we’ll take a closer look later. And what are design patterns?

A design pattern is an approved and reusable solution template for a specific design problem. Design patterns may be applied in many different situations, where the problem coped by the pattern occurs. Design patterns represent best practices.

If you are new to software development (1–3 years) you should focus on design principles first.

And if you already have these under control, you can then tackle the design patterns. After all, the principles appear again and again in the patterns and lay the foundation for good software.

Design Principles (SOLID)

As the name suggests, design principles are general guidelines that we can use in everyday life.

Above all, they help you to recognise and improve poorly designed code. Improve refers to the long list of adjectives in the definition above. In other words, making your code maintainable, extendable, reusable, etc. (or optimising these properties).

The big 5 principles that are pretty much always taught are the SOLID principles. It’s an acronym.

Let’s take a closer look at them.

Single Responsibility Principle

A class should only have a single responsibility. This keeps the class small and its API concise.

Classes dealing with more than one responsibility may need to be split up. However, there is a danger of too fine granularity. You can use modeling techniques like CRC-Cards to check the size of your class.

Let’s look at an example:

public class Student {
private String name;
private String studentId;

public Student(String name, String studentId) {
this.name = name;
this.studentId = studentId;
}

public String getName() {
return name;
}

public String getStudentId() {
return studentId;
}

public boolean isEligibleForGraduation(int completedCredits) {
return completedCredits >= 120;
}

public void sendNotification(String message) {
System.out.println("Sending notification to " + name + ": " + message);
}
}

The Student class is not that optimal, even though it’s a small class (in order to not write too big examples, I try to keep them short but still valuable). But why is that?

The Student class has multiple responsibilities—managing student details, checking graduation eligibility, and sending notifications.

Better example:

public class Student {
private String name;
private String studentId;

public Student(String name, String studentId) {
this.name = name;
this.studentId = studentId;
}

public String getName() {
return name;
}

public String getStudentId() {
return studentId;
}
}

public class GraduationService {
public boolean isEligibleForGraduation(Student student, int completedCredits) {
return completedCredits >= 120;
}
}

public class NotificationService {
public void sendNotification(Student student, String message) {
System.out.println("Sending notification to " + student.getName() + ": " + message);
}
}

In this example the methods for calculating eligibility for graduation and sending a notification or now split in a separate class. What’s the advantage of this?

  • The Student class is now solely responsible for holding student data.
  • The GraduationService class handles graduation eligibility logic.
  • The NotificationService class is responsible for sending notifications.

This separation makes the code easier to maintain and extend, as each class has a single responsibility.

Open Closed Principle

A system should be open for extension with new functionality but closed for modification.

This means that existing code does not need to be changed just because new functionality is added to the system. This one was a game changer for me because respecting this principle made my applications less prone to errors, since I’m not touching already (productive) code.

Here an example where you can spot the problem:

public class Course {
private String courseName;

public Course(String courseName) {
this.courseName = courseName;
}

public String getCourseName() {
return courseName;
}

public double calculateFinalGrade(String courseType, double midtermScore, double finalExamScore) {
if (courseType.equals("Math")) {
return midtermScore * 0.4 + finalExamScore * 0.6;
} else if (courseType.equals("Science")) {
return midtermScore * 0.5 + finalExamScore * 0.5;
} else {
return midtermScore * 0.3 + finalExamScore * 0.7;
}
}
}

If you need to add a new course type, you must modify the calculateFinalGrade method, which violates the OCP. And every time you add a new course type, you risk introducing bugs into the existing logic.

How we can improve this:

public abstract class Course {
private String courseName;

public Course(String courseName) {
this.courseName = courseName;
}

public String getCourseName() {
return courseName;
}

// Abstract method to calculate the final grade
public abstract double calculateFinalGrade(double midtermScore, double finalExamScore);
}

public class MathCourse extends Course {
public MathCourse(String courseName) {
super(courseName);
}

@Override
public double calculateFinalGrade(double midtermScore, double finalExamScore) {
return midtermScore * 0.4 + finalExamScore * 0.6;
}
}

public class ScienceCourse extends Course {
public ScienceCourse(String courseName) {
super(courseName);
}

@Override
public double calculateFinalGrade(double midtermScore, double finalExamScore) {
return midtermScore * 0.5 + finalExamScore * 0.5;
}
}

// Adding a new course type without modifying existing code
public class HistoryCourse extends Course {
public HistoryCourse(String courseName) {
super(courseName);
}

@Override
public double calculateFinalGrade(double midtermScore, double finalExamScore) {
return midtermScore * 0.3 + finalExamScore * 0.7;
}
}

You can add new course types (like HistoryCourse) by creating new classes that extend the Course abstract class without modifying the existing code. The Course class and its existing subclasses remain unchanged, making the system more stable and less prone to bugs.

Liskov Substitution Principle

Wherever an object of a superclass is expected it must be possible to provide an object of a subclass without any change to the system.

In other words, a subclass should behave in such a way that it can substitute its parent class without altering the desired functionality.

This is a tricky one to spot sometimes:

public class Course {
private String courseName;

public Course(String courseName) {
this.courseName = courseName;
}

public String getCourseName() {
return courseName;
}

public int getCourseDuration() {
return 12; // Default course duration is 12 weeks
}
}

public class OnlineCourse extends Course {
public OnlineCourse(String courseName) {
super(courseName);
}

// Violates LSP by returning a different type or unexpected result
@Override
public int getCourseDuration() {
throw new UnsupportedOperationException("Online courses do not have a fixed duration");
}
}

The OnlineCourse class violates LSP because it changes the behavior of the getCourseDuration method in a way that clients of the Course class might not expect. The base class Course promises that getCourseDuration will return an integer, but the subclass OnlineCourse breaks this contract by throwing an exception instead, leading to potential runtime errors.

Following LSP:

public class Course {
private String courseName;

public Course(String courseName) {
this.courseName = courseName;
}

public String getCourseName() {
return courseName;
}

public int getCourseDuration() {
return 12; // Default course duration is 12 weeks
}
}

public class OnlineCourse extends Course {
private int durationInWeeks;

public OnlineCourse(String courseName, int durationInWeeks) {
super(courseName);
this.durationInWeeks = durationInWeeks;
}

@Override
public int getCourseDuration() {
return durationInWeeks; // Online courses have a flexible duration
}
}

The OnlineCourse class correctly overrides getCourseDuration without changing the expected behavior.

It still returns an integer representing the course duration, fulfilling the contract set by the base class. You can substitute an OnlineCourse object wherever a Course object is expected, without causing unexpected behavior or errors.

Interface Segregation Principle

Clients should not depend on methods they don’t use.

Therefore the API of a class should be split up into several independent interfaces. This applies even for classes that follow the single responsibility principle. They still may have many methods, which are not interesting for certain clients.

public interface UniversityMember {
void attendLecture();
void submitAssignment();
void conductResearch();
void gradeAssignments();
}

public class Student implements UniversityMember {
@Override
public void attendLecture() {
System.out.println("Student is attending a lecture.");
}

@Override
public void submitAssignment() {
System.out.println("Student is submitting an assignment.");
}

// Unused methods
@Override
public void conductResearch() {
// Not applicable for students
throw new UnsupportedOperationException("Students do not conduct research.");
}

@Override
public void gradeAssignments() {
// Not applicable for students
throw new UnsupportedOperationException("Students do not grade assignments.");
}
}

public class Professor implements UniversityMember {
@Override
public void attendLecture() {
System.out.println("Professor is attending a lecture.");
}

@Override
public void submitAssignment() {
// Not applicable for professors
throw new UnsupportedOperationException("Professors do not submit assignments.");
}

@Override
public void conductResearch() {
System.out.println("Professor is conducting research.");
}

@Override
public void gradeAssignments() {
System.out.println("Professor is grading assignments.");
}
}

The UniversityMember interface forces both Student and Professor classes to implement methods that are irrelevant to them. Both classes have to deal with methods that don’t apply to them, leading to potential errors or unnecessary exceptions.

How we can improve this:

public interface Attendee {
void attendLecture();
}

public interface Learner {
void submitAssignment();
}

public interface Researcher {
void conductResearch();
}

public interface Grader {
void gradeAssignments();
}

public class Student implements Attendee, Learner {
@Override
public void attendLecture() {
System.out.println("Student is attending a lecture.");
}

@Override
public void submitAssignment() {
System.out.println("Student is submitting an assignment.");
}
}

public class Professor implements Attendee, Researcher, Grader {
@Override
public void attendLecture() {
System.out.println("Professor is attending a lecture.");
}

@Override
public void conductResearch() {
System.out.println("Professor is conducting research.");
}

@Override
public void gradeAssignments() {
System.out.println("Professor is grading assignments.");
}
}

The responsibilities are split into smaller interfaces (Attendee, Learner, Researcher, Grader), and classes only implement the interfaces that are relevant to them. The Student class doesn't need to worry about grading or conducting research, and the Professor class doesn't have to submit assignments.

Dependency Inversion Principle

A class in a lower architectural layer should not depend on a class in a higher layer.

Dependencies should always be directed from upper layers to lower layers. E.g. it is ok if a class of the view layer (user interface, GUI) depends on a class in the model layer, but bot vice versa.

Let’s look how it’s NOT done:

// Low-level module
public class EmailService {
public void sendEmail(String to, String message) {
System.out.println("Sending email to " + to + ": " + message);
}
}

// High-level module
public class StudentNotification {
private EmailService emailService;

public StudentNotification() {
this.emailService = new EmailService(); // Direct dependency on the low-level module
}

public void notifyStudent(String studentEmail, String message) {
emailService.sendEmail(studentEmail, message);
}
}

The StudentNotification class is tightly coupled to the EmailService class.

If you want to change the notification method (e.g., to SMS or push notifications), you would need to modify the StudentNotification class. Then the direct dependency on EmailService makes it harder to test StudentNotification in isolation, as you can't easily replace the email service with a mock or stub.

How it’s done:

// Abstraction
public interface NotificationService {
void sendNotification(String to, String message);
}

// Low-level module
public class EmailService implements NotificationService {
@Override
public void sendNotification(String to, String message) {
System.out.println("Sending email to " + to + ": " + message);
}
}

// High-level module
public class StudentNotification {
private NotificationService notificationService;

// Dependency is injected via constructor
public StudentNotification(NotificationService notificationService) {
this.notificationService = notificationService;
}

public void notifyStudent(String studentEmail, String message) {
notificationService.sendNotification(studentEmail, message);
}
}

// Usage
public class UniversityApp {
public static void main(String[] args) {
NotificationService emailService = new EmailService(); // Low-level module
StudentNotification studentNotification = new StudentNotification(emailService); // High-level module depends on abstraction

studentNotification.notifyStudent("student@example.com", "Your class is starting soon.");
}
}

The StudentNotification class now depends on the NotificationService interface, an abstraction, rather than a concrete EmailService class.

This makes it easier to switch to different notification methods (e.g., SMS, push notifications) by simply implementing the NotificationService interface. The notification method can be easily changed or extended without modifying the StudentNotification class. This adheres to the open/closed principle as well.

You can easily mock or stub the NotificationService interface during testing, making unit tests for StudentNotification more straightforward.

Hands On Exercise Design Principles

Now you’ve seen the theory of Design Principles, let’s get to practice it.

Since I’m assuming you’re a Software Developer (or aspiring), you already have written some applications with lots of code. Go back to some of those now and check if you can see violations or even good implementations of the Design Principles.

In case you see some violations, think about how you can improve it and what benefit it will give you.

In case you see good implementations, think about what benefit this will give you and how a bad implementation would look like.

Design Patterns

If you are familiar with the design principles, then you can venture into the design patterns.

The basic idea behind the patterns is as follows: You are working on a project and are currently struggling with problem X. As long as you are not working in research or doing something very innovative, there is a good chance that someone else has already solved the same or a similar problem. This person was then free enough to publish their solution and it was turned into a reusable pattern (as this problem occurs frequently).

The design patterns usually focus on how you can write better code and sometimes also on specific applications (e.g. an email newsletter).

Let’s look at a good one to start with, the Strategy Pattern.

Strategy Pattern

The Strategy Pattern allows you to define a family of algorithms, encapsulate each one, and make them interchangeable. The pattern lets the algorithm vary independently from clients that use it.

public class PaymentProcessor {
public void processPayment(String paymentType, double amount) {
if (paymentType.equals("CreditCard")) {
System.out.println("Processing credit card payment of $" + amount);
} else if (paymentType.equals("PayPal")) {
System.out.println("Processing PayPal payment of $" + amount);
} else if (paymentType.equals("BankTransfer")) {
System.out.println("Processing bank transfer of $" + amount);
} else {
throw new UnsupportedOperationException("Payment method not supported");
}
}
}

Adding a new payment method requires modifying the PaymentProcessor class, violating the Open/Closed Principle.

The processPayment method becomes increasingly complex as more payment methods are added, making it harder to maintain. Similar logic (e.g., printing the amount) is repeated for each payment method.

// Strategy Interface
public interface PaymentStrategy {
void pay(double amount);
}

// Concrete Strategy for Credit Card
public class CreditCardPayment implements PaymentStrategy {
@Override
public void pay(double amount) {
System.out.println("Processing credit card payment of $" + amount);
}
}

// Concrete Strategy for PayPal
public class PayPalPayment implements PaymentStrategy {
@Override
public void pay(double amount) {
System.out.println("Processing PayPal payment of $" + amount);
}
}

// Concrete Strategy for Bank Transfer
public class BankTransferPayment implements PaymentStrategy {
@Override
public void pay(double amount) {
System.out.println("Processing bank transfer of $" + amount);
}
}

// Context class
public class PaymentProcessor {
private PaymentStrategy paymentStrategy;

// Inject the strategy via constructor
public PaymentProcessor(PaymentStrategy paymentStrategy) {
this.paymentStrategy = paymentStrategy;
}

public void processPayment(double amount) {
paymentStrategy.pay(amount);
}
}

// Usage
public class UniversityApp {
public static void main(String[] args) {
PaymentProcessor creditCardProcessor = new PaymentProcessor(new CreditCardPayment());
creditCardProcessor.processPayment(100.00);

PaymentProcessor payPalProcessor = new PaymentProcessor(new PayPalPayment());
payPalProcessor.processPayment(200.00);

PaymentProcessor bankTransferProcessor = new PaymentProcessor(new BankTransferPayment());
bankTransferProcessor.processPayment(300.00);
}
}

You can easily add new payment methods by creating new classes that implement the PaymentStrategy interface without modifying the existing code.

Each payment method is encapsulated in its own class, making the code cleaner and easier to maintain. The PaymentProcessor class can work with any payment strategy, making it flexible and reusable in different contexts. The logic for each payment type is isolated in its own class, reducing the complexity of the PaymentProcessor class and making the system easier to maintain.

By using the Strategy Pattern, the system becomes more modular, easier to extend, and adheres to good design principles like Open/Closed and Single Responsibility, leading to a more robust and maintainable codebase.

Resource

My absolute favourite site where you can learn and/or look up all the patterns is refactoring.guru. The patterns are explained very clearly and have countless examples in different programming languages.

Hands On Exercise Design Patterns

Similar to the exercise above, I ask you now to go back to your old code and see if you can improve some implementations with the strategy patterns (or another one, if you’re fancy).

In case you don’t have something, I got you. Here’s an exercise you can do. Try to implement the Strategy Pattern with this FeeCalculator class.

public class FeeCalculator {
public double calculateFee(String studentType, double baseFee) {
if (studentType.equals("Undergraduate")) {
return baseFee * 1.0; // No discount for undergraduates
} else if (studentType.equals("Graduate")) {
return baseFee * 0.9; // 10% discount for graduates
} else if (studentType.equals("International")) {
return baseFee * 1.2; // 20% extra for international students
} else {
throw new UnsupportedOperationException("Student type not supported");
}
}
}

Conclusion

We’ve delved into essential concepts for effective software design.

While these principles and patterns can greatly enhance code quality, they come with challenges. Adding layers of abstraction can increase complexity, and while theory may seem simple, practical application is often harder. To effectively implement these principles, focus on one at a time and review your existing code for improvement opportunities.

By gradually applying these practices, you’ll enhance your codebase and build a more robust foundation for your software projects.

--

--

Kenny Wolf
Geek Talk

I write about tech, software development and hacking for non-techies and geeks 🤓 | Software Developer 👾 | Interested in pentesting 👹