Menu

Backend & APIs Topic

What are Software Design Patterns?

Software Design Patterns are reusable solutions to common problems that occur in software design. They are templates for how to structure code to make it more maintainable, flexible, and understandable.

Key Characteristics:

  • Not finished code - they're templates or guidelines

  • Proven solutions to recurring problems

  • Language-agnostic - can be implemented in any OOP language

  • Improve code communication among developers


Categories of Design Patterns

1. Creational Patterns

Deal with object creation mechanisms.

2. Structural Patterns

Concerned with object composition and relationships.

3. Behavioral Patterns

Focus on communication between objects and responsibility assignment.


1. Creational Patterns

1.1 Singleton Pattern

Purpose: Ensure a class has only one instance and provide global access to it.

When to Use:

  • When you need exactly one instance of a class (database connections, logging, configuration)

  • When you need controlled access to a shared resource

Code Example:

java

public class DatabaseConnection {
    // Static variable to hold the single instance
    private static DatabaseConnection instance;
    private Connection connection;
    
    // Private constructor to prevent instantiation
    private DatabaseConnection() {
        try {
            this.connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb", "user", "password");
        } catch (SQLException e) {
            throw new RuntimeException("Failed to create database connection", e);
        }
    }
    
    // Public method to provide access to the instance
    public static DatabaseConnection getInstance() {
        if (instance == null) {
            // Thread-safe implementation
            synchronized (DatabaseConnection.class) {
                if (instance == null) {
                    instance = new DatabaseConnection();
                }
            }
        }
        return instance;
    }
    
    public Connection getConnection() {
        return connection;
    }
}

// Usage
public class Application {
    public static void main(String[] args) {
        DatabaseConnection db1 = DatabaseConnection.getInstance();
        DatabaseConnection db2 = DatabaseConnection.getInstance();
        
        System.out.println(db1 == db2); // true - same instance
    }
}

1.2 Factory Method Pattern

Purpose: Define an interface for creating objects, but let subclasses decide which class to instantiate.

When to Use:

  • When you don't know beforehand the exact types and dependencies of objects

  • When you want to provide a way to extend the internal components

  • When you want to delegate object creation to subclasses

Code Example:

java

// Product interface
public interface PaymentProcessor {
    void processPayment(double amount);
    boolean validate();
}

// Concrete Products
public class CreditCardProcessor implements PaymentProcessor {
    private String cardNumber;
    private String cvv;
    
    public CreditCardProcessor(String cardNumber, String cvv) {
        this.cardNumber = cardNumber;
        this.cvv = cvv;
    }
    
    @Override
    public void processPayment(double amount) {
        System.out.println("Processing credit card payment of $" + amount);
        // Actual credit card processing logic
    }
    
    @Override
    public boolean validate() {
        return cardNumber != null && cardNumber.length() == 16 && cvv != null && cvv.length() == 3;
    }
}

public class PayPalProcessor implements PaymentProcessor {
    private String email;
    
    public PayPalProcessor(String email) {
        this.email = email;
    }
    
    @Override
    public void processPayment(double amount) {
        System.out.println("Processing PayPal payment of $" + amount + " for " + email);
        // Actual PayPal processing logic
    }
    
    @Override
    public boolean validate() {
        return email != null && email.contains("@");
    }
}

// Creator abstract class
public abstract class PaymentProcessorFactory {
    // Factory method
    public abstract PaymentProcessor createPaymentProcessor(String... parameters);
    
    // Common business logic
    public void processOrder(double amount, String... parameters) {
        PaymentProcessor processor = createPaymentProcessor(parameters);
        if (processor.validate()) {
            processor.processPayment(amount);
            System.out.println("Payment processed successfully!");
        } else {
            System.out.println("Payment validation failed!");
        }
    }
}

// Concrete Creators
public class CreditCardProcessorFactory extends PaymentProcessorFactory {
    @Override
    public PaymentProcessor createPaymentProcessor(String... parameters) {
        return new CreditCardProcessor(parameters[0], parameters[1]); // cardNumber, cvv
    }
}

