Mastering GRASP Design Principles for Better Software Design

in10se
6 min readMay 6, 2023

--

Introduction

Creating maintainable, scalable, and reusable software systems is at the core of successful software development. To achieve these goals, understanding and applying the right set of design principles is essential. In object-oriented programming (OOP), the GRASP principles provide valuable guidance to help you design better software systems.

GRASP, which stands for General Responsibility Assignment Software Patterns (or Principles), is a collection of nine best practices that assist in assigning responsibilities to classes and objects. By following these principles, you can create a well-structured, modular, and maintainable software system.

In this blog post, we will explore the nine core GRASP principles with examples to help you gain a deeper understanding and apply these principles to your software projects.

Creator

The Creator principle involves assigning the responsibility of creating an object to a class that uses the object, has the necessary information to create it, or aggregates the object. This principle ensures a clear separation of concerns and simplifies object creation.

Example: In an e-commerce application, the Order class might be responsible for creating OrderItem objects, as it uses and aggregates these objects.

class Order {
private List<OrderItem> items;

public Order() {
items = new ArrayList<>();
}

public void addItem(Product product, int quantity) {
items.add(new OrderItem(product, quantity));
}
}

Information Expert

The Information Expert principle states that responsibilities should be assigned to the class with the most knowledge or information required to fulfill the responsibility. This principle promotes encapsulation and ensures that each class is responsible for managing its data and behavior.

Example: In a payroll system, the Employee class should be responsible for calculating its salary, as it has all the necessary information, such as base salary, hours worked, and bonuses.

class Employee {
private double baseSalary;
private double hoursWorked;
private double bonus;

public double calculateSalary() {
return baseSalary + (hoursWorked * hourlyRate) + bonus;
}
}

Low Coupling

Low Coupling involves minimizing dependencies between classes to reduce the impact of changes and improve maintainability. This principle encourages independent and modular classes that can be easily modified without affecting other parts of the system.

Example: Instead of having a direct dependency between the Order class and the ShippingService class, we can introduce an interface, ShippingProvider, which reduces coupling and allows for easier substitution of shipping services.

interface ShippingProvider {
void ship(Order order);
}

class Order {
private ShippingProvider shippingProvider;

public void setShippingProvider(ShippingProvider provider) {
this.shippingProvider = provider;
}

public void process() {
shippingProvider.ship(this);
}
}

High Cohesion

High Cohesion means grouping related responsibilities together within a single class to make it easier to understand, maintain, and reuse. This principle ensures that each class has a single, focused purpose.

Example: In a blogging platform, the Blog class should only be responsible for managing blog-related activities, such as adding and removing posts, and not for handling user authentication.

class Blog {
private List<Post> posts;

public void addPost(Post post) {
posts.add(post);
}

public void removePost(Post post) {
posts.remove(post);
}
}

Controller

The Controller principle assigns the responsibility of handling system events to a dedicated class, which manages and coordinates the system’s behavior. This principle helps maintain a clean separation between the presentation and domain layers.

Example: In a web application, a UserController class can handle user-related events, such as registering and logging in, delegating the actual processing to other classes.

class UserService {
public void registerUser(String username, String password) {
// Register user logic
}

public boolean authenticate(String username, String password) {
// Authentication logic
return true; // Example: return true if authentication is successful
}
}

class UserController {
private UserService userService;

public UserController(UserService userService) {
this.userService = userService;
}

public void register(String username, String password) {
userService.registerUser(username, password);
}

public boolean login(String username, String password) {
return userService.authenticate(username, password);
}
}

// Usage example
public class Main {
public static void main(String[] args) {
UserService userService = new UserService();
UserController userController = new UserController(userService);

// Register a new user
userController.register("user1", "password1");

// Authenticate the user
boolean isAuthenticated = userController.login("user1", "password1");
System.out.println("Is authenticated: " + isAuthenticated);
}
}

