Skip to content
IRC-CodingIRC-Coding
Software DesignDesign PrinciplesModularityClean CodeSoftware ArchitectureBest PracticesAlgorithmsFundamentals

Software Design Fundamentals

Learn software design principles, patterns, and best practices for maintainable, scalable code.

S

schutzgeist

9 min read
Software Design Fundamentals

Software Design Fundamentals 2024

If you’re wondering what software design actually is and why it matters, you’ve come to the right place. As an application developer, I want to walk you through the essentials.

What is Software Design?

Software design is the process of planning how your software should be structured. It’s not just about what the software should do, but how it should do it. Good design is the foundation of efficient, maintainable, and scalable software.

The three levels of software design:

  1. Architectural Design: The overall structure of your entire application
  2. Detailed Design: Concrete implementation of individual components
  3. Interface Design: Definition of APIs and user interfaces

Why Good Design Matters

Good design is critical to your software’s quality. It ensures that your software not only works today, but remains easy to extend and maintain in the future.

Benefits of good design:

  • Maintainability: Easier to understand and modify
  • Extensibility: New features can be added without major refactoring
  • Testability: Components can be tested in isolation
  • Reusability: Modules can be used in other projects
  • Performance: Efficient use of resources

SOLID Principles

The SOLID principles are five foundational design principles for object-oriented software development:

1. Single Responsibility Principle (SRP)

A class should have only one reason to change.

// ❌ Bad: Class has multiple responsibilities
public class UserService {
    public void saveUser(User user) { /* saves user */ }
    public void sendEmail(User user) { /* sends email */ }
    public void generateReport(User user) { /* generates report */ }
}

// ✅ Good: Each class has a single responsibility
public class UserService {
    public void saveUser(User user) { /* saves user */ }
}

public class EmailService {
    public void sendEmail(User user) { /* sends email */ }
}

public class ReportService {
    public void generateReport(User user) { /* generates report */ }
}

2. Open/Closed Principle (OCP)

Software components should be open for extension but closed for modification.

// ❌ Bad: Must modify code for each new discount type
public class DiscountCalculator {
    public double calculateDiscount(String type, double amount) {
        if (type.equals("STUDENT")) return amount * 0.1;
        if (type.equals("SENIOR")) return amount * 0.2;
        // new type requires code change
        return 0;
    }
}

// ✅ Good: Open for extension
public interface Discount {
    double calculate(double amount);
}

public class StudentDiscount implements Discount {
    public double calculate(double amount) { return amount * 0.1; }
}

public class SeniorDiscount implements Discount {
    public double calculate(double amount) { return amount * 0.2; }
}

3. Liskov Substitution Principle (LSP)

Subclasses should be substitutable for their base classes.

// ❌ Bad: Violates LSP
public class Rectangle {
    protected int width, height;
    public void setWidth(int width) { this.width = width; }
    public void setHeight(int height) { this.height = height; }
    public int getArea() { return width * height; }
}

public class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width; // Violates Rectangle behavior
    }
}

// ✅ Good: Respects LSP
public abstract class Shape {
    public abstract int getArea();
}

public class Rectangle extends Shape {
    private int width, height;
    public int getArea() { return width * height; }
}

public class Square extends Shape {
    private int side;
    public int getArea() { return side * side; }
}

4. Interface Segregation Principle (ISP)

Many client-specific interfaces are better than one general-purpose interface.

// ❌ Bad: Large interface
public interface Worker {
    void work();
    void eat();
    void sleep();
}

public class Robot implements Worker {
    public void work() { /* ... */ }
    public void eat() { /* Robot doesn't eat */ }
    public void sleep() { /* Robot doesn't sleep */ }
}

// ✅ Good: Specific interfaces
public interface Workable {
    void work();
}

public interface Eatable {
    void eat();
}

public class Robot implements Workable {
    public void work() { /* ... */ }
}

5. Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules. Both should depend on abstractions.

// ❌ Bad: Direct dependency
public class LightSwitch {
    private LightBulb bulb;
    
    public LightSwitch() {
        this.bulb = new LightBulb(); // Direct dependency
    }
    
    public void flip() {
        bulb.turnOn();
    }
}

// ✅ Good: Dependency on interface
public interface Switchable {
    void turnOn();
    void turnOff();
}

public class LightBulb implements Switchable {
    public void turnOn() { /* ... */ }
    public void turnOff() { /* ... */ }
}

public class LightSwitch {
    private Switchable device;
    
    public LightSwitch(Switchable device) {
        this.device = device; // Dependency on abstraction
    }
    
    public void flip() {
        device.turnOn();
    }
}