public class PayPalProcessorFactory extends PaymentProcessorFactory {
    @Override
    public PaymentProcessor createPaymentProcessor(String... parameters) {
        return new PayPalProcessor(parameters[0]); // email
    }
}

// Usage
public class ECommerceApp {
    public static void main(String[] args) {
        PaymentProcessorFactory creditCardFactory = new CreditCardProcessorFactory();
        creditCardFactory.processOrder(100.0, "1234567812345678", "123");
        
        PaymentProcessorFactory paypalFactory = new PayPalProcessorFactory();
        paypalFactory.processOrder(75.50, "user@example.com");
    }
}

1.3 Builder Pattern

Purpose: Construct complex objects step by step, separating construction from representation.

When to Use:

  • When creating objects with many optional parameters

  • When the construction process should allow different representations

  • When you want to create immutable objects

Code Example:

java

public class Computer {
    // Required parameters
    private final String CPU;
    private final String RAM;
    
    // Optional parameters
    private final String storage;
    private final String graphicsCard;
    private final String motherboard;
    private final boolean isBluetoothEnabled;
    private final boolean isWifiEnabled;
    
    // Private constructor
    private Computer(ComputerBuilder builder) {
        this.CPU = builder.CPU;
        this.RAM = builder.RAM;
        this.storage = builder.storage;
        this.graphicsCard = builder.graphicsCard;
        this.motherboard = builder.motherboard;
        this.isBluetoothEnabled = builder.isBluetoothEnabled;
        this.isWifiEnabled = builder.isWifiEnabled;
    }
    
    // Getters
    public String getCPU() { return CPU; }
    public String getRAM() { return RAM; }
    public String getStorage() { return storage; }
    public String getGraphicsCard() { return graphicsCard; }
    public String getMotherboard() { return motherboard; }
    public boolean isBluetoothEnabled() { return isBluetoothEnabled; }
    public boolean isWifiEnabled() { return isWifiEnabled; }
    
    // Builder class
    public static class ComputerBuilder {
        // Required parameters
        private final String CPU;
        private final String RAM;
        
        // Optional parameters - initialized to default values
        private String storage = "256GB SSD";
        private String graphicsCard = "Integrated";
        private String motherboard = "Standard";
        private boolean isBluetoothEnabled = false;
        private boolean isWifiEnabled = false;
        
        public ComputerBuilder(String CPU, String RAM) {
            this.CPU = CPU;
            this.RAM = RAM;
        }
        
        public ComputerBuilder setStorage(String storage) {
            this.storage = storage;
            return this;
        }
        
        public ComputerBuilder setGraphicsCard(String graphicsCard) {
            this.graphicsCard = graphicsCard;
            return this;
        }
        
        public ComputerBuilder setMotherboard(String motherboard) {
            this.motherboard = motherboard;
            return this;
        }
        
        public ComputerBuilder enableBluetooth() {
            this.isBluetoothEnabled = true;
            return this;
        }
        
        public ComputerBuilder enableWifi() {
            this.isWifiEnabled = true;
            return this;
        }
        
        public Computer build() {
            return new Computer(this);
        }
    }
}

// Usage
public class ComputerShop {
    public static void main(String[] args) {
        // Building a basic computer
        Computer basicComputer = new Computer.ComputerBuilder("Intel i5", "8GB")
            .build();
        
        // Building a gaming computer with all features
        Computer gamingComputer = new Computer.ComputerBuilder("Intel i9", "32GB")
            .setStorage("1TB NVMe SSD")
            .setGraphicsCard("NVIDIA RTX 4080")
            .setMotherboard("Gaming Motherboard")
            .enableBluetooth()
            .enableWifi()
            .build();
        
        // Building a computer with specific requirements
        Computer officeComputer = new Computer.ComputerBuilder("Intel i7", "16GB")
            .setStorage("512GB SSD")
            .enableWifi()
            .build();
    }
}

2. Structural Patterns

2.1 Adapter Pattern

Purpose: Convert the interface of a class into another interface clients expect.