In this example, the UserController class acts as a controller, responsible for handling user-related events such as registering and logging in. The UserService class contains the actual business logic for registering and authenticating users. The UserController delegates the processing to the UserService class, ensuring a clean separation between the presentation and domain layers.

Polymorphism

Polymorphism involves using inheritance and interfaces to enable different classes to implement the same behavior or operation. This principle allows for more flexibility and easier code maintenance by enabling the system to handle varying implementations without modifications to the existing code.

Example: In a graphics application, the Shape interface defines the common behavior for different shapes, such as Circle and Rectangle, allowing them to be rendered in a consistent manner.

interface Shape {
void draw();
}

class Circle implements Shape {
public void draw() {
// Draw circle
}
}

class Rectangle implements Shape {
public void draw() {
// Draw rectangle
}
}

class GraphicsRenderer {
public void render(List<Shape> shapes) {
for (Shape shape : shapes) {
shape.draw();
}
}
}

Pure Fabrication

Pure Fabrication involves creating an artificial class to fulfill a specific responsibility when no suitable class exists. This principle aims to maintain high cohesion and low coupling by avoiding the assignment of unrelated responsibilities to existing classes.

Example: In a file storage application, the FileStorage class can be created to handle file storage operations, separating it from the core business logic.

class FileStorage {
public void saveFile(String filePath, byte[] content) {
// Save file to storage
}

public byte[] readFile(String filePath) {
// Read file from storage
return new byte[0];
}
}

class Document {
private String filePath;
private byte[] content;
private FileStorage fileStorage;

public void save() {
fileStorage.saveFile(filePath, content);
}

public void load() {
content = fileStorage.readFile(filePath);
}
}

Indirection

Indirection introduces an intermediate class or object to mediate between other classes, helping to maintain low coupling and simplify interactions. This principle can be applied through various patterns, such as the Facade or Adapter patterns.

Example: In a notification system, a NotificationService class can be introduced to send notifications via different channels, like email or SMS, without directly coupling the sender and receiver classes.

interface NotificationChannel {
void sendNotification(String message);
}

class EmailNotificationChannel implements NotificationChannel {
public void sendNotification(String message) {
// Send email notification
}
}

class SMSNotificationChannel implements NotificationChannel {
public void sendNotification(String message) {
// Send SMS notification
}
}

class NotificationService {
private List<NotificationChannel> channels;

public void sendNotification(String message) {
for (NotificationChannel channel : channels) {
channel.sendNotification(message);
}
}
}

Protected Variations

Protected Variations involve encapsulating variations and changes in the system behind stable interfaces to minimize the impact of changes and increase the system’s robustness. This principle can be applied by using abstractions, such as interfaces or abstract classes, to hide implementation details.

Example: In a payment processing system, the PaymentGateway interface protects the system from changes in the implementations of different payment methods, like CreditCardPayment or PayPalPayment.

interface PaymentGateway {
void processPayment(double amount);
}

class CreditCardPayment implements PaymentGateway {
public void processPayment(double amount) {
// Process credit card payment
System.out.println("Processing credit card payment: " + amount);
}
}

class PayPalPayment implements PaymentGateway {
public void processPayment(double amount) {
// Process PayPal payment
System.out.println("Processing PayPal payment: " + amount);
}
}

class ShoppingCart {
private PaymentGateway paymentGateway;

public void setPaymentGateway(PaymentGateway gateway) {
this.paymentGateway = gateway;
}

public void checkout(double amount) {
paymentGateway.processPayment(amount);
}
}

Conclusion

Understanding and applying GRASP principles is a key aspect of designing robust, maintainable, and scalable object-oriented software systems. By leveraging these principles, developers can create well-structured code that is easier to modify and reuse, ultimately leading to more efficient software development processes and higher-quality applications.

In this blog post, we’ve explored each of the nine GRASP principles and provided examples to illustrate their use in practice. As you continue to work on your software development projects, keep these principles in mind and incorporate them into your designs to ensure your systems are built on a solid foundation. Happy coding!

Buy me a coffee: https://www.buymeacoffee.com/in10se

--

--