The Building Blocks of Software: Why We Need Design Principles Like SOLID, CUPID & GRASP
Introduction
In the ever-evolving landscape of software development, the quest for building reliable, maintainable, and efficient software systems is a pursuit shared by developers worldwide. While the technology stack may change and the tools may evolve, one thing remains constant – the importance of sound design principles.
Design principles, often encapsulated by acronyms like SOLID, CUPID, and GRASP, are the guiding stars that illuminate the path toward creating software that stands the test of time. In this blog, we'll go through the "whys" and "hows" of these principles, with a special focus on SOLID.
The Need for Design Principles
The answer lies in the challenges and complexities of modern software development.
Software is a dynamic universe, where change is not just constant; it's inevitable. As developers, we're tasked with crafting code that adapts to the evolving needs of users, embraces new features, and endures the test of time. We strive to build software that's not only functional but also flexible, scalable, and maintainable.
Design principles act as a beacon of wisdom in this sea of innovation. They offer a set of fundamental rules and best practices that help us navigate through the intricacies of software design. From ensuring clarity and maintainability to promoting reusability and testability, these principles equip us with the tools needed to construct software that doesn't just work but works exceptionally well.
SOLID Principles
SOLID, an acronym coined by Robert C. Martin, comprises five distinct principles: Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. These principles serve as the bedrock of structured, high-quality software design. By understanding and applying SOLID, developers can write code that's modular, extensible, and adaptable to change.
Practical Example: Applying SOLID
Bad example:
class Student {
String name;
int age;
Student(String name, int age) {
this.name = name;
this.age = age;
}
void enrollInCourse(String course) {
// Code to enroll a student in a course
}
void attendClass() {
// Code to mark attendance
}
}
class Professor {
String name;
int experience;
Professor(String name, int experience) {
this.name = name;
this.experience = experience;
}
void teachCourse(String course) {
// Code to teach a course
}
}
class College {
List<Student> students;
List<Professor> professors;
College() {
students = new ArrayList<>();
professors = new ArrayList<>();
}
void admitStudent(Student student) {
students.add(student);
}
void hireProfessor(Professor professor) {
professors.add(professor);
}
}
class Management {
College college;
Management(College college) {
this.college = college;
}
void manageCollege() {
// Code to manage college operations
}
}
We'll explore each SOLID principle, examining the issues in this code and how to address them:
Single Responsibility Principle (SRP)
What's Wrong: The Student and Professor classes handle multiple responsibilities.
Correction: We'll split responsibilities into separate classes for personal information, enrollment, and teaching.
Open-Closed Principle (OCP)
What's Wrong: Adding new roles or actions may require modifying existing classes.
Correction: We'll ensure our system is open for extension without modifying existing code.
Liskov Substitution Principle (LSP)
What's Wrong: Subclasses may not follow the Liskov Substitution Principle.
Correction: We'll make sure that subclasses can be used interchangeably with their base class without changing behavior.
Interface Segregation Principle (ISP)
What's Wrong: Classes unnecessarily provide methods not relevant to all clients.
Correction: We'll split the interface into more specific interfaces to avoid imposing unnecessary methods.
Dependency Inversion Principle (DIP)
What's Wrong: The Management class is tightly coupled to the College class.
Correction: We'll use abstractions and dependency injection to decouple high-level modules from low-level details.
Better example:
// SRP - Single Responsibility Principle
class Person {
String name;
int age;
Person(String name, int age) {
this.name = name;
this.age = age;
}
}
class Student {
Person person;
List<String> enrolledCourses;
Student(String name, int age) {
person = new Person(name, age);
enrolledCourses = new ArrayList<>();
}
void enrollInCourse(String course) {
enrolledCourses.add(course);
}
}
class Professor {
Person person;
int experience;
Professor(String name, int age, int experience) {
person = new Person(name, age);
this.experience = experience;
}
void teachCourse(String course) {
// Code to teach a course
}
}
class College {
List<Student> students;
List<Professor> professors;
College() {
students = new ArrayList<>();
professors = new ArrayList<>();
}
void admitStudent(Student student) {
students.add(student);
}
void hireProfessor(Professor professor) {
professors.add(professor);
}
}
class Management {
College college;
Management(College college) {
this.college = college;
}
void manageCollege() {
// Code to manage college operations
}
}
// OCP - Open-Closed Principle
// We can add new roles or actions without modifying existing code.
// LSP - Liskov Substitution Principle
// These subclasses maintain the contract of their respective base classes.
// ISP - Interface Segregation Principle
// We only implement the methods relevant to the interface.
// DIP - Dependency Inversion Principle
// High-level modules depend on abstractions, not concrete classes.
In this improved code, we've adhered to the SOLID principles, ensuring each class has a single responsibility, is open for extension, closed for modification, respects the Liskov Substitution Principle, separates interface responsibilities, and follows the Dependency Inversion Principle.
CUPID Principles
CUPID, a collection of guiding design principles, stands as a beacon for software developers striving to create systems that are not just functional but also well-structured and maintainable. Coined by experts in the field, the CUPID principles encompass Cohesion, Understandability, Primitiveness, Immutability, and Dependency Management.
Practical Example: Applying CUPID
Bad example:
class Student {
String name;
int age;
List<String> enrolledCourses;
Student(String name, int age) {
this.name = name;
this.age = age;
enrolledCourses = new ArrayList<>();
}
void enrollInCourse(String course) {
enrolledCourses.add(course);
}
}
class Professor {
String name;
int experience;
Professor(String name, int experience) {
this.name = name;
this.experience = experience;
}
void teachCourse(String course) {
// Code to teach a course
}
// More methods
}
class College {
List<Student> students;
List<Professor> professors;
College() {
students = new ArrayList<>();
professors = new ArrayList<>();
}
void admitStudent(Student student) {
students.add(student);
}
void hireProfessor(Professor professor) {
professors.add(professor);
}
}
Let's break down the CUPID principles and identify the problems in the code and how to rectify them:
Cohesion (C)
What's Wrong: The classes lack cohesion, handling multiple responsibilities.
Correction: Split responsibilities into separate classes to ensure higher cohesion and reduced coupling.
Understandability (U)
What's Wrong: The code lacks clear documentation and structure, making it hard to comprehend and maintain.
Correction: Implement clear naming conventions, documentation, and consistent code structure to enhance understandability.
Primitiveness (P)
What's Wrong: Classes lack abstraction and are overly detailed with low-level operations.
Correction: Introduce abstractions, higher-level functions, and separate concerns to improve the design's primitiveness.
Immutability (I)
What's Wrong: There's an absence of immutability in classes where it could be beneficial.
Correction: Utilize immutability where possible to create more predictable and error-resistant code.
Dependency Management (D)
What's Wrong: High coupling between classes, hindering flexibility and testability.
Correction: Implement dependency injection and management patterns to reduce tight coupling and improve maintainability.
Better example:
class Person {
String name;
int age;
Person(String name, int age) {
this.name = name;
this.age = age;
}
}
class Student {
Person person;
List<String> enrolledCourses;
Student(String name, int age) {
person = new Person(name, age);
enrolledCourses = new ArrayList<>();
}
void enrollInCourse(String course) {
enrolledCourses.add(course);
}
}
class Professor {
Person person;
int experience;
Professor(String name, int age, int experience) {
person = new Person(name, age);
this.experience = experience;
}
void teachCourse(String course) {
// Code to teach a course
}
}
class College {
List<Student> students;
List<Professor> professors;
College() {
students = new ArrayList<>();
professors = new ArrayList<>();
}
void admitStudent(Student student) {
students.add(student);
}
void hireProfessor(Professor professor) {
professors.add(professor);
}
}
In this refined code, we've adhered to the CUPID principles, ensuring higher cohesion, better understandability, appropriate primitiveness, immutability where needed, and improved dependency management. The design is now more structured, maintainable, and aligned with the CUPID principles.
GRASP Principles
Much like SOLID, the GRASP (General Responsibility Assignment Software Patterns) principles are a set of design guidelines that aid in the effective assignment of responsibilities to classes in software systems. By adhering to these principles, developers achieve the desirable goals of low coupling, high cohesion, and clear responsibility allocation.
GRASP principles encompass Information Expert, Creator, Controller, Low Coupling, and High Cohesion, each playing a pivotal role in shaping well-structured, maintainable software. They guide developers in making informed decisions regarding class responsibilities, object creation, system control, minimizing interdependencies, and ensuring that each class maintains a single, well-defined purpose.
Practical Example: Applying CUPID
Bad example:
class Student {
String name;
int age;
List<String> enrolledCourses;
Student(String name, int age) {
this.name = name;
this.age = age;
enrolledCourses = new ArrayList<>();
}
void enrollInCourse(String course) {
enrolledCourses.add(course);
}
}
class Professor {
String name;
int experience;
Professor(String name, int experience) {
this.name = name;
this.experience = experience;
}
void teachCourse(String course) {
// Code to teach a course
}
// More methods
}
class College {
List<Student> students;
List<Professor> professors;
College() {
students = new ArrayList<>();
professors = new ArrayList<>();
}
void admitStudent(Student student) {
students.add(student);
}
void hireProfessor(Professor professor) {
professors add(professor);
}
}
Let's break down the GRASP principles and identify the issues in the code and how to rectify them:
Information Expert
What's Wrong: Responsibility is not allocated to the most informed class; classes are performing tasks they might not be best suited for.
Correction: Assign responsibilities to classes that possess the most information or expertise for those tasks.
Creator
What's Wrong: The responsibility for creating objects is not allocated efficiently, causing classes to take on unnecessary responsibilities.
Correction: Allocate object creation responsibilities to classes that have the most information required to create those objects.
Controller
What's Wrong: Lack of a centralized controller class to mediate between different components of the system.
Correction: Introduce a controller class to manage communication and flow between different classes.
Low Coupling
What's Wrong: Classes are highly coupled, impacting the flexibility and maintainability of the system.
Correction: Reduce interdependencies between classes to foster flexibility and easier maintenance.
High Cohesion
What's Wrong: Classes have multiple responsibilities instead of focusing on one well-defined task.
Correction: Allocate responsibilities in a way that each class has a single, well-defined purpose.
Better example:
class Person {
String name;
int age;
Person(String name, int age) {
this.name = name;
this.age = age;
}
}
class Student {
Person person;
List<String> enrolledCourses;
Student(String name, int age) {
person = new Person(name, age);
enrolledCourses = new ArrayList<>();
}
void enrollInCourse(String course) {
enrolledCourses.add(course);
}
}
class Professor {
Person person;
int experience;
Professor(String name, int age, int experience) {
person = new Person(name, age);
this.experience = experience;
}
void teachCourse(String course) {
// Code to teach a course
}
}
class College {
List<Student> students;
List<Professor> professors;
College() {
students = new ArrayList<>();
professors = new ArrayList<>();
}
void admitStudent(Student student) {
students.add(student);
}
void hireProfessor(Professor professor) {
professors.add(professor);
}
}
In this refined code, we've adhered to the GRASP principles, ensuring responsibilities are effectively assigned to classes, leading to lower coupling and higher cohesion. The design is now more maintainable and follows the principles of GRASP.
Conclusion
In conclusion, while using design principles like SOLID, CUPID, and GRASP is invaluable, they should be applied judiciously. Striving for best practices takes time and effort. Therefore, stay committed to crafting software that best serves your unique needs and requirements.
Subscribe to my newsletter
Read articles from Gokhul directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Gokhul
Gokhul
Recent grad passionate about community-driven tech! I love connecting, honing software & web skills, and sharing experiences through blogs. Let's connect, collaborate, and innovate in tech!