Skip to content

Design Patterns in Java

Question

Explain the key design patterns in Java, focusing on their implementation, use cases, and best practices. Include examples of Singleton, Factory, and Observer patterns.

Answer

Design patterns are reusable solutions to common problems in software design. They provide proven approaches to solving specific design problems and help create more maintainable, flexible, and scalable code.

1. Singleton Pattern

The Singleton pattern ensures a class has only one instance and provides a global point of access to it.

Implementation:

public class Singleton {
    // Private static instance
    private static volatile Singleton instance;

    // Private constructor to prevent instantiation
    private Singleton() {}

    // Public static method to get instance
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

Thread-Safe Implementation:

public class ThreadSafeSingleton {
    private static final ThreadSafeSingleton instance = new ThreadSafeSingleton();

    private ThreadSafeSingleton() {}

    public static ThreadSafeSingleton getInstance() {
        return instance;
    }
}

Use Cases:

public class ConfigurationManager {
    private static ConfigurationManager instance;
    private Properties properties;

    private ConfigurationManager() {
        properties = new Properties();
        loadProperties();
    }

    public static ConfigurationManager getInstance() {
        if (instance == null) {
            synchronized (ConfigurationManager.class) {
                if (instance == null) {
                    instance = new ConfigurationManager();
                }
            }
        }
        return instance;
    }

    public String getProperty(String key) {
        return properties.getProperty(key);
    }
}

2. Factory Pattern

The Factory pattern provides an interface for creating objects but lets subclasses decide which class to instantiate.

Simple Factory:

public interface Animal {
    void makeSound();
}

public class Dog implements Animal {
    @Override
    public void makeSound() {
        System.out.println("Woof!");
    }
}

public class Cat implements Animal {
    @Override
    public void makeSound() {
        System.out.println("Meow!");
    }
}

public class AnimalFactory {
    public static Animal createAnimal(String type) {
        switch (type.toLowerCase()) {
            case "dog":
                return new Dog();
            case "cat":
                return new Cat();
            default:
                throw new IllegalArgumentException("Unknown animal type");
        }
    }
}

Factory Method:

public abstract class Creator {
    public abstract Product factoryMethod();

    public void operation() {
        Product product = factoryMethod();
        product.operation();
    }
}

public class ConcreteCreator extends Creator {
    @Override
    public Product factoryMethod() {
        return new ConcreteProduct();
    }
}

Abstract Factory:

public interface GUIFactory {
    Button createButton();
    Checkbox createCheckbox();
}

public class WindowsFactory implements GUIFactory {
    @Override
    public Button createButton() {
        return new WindowsButton();
    }

    @Override
    public Checkbox createCheckbox() {
        return new WindowsCheckbox();
    }
}

public class MacFactory implements GUIFactory {
    @Override
    public Button createButton() {
        return new MacButton();
    }

    @Override
    public Checkbox createCheckbox() {
        return new MacCheckbox();
    }
}

3. Observer Pattern

The Observer pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically.

Implementation:

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;

    @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(news);
        }
    }

    public void setNews(String news) {
        this.news = news;
        notifyObservers();
    }
}

public class NewsChannel implements Observer {
    private String name;

    public NewsChannel(String name) {
        this.name = name;
    }

    @Override
    public void update(String news) {
        System.out.println(name + " received news: " + news);
    }
}

4. Builder Pattern

The Builder pattern separates the construction of a complex object from its representation.

Implementation:

public class Computer {
    private String cpu;
    private String ram;
    private String storage;

    private Computer(Builder builder) {
        this.cpu = builder.cpu;
        this.ram = builder.ram;
        this.storage = builder.storage;
    }

    public static class Builder {
        private String cpu;
        private String ram;
        private String storage;

        public Builder cpu(String cpu) {
            this.cpu = cpu;
            return this;
        }

        public Builder ram(String ram) {
            this.ram = ram;
            return this;
        }