When to Use:

  • When you want to use an existing class but its interface doesn't match what you need

  • When you want to create reusable classes that cooperate with unrelated classes

Code Example:

java

// Target interface (what the client expects)
public interface ModernPrinter {
    void printDocument(String document);
    void scanDocument();
    void faxDocument(String document);
}

// Adaptee (existing class with incompatible interface)
public class LegacyPrinter {
    public void print(String text) {
        System.out.println("Legacy Printing: " + text);
    }
    
    public void scan() {
        System.out.println("Legacy Scanning...");
    }
    
    // Note: No fax capability in legacy printer
}

// Adapter
public class PrinterAdapter implements ModernPrinter {
    private LegacyPrinter legacyPrinter;
    
    public PrinterAdapter(LegacyPrinter legacyPrinter) {
        this.legacyPrinter = legacyPrinter;
    }
    
    @Override
    public void printDocument(String document) {
        legacyPrinter.print(document);
    }
    
    @Override
    public void scanDocument() {
        legacyPrinter.scan();
    }
    
    @Override
    public void faxDocument(String document) {
        // Legacy printer doesn't support fax, so we simulate it
        System.out.println("Adapter: Fax simulation for - " + document);
        System.out.println("Document sent via email instead of fax");
    }
}

// Client code
public class Office {
    public static void main(String[] args) {
        LegacyPrinter oldPrinter = new LegacyPrinter();
        ModernPrinter adaptedPrinter = new PrinterAdapter(oldPrinter);
        
        // Client can use the modern interface
        adaptedPrinter.printDocument("Important Report");
        adaptedPrinter.scanDocument();
        adaptedPrinter.faxDocument("Urgent Memo");
    }
}

2.2 Decorator Pattern

Purpose: Attach additional responsibilities to an object dynamically.

When to Use:

  • When you want to add responsibilities to individual objects without affecting others

  • When extension by subclassing is impractical

Code Example:

java

// Component interface
public interface Coffee {
    String getDescription();
    double getCost();
}

// Concrete Component
public class SimpleCoffee implements Coffee {
    @Override
    public String getDescription() {
        return "Simple Coffee";
    }
    
    @Override
    public double getCost() {
        return 2.0;
    }
}

// Decorator abstract class
public abstract class CoffeeDecorator implements Coffee {
    protected Coffee decoratedCoffee;
    
    public CoffeeDecorator(Coffee coffee) {
        this.decoratedCoffee = coffee;
    }
    
    @Override
    public String getDescription() {
        return decoratedCoffee.getDescription();
    }
    
    @Override
    public double getCost() {
        return decoratedCoffee.getCost();
    }
}

// Concrete Decorators
public class MilkDecorator extends CoffeeDecorator {
    public MilkDecorator(Coffee coffee) {
        super(coffee);
    }
    
    @Override
    public String getDescription() {
        return decoratedCoffee.getDescription() + ", Milk";
    }
    
    @Override
    public double getCost() {
        return decoratedCoffee.getCost() + 0.5;
    }
}

public class SugarDecorator extends CoffeeDecorator {
    public SugarDecorator(Coffee coffee) {
        super(coffee);
    }
    
    @Override
    public String getDescription() {
        return decoratedCoffee.getDescription() + ", Sugar";
    }
    
    @Override
    public double getCost() {
        return decoratedCoffee.getCost() + 0.2;
    }
}

public class WhippedCreamDecorator extends CoffeeDecorator {
    public WhippedCreamDecorator(Coffee coffee) {
        super(coffee);
    }
    
    @Override
    public String getDescription() {
        return decoratedCoffee.getDescription() + ", Whipped Cream";
    }
    
    @Override
    public double getCost() {
        return decoratedCoffee.getCost() + 0.7;
    }
}

