Design patterns are reusable templates for solving common software design problems. They are not actual code but structured guidelines that help organize code efficiently, making it reusable, readable, and maintainable.
- Purpose:
- Solve recurring problems.
- Improve code quality and maintainability.
- Benefits:
- Standardized, proven solutions.
- Enhance collaboration using common pattern names.
- Make systems scalable and flexible.
- Types of Patterns:
- Creational Patterns: Focus on flexible, decoupled object creation mechanisms, such as the Singleton, Factory, and Builder patterns.
- Structural Patterns: Deal with the efficient composition of classes and objects to form larger structures, including the Adapter, Decorator, and Composite patterns.
- Behavioral Patterns: Focus on managing the interactions and behavior between objects, such as the Observer, Strategy, and Command patterns.
- When to Use:
- For recurring design problems or scalable application development.
- Learning Tips:
- Understand the problem.
- Study examples and practice implementation.
In essence, design patterns are like blueprints that simplify complex designs, reduce bugs, and make systems easier to develop and maintain.
What is it?
The Singleton pattern ensures that a class has only one instance throughout the application. This is useful when you need to control access to shared resources, like a configuration file or database connection pool.
When to Use?
It's ideal when creating multiple instances of a class is inefficient or unnecessary. For example, managing a single configuration for your application.
How it Works?
The class contains a private static variable that holds the single instance. The constructor is private, preventing the creation of more instances, and a public static method is provided to fetch the instance.
Example
public class Singleton {
private static Singleton instance;
// Private constructor to prevent instantiation
private Singleton() {
// Private constructor
}
// Public method to provide access to the instance
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
// Method to demonstrate functionality (optional)
public void showMessage() {
System.out.println("Singleton instance is working!");
}
public static void main(String[] args) {
// Get the only instance of Singleton
Singleton singleton = Singleton.getInstance();
// Call a method on the Singleton instance
singleton.showMessage();
// Verifying that only one instance is created
Singleton anotherInstance = Singleton.getInstance();
System.out.println("Are both instances equal? " + (singleton == anotherInstance));
}
}
What is it?
The Factory pattern provides a method to create objects, but the exact class type to be instantiated is decided at runtime. The Factory pattern abstracts the object creation process and promotes loose coupling between the client and the object it uses.
When to Use?
When you want to decouple the object creation logic from the rest of the application, particularly when the client code should not be aware of the specific classes being used.
How it Works?
A Factory class is used to instantiate objects based on specific inputs or conditions. The client interacts with the Factory, not directly with the objects themselves.
Example
public class Main {
// Interface Shape
public interface Shape {
void draw();
}
// Circle class implementing Shape interface
public static class Circle implements Shape {
public void draw() {
System.out.println("Drawing Circle");
}
}
// ShapeFactory class
public static class ShapeFactory {
public Shape getShape(String shapeType) {
if (shapeType.equalsIgnoreCase("CIRCLE")) {
return new Circle();
}
return null;
}
}
// Main method to demonstrate the use of ShapeFactory
public static void main(String[] args) {
// Create a ShapeFactory instance
ShapeFactory shapeFactory = new ShapeFactory();
// Get a Circle shape using ShapeFactory
Shape shape = shapeFactory.getShape("CIRCLE");
// Call the draw method
if (shape != null) {
shape.draw(); // Output: Drawing Circle
} else {
System.out.println("Shape not found");
}
}
}
What is it?
The Builder pattern is used to construct complex objects step by step. It’s particularly useful for objects with many optional parameters or when you want to ensure the object is immutable after its creation.
When to Use?
It’s helpful when the object construction involves multiple parameters, some of which might be optional, or when you want more control over the initialization process.
How it Works?
The Builder class provides methods to set the object’s parameters and a final method to build the object. This pattern simplifies the construction process by making it more readable and flexible.
Example
public class Car {
private String engine;
private int wheels;
private Car(CarBuilder builder) {
this.engine = builder.engine;
this.wheels = builder.wheels;
}
public static class CarBuilder {
private String engine;
private int wheels;
public CarBuilder setEngine(String engine) {
this.engine = engine;
return this;
}
public CarBuilder setWheels(int wheels) {
this.wheels = wheels;
return this;
}
public Car build() {
return new Car(this);
}
}
public static void main(String[] args) {
Car car = new Car.CarBuilder()
.setEngine("V8")
.setWheels(4)
.build();
System.out.println(car);
}
}
What is it?
The Prototype pattern allows for creating new objects by cloning existing ones. This is especially useful when object creation is expensive and you want to avoid redundant instantiation.
When to Use?
It’s ideal when cloning an object is more efficient than creating a new one from scratch, or when an object has complex initialization that can be replicated.
How it Works?
A class implements the Cloneable
interface and provides a method to clone itself. This allows new instances to be created by copying an existing object, rather than instantiating a new one.
Example
public class Vehicle implements Cloneable {
private String type;
public Vehicle(String type) {
this.type = type;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
@Override
public Object clone() {
try {
return super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError(); // Shouldn't happen since we implement Cloneable
}
}
@Override
public String toString() {
return "Vehicle{type='" + type + "'}";
}
public static void main(String[] args) {
Vehicle vehicle1 = new Vehicle("Car");
try {
Vehicle vehicle2 = (Vehicle) vehicle1.clone();
System.out.println(vehicle1); // Vehicle{type='Car'}
System.out.println(vehicle2); // Vehicle{type='Car'}
// Modify the clone
vehicle2.setType("Bike");
System.out.println(vehicle1); // Vehicle{type='Car'}
System.out.println(vehicle2); // Vehicle{type='Bike'}
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
}
}
What is it?
The Adapter pattern is used to enable two incompatible interfaces to work together. It acts as a bridge between the client and the existing system by converting one interface into another.
When to Use?
It’s commonly used when integrating legacy systems or when you need to adapt new functionality to an existing system without altering the original system's code.
How it Works?
An Adapter class implements the required interface and delegates calls to the existing system. This makes the system work with the new interface seamlessly.
Example
public interface MediaPlayer {
void play(String audioType, String fileName);
}
public class AdvancedMediaPlayer {
public void playMp4(String fileName) {
System.out.println("Playing MP4: " + fileName);
}
public void playVlc(String fileName) {
System.out.println("Playing VLC: " + fileName);
}
}
public class MediaAdapter implements MediaPlayer {
private AdvancedMediaPlayer advancedMediaPlayer;
public MediaAdapter(String audioType) {
advancedMediaPlayer = new AdvancedMediaPlayer();
// You could handle different formats here
if (audioType.equalsIgnoreCase("MP4")) {
// Setup for MP4
} else if (audioType.equalsIgnoreCase("VLC")) {
// Setup for VLC
}
}
@Override
public void play(String audioType, String fileName) {
if (audioType.equalsIgnoreCase("MP4")) {
advancedMediaPlayer.playMp4(fileName);
} else if (audioType.equalsIgnoreCase("VLC")) {
advancedMediaPlayer.playVlc(fileName);
}
}
}
public class AudioPlayer implements MediaPlayer {
private MediaAdapter mediaAdapter;
@Override
public void play(String audioType, String fileName) {
if (audioType.equalsIgnoreCase("MP3")) {
System.out.println("Playing MP3: " + fileName);
} else if (audioType.equalsIgnoreCase("MP4") || audioType.equalsIgnoreCase("VLC")) {
mediaAdapter = new MediaAdapter(audioType);
mediaAdapter.play(audioType, fileName);
} else {
System.out.println("Invalid audio type: " + audioType);
}
}
}
public class AdapterPatternDemo {
public static void main(String[] args) {
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");
}
}
What is it?
The Observer pattern allows an object (called the "subject") to notify other objects (called "observers") when its state changes. This is particularly useful for event-driven systems.
When to Use?
It’s used when multiple objects need to be updated based on a change in another object, such as in GUI applications or messaging systems.
How it Works?
The subject maintains a list of observers and notifies them whenever its state changes. Observers subscribe to the subject to receive updates automatically.
Example
import java.util.ArrayList;
import java.util.List;
public class Main {
// Observer interface
public interface Observer {
void update(String message);
}
// Concrete Observer (NewsChannel)
public static class NewsChannel implements Observer {
public void update(String message) {
System.out.println("News Channel received: " + message);
}
}
// Subject (NewsAgency)
public static class NewsAgency {
private List<Observer> observers = new ArrayList<>();
public void addObserver(Observer observer) {
observers.add(observer);
}
public void notifyObservers(String message) {
for (Observer observer : observers) {
observer.update(message);
}
}
}
// Main method to demonstrate Observer Pattern
public static void main(String[] args) {
// Create NewsAgency (Subject)
NewsAgency newsAgency = new NewsAgency();
// Create Observers (NewsChannels)
Observer channel1 = new NewsChannel();
Observer channel2 = new NewsChannel();
// Register observers with the news agency
newsAgency.addObserver(channel1);
newsAgency.addObserver(channel2);
// Notify observers with a message
newsAgency.notifyObservers("Breaking News: New technology released!");
}
}
What is it?
The Strategy pattern defines a family of algorithms and allows them to be used interchangeably. It eliminates the need for complex conditional statements by encapsulating each algorithm in a separate class.
When to Use?
It’s helpful when you have different variations of an algorithm that can be switched dynamically, such as different payment methods or sorting strategies.
How it Works?
A common interface defines the algorithm’s structure, and multiple concrete strategies implement the interface. The client can choose the appropriate strategy based on the context.
Example
public class Main {
// PaymentStrategy interface
public interface PaymentStrategy {
void pay(int amount);
}
// CreditCardPayment class implementing PaymentStrategy
public static class CreditCardPayment implements PaymentStrategy {
public void pay(int amount) {
System.out.println("Paid with Credit Card: " + amount);
}
}
// ShoppingCart class that uses PaymentStrategy
public static class ShoppingCart {
private PaymentStrategy paymentStrategy;
// Constructor to initialize the payment strategy
public ShoppingCart(PaymentStrategy paymentStrategy) {
this.paymentStrategy = paymentStrategy;
}
// Checkout method that uses the payment strategy
public void checkout(int amount) {
paymentStrategy.pay(amount);
}
}
// Main method to demonstrate Strategy Pattern
public static void main(String[] args) {
// Create an instance of CreditCardPayment (payment strategy)
PaymentStrategy creditCardPayment = new CreditCardPayment();
// Create a ShoppingCart and set its payment strategy
ShoppingCart cart = new ShoppingCart(creditCardPayment);
// Checkout with a specific amount
cart.checkout(150); // Output: Paid with Credit Card: 150
}
}
What is it?
The Decorator pattern allows you to add new functionality to an existing object dynamically without modifying its structure. It provides a flexible way to extend behavior.
When to Use?
It’s used when you need to add responsibilities to individual objects without affecting the entire class. It follows the Open/Closed Principle by allowing extensions without changing the core class.
How it Works?
The Decorator class implements the same interface as the object it decorates. The decorator can modify or add behavior while delegating the base functionality to the original object.
Example
public class Main {
// Coffee interface
public interface Coffee {
String getDescription();
double getCost();
}
// SimpleCoffee class implementing Coffee interface
public static class SimpleCoffee implements Coffee {
public String getDescription() {
return "Simple Coffee";
}
public double getCost() {
return 5;
}
}
// MilkDecorator class implementing Coffee interface to add milk
public static class MilkDecorator implements Coffee {
private Coffee coffee;
public MilkDecorator(Coffee coffee) {
this.coffee = coffee;
}
public String getDescription() {
return coffee.getDescription() + ", Milk";
}
public double getCost() {
return coffee.getCost() + 1.5;
}
}
// Main method to demonstrate the Decorator Pattern
public static void main(String[] args) {
// Create a simple coffee
Coffee simpleCoffee = new SimpleCoffee();
// Print the description and cost of the simple coffee
System.out.println(simpleCoffee.getDescription() + " | Cost: " + simpleCoffee.getCost());
// Decorate the simple coffee with milk
Coffee milkCoffee = new MilkDecorator(simpleCoffee);
// Print the description and cost of the coffee with milk
System.out.println(milkCoffee.getDescription() + " | Cost: " + milkCoffee.getCost());
}
}
Understanding these design patterns is essential for Java developers. They provide standardized solutions to common problems, allowing developers to write cleaner, more maintainable, and flexible code. Whether you are building large-scale systems or simple applications, these patterns help to structure your code in a way that is both efficient and easy to understand.
This article provided an overview of design patterns, including creational, structural, and behavioral types, highlighting their benefits in solving software design problems, improving code quality, and enhancing maintainability. It covered key patterns like Singleton, Factory, and Observer, demonstrating when to apply them. In conclusion, design patterns offer proven solutions that help developers create efficient, scalable, and maintainable systems.