Other Important Design Principles

KISS (Keep It Simple, Stupid)

Keep your code simple and understandable. Simple solutions are often the best ones.

DRY (Don’t Repeat Yourself)

Avoid code duplication. Don’t repeat logic across your codebase.

YAGNI (You Ain’t Gonna Need It)

Don’t build for hypothetical future scenarios. Focus on what’s needed right now.

Separation of Concerns

Break different aspects of your application into separate modules.

Architectural Patterns

Layered Architecture

Organizes code into logical layers:

  • Presentation Layer: User interface
  • Business Layer: Business logic
  • Data Access Layer: Data access
  • Database Layer: Database

MVC (Model-View-Controller)

Separates data, presentation, and control:

  • Model: Data and business logic
  • View: User interface
  • Controller: Mediates between Model and View

Microservices Architecture

Splits the system into small, independent services:

  • Each service has its own database
  • Services communicate via APIs
  • Independent deployment

Event-Driven Architecture

Systems communicate through events:

  • Loose coupling between components
  • Asynchronous communication
  • Good scalability

Design Patterns (Gang of Four)

Creational Patterns

Factory Method

Creates objects without specifying their exact classes.

public interface Vehicle {
    void drive();
}

public class Car implements Vehicle {
    public void drive() { System.out.println("Car drives"); }
}

public class Motorcycle implements Vehicle {
    public void drive() { System.out.println("Motorcycle drives"); }
}

public abstract class VehicleFactory {
    public abstract Vehicle createVehicle();
}

public class CarFactory extends VehicleFactory {
    public Vehicle createVehicle() { return new Car(); }
}

Singleton

Ensures a class has only one instance.

public class DatabaseConnection {
    private static DatabaseConnection instance;
    
    private DatabaseConnection() { /* private constructor */ }
    
    public static DatabaseConnection getInstance() {
        if (instance == null) {
            instance = new DatabaseConnection();
        }
        return instance;
    }
}

Structural Patterns

Adapter

Allows incompatible interfaces to work together.

public interface MediaPlayer {
    void play(String audioType, String fileName);
}

public interface AdvancedMediaPlayer {
    void playVlc(String fileName);
    void playMp4(String fileName);
}

public class MediaAdapter implements MediaPlayer {
    private AdvancedMediaPlayer advancedMusicPlayer;
    
    public MediaAdapter(String audioType) {
        if (audioType.equalsIgnoreCase("vlc")) {
            advancedMusicPlayer = new VlcPlayer();
        }
    }
    
    public void play(String audioType, String fileName) {
        if (audioType.equalsIgnoreCase("vlc")) {
            advancedMusicPlayer.playVlc(fileName);
        }
    }
}

Behavioral Patterns

Observer

Enables notification when state changes occur.

import java.util.ArrayList;
import java.util.List;

public interface Observer {
    void update(String message);
}

public interface Subject {
    void registerObserver(Observer observer);
    void removeObserver(Observer observer);
    void notifyObservers();
}

public class NewsAgency implements Subject {
    private List<Observer> observers = new ArrayList<>();
    private String news;
    
    public void registerObserver(Observer observer) {
        observers.add(observer);
    }
    
    public void notifyObservers() {
        for (Observer observer : observers) {
            observer.update(news);
        }
    }
    
    public void setNews(String news) {
        this.news = news;
        notifyObservers();
    }
}

Strategy

Defines a family of algorithms and makes them interchangeable.

public interface PaymentStrategy {
    void pay(int amount);
}

public class CreditCardPayment implements PaymentStrategy {
    public void pay(int amount) {
        System.out.println("Paid " + amount + " using Credit Card");
    }
}

public class PayPalPayment implements PaymentStrategy {
    public void pay(int amount) {
        System.out.println("Paid " + amount + " using PayPal");
    }
}

public class ShoppingCart {
    private PaymentStrategy paymentStrategy;
    
    public void setPaymentStrategy(PaymentStrategy strategy) {
        this.paymentStrategy = strategy;
    }
    
    public void checkout(int amount) {
        paymentStrategy.pay(amount);
    }
}

Refactoring Techniques

Extract Method

Pull code into a separate method to improve readability.

// Before
public void processOrder(Order order) {
    // Validation
    if (order == null) throw new IllegalArgumentException();
    if (order.getItems().isEmpty()) throw new IllegalArgumentException();
    
    // Calculation
    double total = 0;
    for (Item item : order.getItems()) {
        total += item.getPrice() * item.getQuantity();
    }
    
    // Storage
    order.setTotal(total);
    orderRepository.save(order);
}