// Usage
public class CoffeeShop {
    public static void main(String[] args) {
        // Simple coffee
        Coffee coffee = new SimpleCoffee();
        System.out.println(coffee.getDescription() + " $" + coffee.getCost());
        
        // Coffee with milk and sugar
        Coffee milkSugarCoffee = new SugarDecorator(new MilkDecorator(new SimpleCoffee()));
        System.out.println(milkSugarCoffee.getDescription() + " $" + milkSugarCoffee.getCost());
        
        // Fancy coffee with everything
        Coffee fancyCoffee = new WhippedCreamDecorator(
                              new SugarDecorator(
                                new MilkDecorator(
                                  new SimpleCoffee())));
        System.out.println(fancyCoffee.getDescription() + " $" + fancyCoffee.getCost());
    }
}

3. Behavioral Patterns

3.1 Observer Pattern

Purpose: Define a one-to-many dependency between objects so when one changes state, all dependents are notified.

When to Use:

  • When a change to one object requires changing others, and you don't know how many need to be changed

  • When an object should be able to notify others without knowing who they are

Code Example:

java

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

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

// Concrete Subject
public class WeatherStation implements Subject {
    private List<Observer> observers;
    private float temperature;
    private float humidity;
    private float pressure;
    
    public WeatherStation() {
        this.observers = new ArrayList<>();
    }
    
    @Override
    public void registerObserver(Observer observer) {
        observers.add(observer);
    }
    
    @Override
    public void removeObserver(Observer observer) {
        observers.remove(observer);
    }
    
    @Override
    public void notifyObservers() {
        for (Observer observer : observers) {
            observer.update(temperature, humidity, pressure);
        }
    }
    
    public void setMeasurements(float temperature, float humidity, float pressure) {
        this.temperature = temperature;
        this.humidity = humidity;
        this.pressure = pressure;
        measurementsChanged();
    }
    
    private void measurementsChanged() {
        notifyObservers();
    }
}

// Observer interface
public interface Observer {
    void update(float temperature, float humidity, float pressure);
}

// Display element interface
public interface DisplayElement {
    void display();
}

// Concrete Observers
public class CurrentConditionsDisplay implements Observer, DisplayElement {
    private float temperature;
    private float humidity;
    private Subject weatherData;
    
    public CurrentConditionsDisplay(Subject weatherData) {
        this.weatherData = weatherData;
        weatherData.registerObserver(this);
    }
    
    @Override
    public void update(float temperature, float humidity, float pressure) {
        this.temperature = temperature;
        this.humidity = humidity;
        display();
    }
    
    @Override
    public void display() {
        System.out.println("Current conditions: " + temperature + "°C and " + humidity + "% humidity");
    }
}

public class StatisticsDisplay implements Observer, DisplayElement {
    private List<Float> temperatures;
    private Subject weatherData;
    
    public StatisticsDisplay(Subject weatherData) {
        this.weatherData = weatherData;
        this.temperatures = new ArrayList<>();
        weatherData.registerObserver(this);
    }
    
    @Override
    public void update(float temperature, float humidity, float pressure) {
        temperatures.add(temperature);
        display();
    }
    
    @Override
    public void display() {
        float avg = (float) temperatures.stream().mapToDouble(Float::doubleValue).average().orElse(0.0);
        float max = (float) temperatures.stream().mapToDouble(Float::doubleValue).max().orElse(0.0);
        float min = (float) temperatures.stream().mapToDouble(Float::doubleValue).min().orElse(0.0);
        
        System.out.println("Avg/Max/Min temperature: " + avg + "/" + max + "/" + min);
    }
}

// Usage
public class WeatherApp {
    public static void main(String[] args) {
        WeatherStation weatherStation = new WeatherStation();
        
        CurrentConditionsDisplay currentDisplay = new CurrentConditionsDisplay(weatherStation);
        StatisticsDisplay statisticsDisplay = new StatisticsDisplay(weatherStation);
        
        // Simulate weather changes
        weatherStation.setMeasurements(25.0f, 65.0f, 1013.1f);
        weatherStation.setMeasurements(26.5f, 70.0f, 1012.5f);
        weatherStation.setMeasurements(24.0f, 90.0f, 1014.0f);
    }
}

3.2 Strategy Pattern

Purpose: Define a family of algorithms, encapsulate each one, and make them interchangeable.