        public Builder storage(String storage) {
            this.storage = storage;
            return this;
        }

        public Computer build() {
            return new Computer(this);
        }
    }
}

5. Strategy Pattern

The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable.

Implementation:

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

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

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

public class ShoppingCart {
    private PaymentStrategy paymentStrategy;

    public void setPaymentStrategy(PaymentStrategy paymentStrategy) {
        this.paymentStrategy = paymentStrategy;
    }

    public void checkout(int amount) {
        paymentStrategy.pay(amount);
    }
}

6. Decorator Pattern

The Decorator pattern attaches additional responsibilities to an object dynamically.

Implementation:

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

public class SimpleCoffee implements Coffee {
    @Override
    public double getCost() {
        return 1;
    }

    @Override
    public String getDescription() {
        return "Simple coffee";
    }
}

public abstract class CoffeeDecorator implements Coffee {
    protected Coffee coffee;

    public CoffeeDecorator(Coffee coffee) {
        this.coffee = coffee;
    }

    @Override
    public double getCost() {
        return coffee.getCost();
    }

    @Override
    public String getDescription() {
        return coffee.getDescription();
    }
}

public class MilkDecorator extends CoffeeDecorator {
    public MilkDecorator(Coffee coffee) {
        super(coffee);
    }

    @Override
    public double getCost() {
        return coffee.getCost() + 0.5;
    }

    @Override
    public String getDescription() {
        return coffee.getDescription() + ", milk";
    }
}

Best Practices

  1. Choose the Right Pattern

    // Use Singleton for global configuration
    public class Configuration {
        private static Configuration instance;
        private Properties props;
    
        private Configuration() {
            loadProperties();
        }
    
        public static Configuration getInstance() {
            if (instance == null) {
                instance = new Configuration();
            }
            return instance;
        }
    }
    
    // Use Factory for object creation
    public class DocumentFactory {
        public static Document createDocument(String type) {
            switch (type) {
                case "PDF":
                    return new PDFDocument();
                case "Word":
                    return new WordDocument();
                default:
                    throw new IllegalArgumentException();
            }
        }
    }
    

  2. Keep Patterns Simple

    // Simple Observer implementation
    public class EventEmitter {
        private List<Consumer<String>> listeners = new ArrayList<>();
    
        public void addListener(Consumer<String> listener) {
            listeners.add(listener);
        }
    
        public void emit(String event) {
            listeners.forEach(listener -> listener.accept(event));
        }
    }
    

Common Pitfalls

  1. Overusing Singleton

    // Bad - Global state
    public class GlobalState {
        private static GlobalState instance;
        private Map<String, Object> state = new HashMap<>();
    
        public static GlobalState getInstance() {
            if (instance == null) {
                instance = new GlobalState();
            }
            return instance;
        }
    }
    
    // Better - Dependency Injection
    public class Service {
        private final Configuration config;
    
        public Service(Configuration config) {
            this.config = config;
        }
    }
    

  2. Complex Factory Methods

    // Bad - Too many parameters
    public class ComplexFactory {
        public static Product createProduct(String type, String color, 
                                          int size, boolean isPremium) {
            // Complex creation logic
        }
    }
    
    // Better - Builder pattern
    public class ProductBuilder {
        private String type;
        private String color;
        private int size;
        private boolean isPremium;
    
        public ProductBuilder type(String type) {
            this.type = type;
            return this;
        }
    
        // ... other methods
    
        public Product build() {
            return new Product(type, color, size, isPremium);
        }
    }
    

Modern Java Features

  1. Records for Data Transfer Objects

    public record User(String name, String email, int age) {}
    

  2. Sealed Classes for Type Safety

    public sealed interface Shape permits Circle, Rectangle {
        double getArea();
    }
    
    public record Circle(double radius) implements Shape {
        @Override
        public double getArea() {
            return Math.PI * radius * radius;
        }
    }