Design patterns are tried-and-tested solutions to common problems in software design. They provide a structured approach to solving issues that frequently occur in software development. In Java, these patterns are particularly useful due to the language's object-oriented nature. This article will delve into some of the most common Java design patterns, explaining their structure, use cases, and providing practical examples.

Creational Patterns

Creational patterns deal with object creation mechanisms, trying to create objects in a manner suitable to the situation. Let's explore some of the most widely used creational patterns in Java.

1. Singleton Pattern

The Singleton pattern ensures a class has only one instance and provides a global point of access to it. This is useful when exactly one object is needed to coordinate actions across the system.

🔑 Key points:

  • Private constructor to prevent instantiation
  • Static method to return the instance
  • Thread-safe implementation is crucial

Here's a thread-safe implementation of the Singleton pattern:

public class Singleton {
    private static volatile Singleton instance;
    private String data;

    private Singleton(String data) {
        this.data = data;
    }

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

    public String getData() {
        return data;
    }
}

In this example, we use double-checked locking to ensure thread safety. The volatile keyword ensures that changes to the instance variable are immediately visible to other threads.

Usage:

Singleton singleton = Singleton.getInstance("Hello, Singleton!");
System.out.println(singleton.getData()); // Output: Hello, Singleton!

Singleton anotherSingleton = Singleton.getInstance("This won't change the data");
System.out.println(anotherSingleton.getData()); // Output: Hello, Singleton!

2. Factory Method Pattern

The Factory Method pattern defines an interface for creating an object but lets subclasses decide which class to instantiate. It promotes loose coupling by eliminating the need to bind application-specific classes into the code.

🔑 Key points:

  • Defines an interface for creating objects
  • Lets subclasses decide which class to instantiate
  • Promotes flexibility and extensibility

Here's an example of the Factory Method pattern:

interface Animal {
    void makeSound();
}

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

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

abstract class AnimalFactory {
    abstract Animal createAnimal();
}

class DogFactory extends AnimalFactory {
    @Override
    Animal createAnimal() {
        return new Dog();
    }
}

class CatFactory extends AnimalFactory {
    @Override
    Animal createAnimal() {
        return new Cat();
    }
}

Usage:

AnimalFactory dogFactory = new DogFactory();
Animal dog = dogFactory.createAnimal();
dog.makeSound(); // Output: Woof!

AnimalFactory catFactory = new CatFactory();
Animal cat = catFactory.createAnimal();
cat.makeSound(); // Output: Meow!

This pattern allows for easy extension. If we want to add a new animal, we simply create a new class implementing the Animal interface and a corresponding factory.

Structural Patterns

Structural patterns deal with object composition, creating relationships between objects to form larger structures. Let's examine some common structural patterns in Java.

3. Adapter Pattern

The Adapter pattern allows incompatible interfaces to work together. It acts as a bridge between two incompatible interfaces by wrapping the interface of a class into another interface a client expects.

🔑 Key points:

  • Allows classes with incompatible interfaces to work together
  • Wraps an existing class with a new interface
  • Improves reusability of older code

Here's an example of the Adapter pattern:

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

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

class VlcPlayer implements AdvancedMediaPlayer {
    @Override
    public void playVlc(String fileName) {
        System.out.println("Playing vlc file. Name: " + fileName);
    }

    @Override
    public void playMp4(String fileName) {
        // do nothing
    }
}

class Mp4Player implements AdvancedMediaPlayer {
    @Override
    public void playVlc(String fileName) {
        // do nothing
    }

    @Override
    public void playMp4(String fileName) {
        System.out.println("Playing mp4 file. Name: " + fileName);
    }
}

class MediaAdapter implements MediaPlayer {
    AdvancedMediaPlayer advancedMusicPlayer;

    public MediaAdapter(String audioType) {
        if (audioType.equalsIgnoreCase("vlc")) {
            advancedMusicPlayer = new VlcPlayer();
        } else if (audioType.equalsIgnoreCase("mp4")) {
            advancedMusicPlayer = new Mp4Player();
        }
    }