// After
public void processOrder(Order order) {
    validateOrder(order);
    double total = calculateTotal(order);
    saveOrder(order, total);
}

private void validateOrder(Order order) {
    if (order == null) throw new IllegalArgumentException();
    if (order.getItems().isEmpty()) throw new IllegalArgumentException();
}

private double calculateTotal(Order order) {
    double total = 0;
    for (Item item : order.getItems()) {
        total += item.getPrice() * item.getQuantity();
    }
    return total;
}

private void saveOrder(Order order, double total) {
    order.setTotal(total);
    orderRepository.save(order);
}

Replace Conditional with Polymorphism

Replace conditionals with polymorphic behavior.

// Before
public class Bird {
    public void fly(String type) {
        if (type.equals("Eagle")) {
            System.out.println("Eagle flies high");
        } else if (type.equals("Penguin")) {
            System.out.println("Penguin cannot fly");
        }
    }
}

// After
public abstract class Bird {
    public abstract void fly();
}

public class Eagle extends Bird {
    public void fly() {
        System.out.println("Eagle flies high");
    }
}

public class Penguin extends Bird {
    public void fly() {
        System.out.println("Penguin cannot fly");
    }
}

Code Quality Metrics

Cyclomatic Complexity

Measures code complexity by counting the number of decision points.

Maintainability Index

Evaluates how easily code can be maintained.

Test Coverage

The percentage of code covered by tests.

The Importance of Documentation

Good documentation is crucial for maintainability:

Types of Documentation

  • API Documentation: Description of interfaces
  • Architecture Documentation: Overview of system architecture
  • Code Comments: Explanations of complex code sections
  • User Documentation: Usage guides

Documentation Best Practices

  • Document the “why”, not just the “what”
  • Keep documentation up to date
  • Use clear and understandable language
  • Use diagrams for visualization

Practical Example: E-Commerce System

Here’s a practical example that applies many of the principles we’ve discussed:

// Interfaces for loose coupling
public interface OrderService {
    Order createOrder(List<Item> items);
    void processPayment(Order order, PaymentStrategy strategy);
}

public interface InventoryService {
    boolean checkAvailability(Item item);
    void reserveItem(Item item);
}

// Implementation with SOLID principles
@Service
public class OrderServiceImpl implements OrderService {
    private final InventoryService inventoryService;
    private final NotificationService notificationService;
    
    public OrderServiceImpl(InventoryService inventoryService, 
                           NotificationService notificationService) {
        this.inventoryService = inventoryService;
        this.notificationService = notificationService;
    }
    
    @Override
    public Order createOrder(List<Item> items) {
        validateItems(items);
        reserveItems(items);
        
        Order order = new Order(items);
        order.calculateTotal();
        
        return order;
    }
    
    @Override
    public void processPayment(Order order, PaymentStrategy strategy) {
        strategy.pay(order.getTotal());
        notificationService.sendOrderConfirmation(order);
    }
    
    private void validateItems(List<Item> items) {
        for (Item item : items) {
            if (!inventoryService.checkAvailability(item)) {
                throw new ItemNotAvailableException(item);
            }
        }
    }
    
    private void reserveItems(List<Item> items) {
        for (Item item : items) {
            inventoryService.reserveItem(item);
        }
    }
}

Exam Questions and Answers

1. What are the SOLID principles and why are they important?

Answer: The SOLID principles are five design principles: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. They are important because they lead to more maintainable, extensible, and testable code. They help avoid technical debt and improve overall code quality.

2. Explain the Single Responsibility Principle with an example.

Answer: The SRP states that a class should have only one reason to change. For example, a UserService class should be responsible only for user management, not email sending or report generation. This improves maintainability because changes to one responsibility don’t affect the other.

3. What is the difference between cohesion and coupling?

Answer: Cohesion describes how strongly the elements within a module belong together (high cohesion is good). Coupling describes how strongly modules depend on each other (low coupling is good). The goal is high cohesion and low coupling for better maintainability.

4. When do you use the Singleton pattern?

Answer: Singleton is used when exactly one instance of a class is needed, such as for database connections, logging services, or configuration managers. It ensures that globally only one instance exists and provides a central point of access.

5. Explain the Open/Closed Principle.

Answer: The OCP states that software components should be open for extension but closed for modification. This means you should be able to add new functionality through extension (for example, inheritance) without having to change existing code.

These are external affiliate links. We both benefit when you purchase through them:

Building Evolutionary Architectures

Building Evolutionary Architectures

Clean Code

Clean Code

Design Patterns

Design Patterns

Back to Blog
Share:

Related Posts