When to Use:

  • When you have multiple variations of an algorithm

  • When you want to avoid exposing complex, algorithm-specific data structures

  • When a class defines many behaviors that appear as multiple conditional statements

Code Example:

java

// Strategy interface
public interface PaymentStrategy {
    boolean pay(double amount);
    boolean validate();
}

// Concrete Strategies
public class CreditCardStrategy implements PaymentStrategy {
    private String cardNumber;
    private String cvv;
    private String expiryDate;
    
    public CreditCardStrategy(String cardNumber, String cvv, String expiryDate) {
        this.cardNumber = cardNumber;
        this.cvv = cvv;
        this.expiryDate = expiryDate;
    }
    
    @Override
    public boolean pay(double amount) {
        System.out.println("Paid " + amount + " using Credit Card");
        // Actual credit card processing logic
        return true;
    }
    
    @Override
    public boolean validate() {
        return cardNumber != null && cardNumber.length() == 16 &&
               cvv != null && cvv.length() == 3 &&
               expiryDate != null;
    }
}

public class PayPalStrategy implements PaymentStrategy {
    private String email;
    private String password;
    
    public PayPalStrategy(String email, String password) {
        this.email = email;
        this.password = password;
    }
    
    @Override
    public boolean pay(double amount) {
        System.out.println("Paid " + amount + " using PayPal");
        // Actual PayPal processing logic
        return true;
    }
    
    @Override
    public boolean validate() {
        return email != null && email.contains("@") && 
               password != null && password.length() >= 6;
    }
}

public class CryptoStrategy implements PaymentStrategy {
    private String walletAddress;
    
    public CryptoStrategy(String walletAddress) {
        this.walletAddress = walletAddress;
    }
    
    @Override
    public boolean pay(double amount) {
        System.out.println("Paid " + amount + " using Cryptocurrency");
        // Actual cryptocurrency processing logic
        return true;
    }
    
    @Override
    public boolean validate() {
        return walletAddress != null && walletAddress.startsWith("0x") && walletAddress.length() == 42;
    }
}

// Context class
public class PaymentProcessor {
    private PaymentStrategy paymentStrategy;
    
    public void setPaymentStrategy(PaymentStrategy paymentStrategy) {
        this.paymentStrategy = paymentStrategy;
    }
    
    public boolean processPayment(double amount) {
        if (paymentStrategy == null) {
            System.out.println("No payment strategy set!");
            return false;
        }
        
        if (!paymentStrategy.validate()) {
            System.out.println("Payment validation failed!");
            return false;
        }
        
        return paymentStrategy.pay(amount);
    }
}

// Usage
public class ShoppingCart {
    public static void main(String[] args) {
        PaymentProcessor processor = new PaymentProcessor();
        
        // Pay with Credit Card
        processor.setPaymentStrategy(new CreditCardStrategy("1234567812345678", "123", "12/25"));
        processor.processPayment(100.0);
        
        // Pay with PayPal
        processor.setPaymentStrategy(new PayPalStrategy("user@example.com", "password123"));
        processor.processPayment(75.50);
        
        // Pay with Cryptocurrency
        processor.setPaymentStrategy(new CryptoStrategy("0x742d35Cc6634C0532925a3b8D"));
        processor.processPayment(200.0);
    }
}

Summary Table

Pattern Category Purpose When to Use
Singleton Creational Ensure one instance Logging, DB connections, config
Factory Method Creational Delegate instantiation When object type isn't known beforehand
Builder Creational Construct complex objects Many optional parameters, immutable objects
Adapter Structural Make interfaces compatible Integrating legacy code, incompatible interfaces
Decorator Structural Add responsibilities dynamically When subclassing is impractical
Observer Behavioral One-to-many dependency Event handling, publish-subscribe scenarios
Strategy Behavioral Encapsulate algorithms Multiple algorithm variations, avoid conditionals

These patterns help create more maintainable, flexible, and understandable code by providing proven solutions to common design problems.

 

 

Comments (0)

No comments yet. Be the first to comment!

LecturreAI Assistant

Hello! I'm LecturreAI

What would you like to learn today?