    @Override
    public void play(String audioType, String fileName) {
        if (audioType.equalsIgnoreCase("vlc")) {
            advancedMusicPlayer.playVlc(fileName);
        } else if (audioType.equalsIgnoreCase("mp4")) {
            advancedMusicPlayer.playMp4(fileName);
        }
    }
}

class AudioPlayer implements MediaPlayer {
    MediaAdapter mediaAdapter;

    @Override
    public void play(String audioType, String fileName) {
        if (audioType.equalsIgnoreCase("mp3")) {
            System.out.println("Playing mp3 file. Name: " + fileName);
        } else if (audioType.equalsIgnoreCase("vlc") || audioType.equalsIgnoreCase("mp4")) {
            mediaAdapter = new MediaAdapter(audioType);
            mediaAdapter.play(audioType, fileName);
        } else {
            System.out.println("Invalid media. " + audioType + " format not supported");
        }
    }
}

Usage:

AudioPlayer audioPlayer = new AudioPlayer();

audioPlayer.play("mp3", "beyond_the_horizon.mp3");
audioPlayer.play("mp4", "alone.mp4");
audioPlayer.play("vlc", "far_far_away.vlc");
audioPlayer.play("avi", "mind_me.avi");

Output:

Playing mp3 file. Name: beyond_the_horizon.mp3
Playing mp4 file. Name: alone.mp4
Playing vlc file. Name: far_far_away.vlc
Invalid media. avi format not supported

This pattern allows the AudioPlayer to play different types of audio formats by using the MediaAdapter.

4. Decorator Pattern

The Decorator pattern allows behavior to be added to individual objects, either statically or dynamically, without affecting the behavior of other objects from the same class.

🔑 Key points:

  • Adds new functionality to objects without altering their structure
  • More flexible alternative to subclassing for extending functionality
  • Can be used to add responsibilities to objects at runtime

Here's an example of the Decorator pattern:

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

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

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

abstract class CoffeeDecorator implements Coffee {
    protected Coffee decoratedCoffee;

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

    public double getCost() {
        return decoratedCoffee.getCost();
    }

    public String getDescription() {
        return decoratedCoffee.getDescription();
    }
}

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

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

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

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

    @Override
    public double getCost() {
        return super.getCost() + 0.2;
    }

    @Override
    public String getDescription() {
        return super.getDescription() + ", sugar";
    }
}

Usage:

Coffee coffee = new SimpleCoffee();
System.out.println(coffee.getDescription() + " $" + coffee.getCost());

Coffee milkCoffee = new Milk(coffee);
System.out.println(milkCoffee.getDescription() + " $" + milkCoffee.getCost());

Coffee sweetMilkCoffee = new Sugar(new Milk(coffee));
System.out.println(sweetMilkCoffee.getDescription() + " $" + sweetMilkCoffee.getCost());

Output:

Simple coffee $1.0
Simple coffee, milk $1.5
Simple coffee, milk, sugar $1.7

This pattern allows us to add new behaviors (like adding milk or sugar) to objects of the Coffee class without altering their structure.

Behavioral Patterns

Behavioral patterns are concerned with algorithms and the assignment of responsibilities between objects. Let's explore some common behavioral patterns in Java.

5. 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.

🔑 Key points:

  • Defines a one-to-many dependency between objects
  • When one object changes state, all its dependents are notified
  • Promotes loose coupling between objects

Here's an example of the Observer pattern:

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

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

interface Observer {
    void update(float temp, float humidity, float pressure);
}

class WeatherData implements Subject {
    private List<Observer> observers;
    private float temperature;
    private float humidity;
    private float pressure;

    public WeatherData() {
        observers = new ArrayList<Observer>();
    }

    @Override
    public void registerObserver(Observer o) {
        observers.add(o);
    }

    @Override
    public void removeObserver(Observer o) {
        observers.remove(o);
    }

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

class CurrentConditionsDisplay implements Observer {
    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();
    }

    public void display() {
        System.out.println("Current conditions: " + temperature 
            + "F degrees and " + humidity + "% humidity");
    }
}

Usage:

WeatherData weatherData = new WeatherData();
CurrentConditionsDisplay currentDisplay = new CurrentConditionsDisplay(weatherData);

weatherData.setMeasurements(80, 65, 30.4f);
weatherData.setMeasurements(82, 70, 29.2f);
weatherData.setMeasurements(78, 90, 29.2f);

Output:

Current conditions: 80.0F degrees and 65.0% humidity
Current conditions: 82.0F degrees and 70.0% humidity
Current conditions: 78.0F degrees and 90.0% humidity

In this example, WeatherData is the subject and CurrentConditionsDisplay is an observer. When the weather data changes, all registered observers are notified.

6. Strategy Pattern

The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. It lets the algorithm vary independently from clients that use it.

🔑 Key points:

  • Defines a family of algorithms
  • Encapsulates each algorithm
  • Makes the algorithms interchangeable within that family

Here's an example of the Strategy pattern:

interface PaymentStrategy {
    void pay(int amount);
}

class CreditCardStrategy implements PaymentStrategy {
    private String name;
    private String cardNumber;
    private String cvv;
    private String dateOfExpiry;

    public CreditCardStrategy(String nm, String ccNum, String cvv, String expiryDate){
        this.name=nm;
        this.cardNumber=ccNum;
        this.cvv=cvv;
        this.dateOfExpiry=expiryDate;
    }

    @Override
    public void pay(int amount) {
        System.out.println(amount + " paid with credit/debit card");
    }
}

class PayPalStrategy implements PaymentStrategy {
    private String emailId;
    private String password;

    public PayPalStrategy(String email, String pwd){
        this.emailId=email;
        this.password=pwd;
    }

    @Override
    public void pay(int amount) {
        System.out.println(amount + " paid using PayPal.");
    }
}

class ShoppingCart {
    private List<Item> items;

    public ShoppingCart(){
        this.items=new ArrayList<Item>();
    }

    public void addItem(Item item){
        this.items.add(item);
    }

    public void removeItem(Item item){
        this.items.remove(item);
    }

    public int calculateTotal(){
        int sum = 0;
        for(Item item : items){
            sum += item.getPrice();
        }
        return sum;
    }

    public void pay(PaymentStrategy paymentMethod){
        int amount = calculateTotal();
        paymentMethod.pay(amount);
    }
}

class Item {
    private String upcCode;
    private int price;

    public Item(String upc, int cost){
        this.upcCode=upc;
        this.price=cost;
    }

    public String getUpcCode() {
        return upcCode;
    }

    public int getPrice() {
        return price;
    }
}

Usage:

ShoppingCart cart = new ShoppingCart();

Item item1 = new Item("1234",10);
Item item2 = new Item("5678",40);

cart.addItem(item1);
cart.addItem(item2);

cart.pay(new PayPalStrategy("[email protected]", "mypwd"));
cart.pay(new CreditCardStrategy("John Doe", "1234567890123456", "786", "12/15"));

Output:

50 paid using PayPal.
50 paid with credit/debit card

In this example, we can change the payment strategy at runtime. The ShoppingCart doesn't need to know the details of the payment method, it just uses the strategy provided.

Conclusion

Design patterns are essential tools in a Java developer's toolkit. They provide tested, proven development paradigms that can speed up the development process by providing tested, robust solutions to common programming problems. However, it's important to remember that design patterns should be used judiciously. Overuse or misuse of design patterns can lead to unnecessarily complicated code.

The patterns we've explored in this article – Singleton, Factory Method, Adapter, Decorator, Observer, and Strategy – are just a few of the many design patterns available. Each pattern has its own strengths and is suited to solving particular types of problems.

As you continue your journey in Java development, you'll encounter situations where these patterns (and others) can be applied. The key is to understand the problem you're trying to solve and then determine if a particular design pattern fits that problem. With practice, you'll become more adept at recognizing these situations and applying the appropriate patterns.

Remember, the goal of using design patterns is to create more flexible, reusable, and maintainable code. Always keep this in mind when deciding whether to use a particular pattern in your projects.

Happy coding! 🚀👨‍💻👩‍💻