In software engineering, transforming concepts into functioning code can be challenging.
As developers, our aim goes beyond just getting things to run; we also strive to ensure our code is maintainable, scalable, adaptable, and reusable.
As developers, our aim extends beyond merely ensuring functionality; we strive to create code that is maintainable, scalable, adaptable, and reusable.
Enter design patterns — the proven frameworks that enable us to address recurring design challenges with both elegance and efficiency.
At its core, a design pattern serves as a pre-made answer to frequent issues encountered in software design. These solutions act as shortcuts, conserving our time and effort by employing tried-and-true strategies that experts have honed over the years.
In this article, we’ll explore several crucial design patterns that all developers should know. We’ll discuss their principles, their benefits, and how to apply them in real-world projects. Whether you have trouble with object creation, structuring relationships between classes, or controlling object behaviors, there's a design pattern to assist you.
Let’s begin.
1. Singleton pattern
The Singleton pattern, a type of creational design pattern, guarantees that a class has just one instance while offering a universal access point to that instance. Put simply, it’s like making sure there's only one exclusive copy of a specific object in your program, which can be accessed from any part of your code.
Consider a straightforward everyday example: the clipboard. Imagine several applications or processes operating simultaneously on a computer, each trying to access the clipboard at the same time. If each application were to generate its own version of the clipboard for handling copy and paste tasks, it could result in conflicting data.
public class Clipboard {
private String value;
public void copy(String value) {
this.value = value;
}
public String paste() {
return value;
}
}
In the example provided above, we've created a Clipboard
class that can handle copying and pasting values. Nonetheless, if we instantiate several Clipboard
objects, each one will maintain its own distinct data.
public class Main {
public static void main(String[] args) {
Clipboard clipboard1 = new Clipboard();
Clipboard clipboard2 = new Clipboard();
clipboard1.copy("Java");
clipboard2.copy("Design patterns");
System.out.println(clipboard1.paste()); // output: Java
System.out.println(clipboard2.paste()); // output: Design patterns
}
}
Obviously, this situation is not optimal. We want both clipboard instances to show identical values. This is exactly when the Singleton pattern demonstrates its effectiveness.
public class Clipboard {
private String value;
private static Clipboard clipboard = null;
// Private constructor to prevent instantiation from outside
private Clipboard() {}
// Method to provide access to the singleton instance
public static Clipboard getInstance() {
if (clipboard == null) {
clipboard = new Clipboard();
}
return clipboard;
}
public void copy(String value) {
this.value = value;
}
public String paste() {
return value;
}
}
Using the Singleton pattern guarantees that just a single instance of the Clipboard
class is maintained during the entire runtime of the program.
public class Main {
public static void main(String[] args) {
// Getting the singleton instances
Clipboard clipboard1 = Clipboard.getInstance();
Clipboard clipboard2 = Clipboard.getInstance();
clipboard1.copy("Java");
clipboard2.copy("Design patterns");
System.out.println(clipboard1.paste()); // output: Design patterns
System.out.println(clipboard2.paste()); // output: Design patterns
}
}
At this point, both clipboard1
and clipboard2
are pointing to the same instance of the Clipboard
class, guaranteeing uniformity throughout the app.
2. Factory Design pattern
The Factory Design Pattern is a creational pattern that offers an interface for object creation in a base class while letting subclasses determine the specific class to instantiate. Put simply, it enables the delegation of the object creation process to the child classes.
Picture yourself developing a program that mimics a basic console-based calculator. This calculator can perform various operations such as addition, subtraction, multiplication, and division, each with distinct behaviors. Your goal is to generate these operation objects within the program according to the user's selection.
The challenge lies in finding a method to generate these operation objects without overly complicating your code or creating tight dependencies. Essentially, this means avoiding heavy reliance on specific operation classes within your code. Additionally, you want to ensure that adding new types of operations in the future can be done with minimal code modifications.
The Factory Design Pattern addresses this issue by offering a method to generate objects without needing to define their specific class. Instead, it entrusts the object creation process to a factory class.
- Clarify the product interface. (
Operation
).
public interface Operation {
double calculate(double number1, double number2);
}
2. Implement concrete products for each operation.
// for addition
public class AddOperation implements Operation{
@Override
public double calculate(double number1, double number2) {
return number1 + number2;
}
}
// for substration
public class SubOperation implements Operation{
@Override
public double calculate(double number1, double number2) {
return number1 - number2;
}
}
// for multiplication
public class MulOperation implements Operation{
@Override
public double calculate(double number1, double number2) {
return number1 * number2;
}
}
// for division
public class DivOperation implements Operation{
@Override
public double calculate(double number1, double number2) {
if(number2 == 0)
throw new ArithmeticException("Cannot divide by zero!");
return number1 / number2;
}
}
// An exception class invokes when user input invalid choice for operation
public class InvalidOperationException extends Exception{
public InvalidOperationException(String message) {
super(message);
}
}
3. Create a factory class (OperationFactory
) that includes a method (getInstance
) to generate objects based on certain parameters.
public interface OperationFactory {
Operation getInstance(int choice) throws InvalidOperation;
}
public class OperationFactoryImpl implements OperationFactory{
@Override
public Operation getInstance(int choice) throws InvalidOperationException {
if(choice==1)
return new AddOperation();
else if(choice==2)
return new SubOperation();
else if(choice==3)
return new MulOperation();
else if(choice==4)
return new DivOperation();
throw new InvalidOperation("Invalid operation selected!");
}
}
4. Utilize the factory to generate objects without being aware of their exact classes.
public static void main(String[] args) {
Scanner scan = new Scanner(System.in);
Output output = new ConsoleOutput();
try {
System.out.println("\n1. Addition(+)\n2. Subtraction(-)\n3. Multiplication(*)\n4. Division(/)");
// getting choice from user
System.out.println("\n\nSelect your operation (1-4): ");
int choice = scan.nextInt()
// getting 2 operands from user
System.out.println("Enter first operand: ");
double operand1 = scan.nextDouble();
System.out.println("Enter second operand: ");
double operand2 = scan.nextDouble();
// create opeartion instance based on user choice
OperationFactory operationFactory = new OperationFactoryImpl();
Operation operation = operationFactory.getInstance(choice);
// printing result
System.out.println("\nThis result is " + operation.calculate(operand1, operand2) + ".");
}
catch (InputMismatchException e) {
System.out.println("Invalid input type!\n");
}
catch (InvalidOperation | ArithmeticException e) {
System.out.println(e.getMessage());
}
scan.close();
}
In this instance, the Main
class showcases how the factory can be used to generate various operation objects without needing to know their precise implementation classes, promoting loose coupling. The interaction is solely with the factory interface. Additionally, adding new kinds of operations is straightforward and doesn't require alterations to the existing client code. All that's needed is to create a new specific product and, if necessary, update the factory.
3. Builder pattern
The Builder Pattern offers a method for creating an object by enabling you to configure its different properties (or attributes) sequentially.
While some parameters for an object may be optional, we often have to include all parameters, sending the optional ones as NULL. To address the problem of having many parameters, we can create a constructor for the required parameters and offer different setter methods for the optional ones.
This approach is especially helpful for handling objects that come with numerous optional settings or parameters.
Let's picture the scenario of creating a user entity. Users come with various attributes such as name, email, phone, and city, among others. In this case, name and email are mandatory fields, whereas phone and city are optional. Consequently, each user might possess a unique blend of these attributes: some might include a city, others might forego it; some might list a phone number, while others might not. The Builder Design Pattern is an excellent tool for crafting these users in a flexible, step-by-step manner.
// Main product class
public class User {
private String name; // required field
private String email; // required field
private String phone; // optional field
private String city; // optional field
public User(UserBuilder userBuilder) {
this.name = userBuilder.getName();
this.email = userBuilder.getEmail();
this.phone = userBuilder.getPhone();
this.city = userBuilder.getCity();
}
public static UserBuilder builder(String name, String email) {
return new UserBuilder(name, email);
}
@Override
public String toString() {
return "User = " +
"{ name: '" + name + '\'' +
", email: '" + email + '\'' +
", phone: '" + phone + '\'' +
", city: '" + city + '\'' +
" }";
}
// builder class
public static class UserBuilder {
private String name; // required field
private String email; // required field
private String phone = "unknown"; // optional field
private String city = "unknown"; // optional field
public UserBuilder(String name, String email) {
this.name = name;
this.email = email;
}
// getters
public UserBuilder name(String name) {
this.name = name;
return this;
}
public UserBuilder email(String email) {
this.email = email;
return this;
}
public UserBuilder phone(String phone) {
this.phone = phone;
return this;
}
public UserBuilder city(String city) {
this.city = city;
return this;
}
public User build() {
return new User(this);
}
}
}
- The
UserBuilder
class serves as an inner builder class tasked with creatingUser
objects. It includes fields that represent the different properties (name
,email
,phone
,city
) that may or may not be present. The class offers setter methods for each property which return the builder itself, including (name()
,phone()
,city()
,email()
), allowing for method chaining. - The User class is designed to represent the product you're constructing using the builder pattern. It contains private fields for the user's properties (
name
,email
,phone
,city
). TheUser
constructor accepts aUserBuilder
object and initializes its fields according to the builder's settings. Additionally, there is a static methodbuilder()
that creates a new instance ofUserBuilder
, making it easier to set up a new builder.
Here's an illustration of how this code can be utilized to create a user with optional properties:
public class Main {
public static void main(String[] args) {
User user1 = User
.builder("John", "john@abc@gmail.com")
.build();
System.out.println(user1); // User = { name: 'John', email: 'john@abc@gmail.com', phone: 'unknown', city: 'unknown' }
User user2 = User
.builder("Mary", "mary@abc@gmail.com")
.city("Colombo")
.build();
System.out.println(user2); // User = { name: 'Mary', email: 'mary@abc@gmail.com', phone: 'unknown', city: 'Colombo' }
}
}
So there you have it, folks—that’s the gist of builder patterns. This pattern comes in handy when dealing with complex objects that have numerous optional parameters. It simplifies and tidies up your code, making it more readable. Plus, it lets you create different versions of objects using the same builder by tweaking the parameters as required.
4. Adapter pattern
The Adapter pattern is a structural design pattern that enables objects with differing interfaces to collaborate. It serves as a bridge between two incompatible interfaces.
Envision a scenario where you have two classes or components that accomplish alike tasks, but they differ in their method names, parameter types, or structures. The Adapter pattern helps these mismatched interfaces to function together by offering a wrapper (the adapter) that converts the interface of one class into an interface that the client anticipates.
- Target is the interface expected by the client.
- Adaptee is the class that needs to be adapted.
- An adapter class serves the purpose of implementing the Target interface and encapsulating the Adaptee class.
- The Client class utilizes the adapter to communicate with the Adaptee via the Target interface.
// Target interface
interface CellPhone {
void call();
}
// Adaptee (the class to be adapted)
class FriendCellPhone {
public void ring() {
System.out.println("Ringing");
}
}
// Adapter class implementing the Target interface
class CellPhoneAdapter implements CellPhone {
private FriendCellPhone friendCellPhone;
public CellPhoneAdapter(FriendCellPhone friendCellPhone) {
this.friendCellPhone = friendCellPhone;
}
@Override
public void call() {
friendCellPhone.ring();
}
}
// Client class
public class AdapterMain {
public static void main(String[] args) {
// Using the adapter to make Adaptee work with Target interface
FriendCellPhone adaptee = new FriendCellPhone();
CellPhone adapter = new CellPhoneAdapter(adaptee);
adapter.call();
}
}
In this example:
- `
CellPhone
` is the expected interface for your client code, and you currently lack an implementation for it. - "
FriendCellPhone
is the class you should consider adapting or reusing (the Adaptee), as it includes a method calledring
. This approach eliminates the need to create a new implementation of theCellPhone
interface." - The
CellPhoneAdapter
serves as an adapter class that adheres to theCellPhone
interface and encapsulates aFriendCellPhone
instance. Within this adapter, thecall
method forwards its execution to thering
method of theFriendCellPhone
class. - The
AdapterMain
class acts as the client showcasing the Adapter pattern in practice.
Why adapter pattern?
- The Adaptee could be a class from a third-party library or an older codebase that you cannot alter directly. By employing an adapter, you can adjust its interface to align with the interface anticipated by the client without changing the original code.
- The client may need only certain features from the Adaptee. With an adapter, you can create a customized interface that reveals just the needed functionality, instead of showing the whole interface of the Adaptee.
- While it may appear that you could get the same result by directly creating an instance of the
Target
interface, using an adapter offers advantages in terms of reusability, maintainability, and flexibility of your code. This is particularly beneficial when working with existing codebases or third-party libraries.
5. Decorator pattern
The Decorator Pattern is a design approach in object-oriented programming that enables the addition of behavior to specific objects, either fixedly or flexibly, without altering how other objects of the same class behave.
In this design pattern, a base class or interface outlines the shared functionalities. Various decorator classes then enhance this by adding extra behaviors. These decorators encase the original object, making it better in a modular and adaptable manner.
Picture this: you're assigned to develop a drawing app where users can craft and personalize shapes with different decorative elements. The app should enable the seamless addition of new decorators for extra functionalities without altering the current code for shapes or other decorators.
Let's explore how we can accomplish this with the decorator pattern.
// Shape Interface
interface Shape {
void draw();
String getName();
}
// Concrete Shape: Circle
class Circle implements Shape {
private String name;
public Circle(String name) {
this.name = name;
}
public String getName() {
return name;
}
@Override
public void draw() {
System.out.println("Drawing circle, " + getName() + ".");
}
}
- Shape Interface: Establishes the fundamental actions that every shape must be capable of performing. In this context, it consists of the
draw()
function to render the shape andgetName()
to retrieve the shape’s name. - Circle Class: This class carries out the requirements of the
Shape
interface, standing for a specific geometric figure (here, a circle). It includes aname
attribute and defines thedraw()
method to illustrate a circle.
// Abstract Decorator Class
abstract class ShapeDecorator implements Shape {
private Shape decoratedShape;
public ShapeDecorator(Shape decoratedShape) {
this.decoratedShape = decoratedShape;
}
@Override
public void draw() {
decoratedShape.draw();
}
@Override
public String getName() {
return decoratedShape.getName();
}
}
- ShapeDecorator Abstract Class: This abstract class adopts the
Shape
interface. It holds a reference to aShape
object (the shape being decorated) and passes thedraw()
method to this object.
// Concrete Decorator: BorderDecorator
class BorderDecorator extends ShapeDecorator {
private String color;
private int widthInPxs;
public BorderDecorator(Shape decoratedShape, String color, int widthInPxs) {
super(decoratedShape);
this.color = color;
this.widthInPxs = widthInPxs;
}
@Override
public void draw() {
super.draw();
System.out.println("Adding " + widthInPxs + "px, " + color + " color border to " + getName() + ".");
}
}
// Concrete Decorator: ColorDecorator
class ColorDecorator extends ShapeDecorator {
private String color;
public ColorDecorator(Shape decoratedShape, String color) {
super(decoratedShape);
this.color = color;
}
@Override
public void draw() {
super.draw();
System.out.println("Filling with " + color + " color to " + getName() + ".");
}
}
- BorderDecorator and ColorDecorator Classes: These are specific decorator classes that build upon
ShapeDecorator
. They enhance the decorated shapes by adding extra features like borders and colors. They override thedraw()
method to incorporate their unique functionality while still invoking thedraw()
method of the shape they decorate.
// Main Class
public class DecoratorMain {
public static void main(String[] args) {
// Create a circle
Shape circle1 = new Circle("circle1");
// Decorate the circle with a border
Shape circle1WithBorder = new BorderDecorator(circle1, "red", 2);
// Decorate the circle with a color
Shape circle1WithBorderAndColor = new ColorDecorator(circle1WithBorder, "blue");
// Draw the decorated circle
circle1WithBorderAndColor.draw();
// output
// Drawing circle, circle1.
// Adding 2px, red color border to circle1.
// Filling with blue color to circle1.
}
}
- DecoratorMain Class: This class includes the
main()
method, which showcases the decorator pattern. In this method, a circle is created first, then it is adorned with a border, and subsequently, color is added to the decoration. The process concludes by invoking thedraw()
method to display the decorated shape.
Now, thanks to the introduction of the Decorator Pattern, our drawing application can beautifully adorn not just circles, but a wide range of geometric shapes like rectangles, triangles, and more. Furthermore, this pattern’s extensibility allows us to easily add new decorators that provide additional features such as transparency and a variety of border styles (solid, dotted), among others. This powerful enhancement capability, accomplished without modifying the shapes' fundamental structure, highlights the pattern’s effectiveness in encouraging code reusability, flexibility, and scalability.
6. Observer pattern
The Observer Pattern is a popular behavioral design pattern in object-oriented programming that creates a one-to-many relationship between objects. In this pattern, an object, known as the subject or observable, keeps track of a list of dependents, called observers, and informs them of any changes in its state, typically by invoking one of their methods.
Here’s how it works:
- Subject: This entity maintains the state and oversees the list of observers. It includes methods to add, remove, and update observers.
- Observer: This interface outlines the method(s) that the subject uses to alert the observer whenever there are state changes. Usually, observers will implement this interface.
- Concrete Subject: This class serves as the actual implementation of the subject interface. It keeps track of its state and alerts observers whenever there are changes in that state.
- Concrete Observer: This specific implementation of the observer interface actively registers with a subject so it can get notifications. It also defines the update method to handle any changes in state.
For a YouTube channel's subscriber situation, the channel plays the role of the subject, while the subscribers act as the observers. When a new video is uploaded on the YouTube channel, it sends a notification to all its subscribers, inviting them to watch the latest content.
Let’s implement this example in code,
public enum EventType {
NEW_VIDEO,
LIVE_STREAM
}
public class YoutubeEvent {
private EventType eventType;
private String topic;
public YoutubeEvent(EventType eventType, String topic) {
this.eventType = eventType;
this.topic = topic;
}
// getters ans setters
@Override
public String toString() {
return eventType.name() + " on " + topic;
}
}
- EventType: The
EventType
enum specifies the various categories of events that might happen, includingNEW_VIDEO
,LIVE_STREAM
, and others. - Event: The
YoutubeEvent
class signifies the system's events. It includes details like the event type and the topic.
public interface Subject {
void addSubscriber(Observer observer);
void removeSubscriber(Observer observer);
void notifyAllSubscribers(YoutubeEvent event);
}
public interface Observer {
void notifyMe(String youtubeChannelName, YoutubeEvent event);
}
- Subject: The
Subject
interface outlines methods for handling subscribers throughaddSubscriber
andremoveSubscriber
, as well as notifying them vianotifyAllSubscribers
when an event happens. - Observer: The
Observer
interface defines a method (notifyMe
) that is used by subjects to alert observers whenever there's a change in state.
package observer;
import java.util.ArrayList;
import java.util.List;
public class YoutubeChannel implements Subject{
private String name;
private List<Observer> subscribers = new ArrayList<>();
public YoutubeChannel(String name) {
this.name = name;
}
public String getName() {
return name;
}
@Override
public void addSubscriber(Observer observer) {
subscribers.add(observer);
}
@Override
public void removeSubscriber(Observer observer) {
subscribers.remove(observer);
}
@Override
public void notifyAllSubscribers(YoutubeEvent event) {
for(Observer observer: subscribers) {
observer.notifyMe(getName(), event);
}
}
}
- Specific Topic: The
YoutubeChannel
class follows theSubject
interface. This class keeps track of subscribers and updates them whenever there is a new event.
package observer;
public class YoutubeSubscriber implements Observer{
private String name;
public YoutubeSubscriber(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public void notifyMe(String youtubeChannelName, YoutubeEvent event) {
System.out.println("Dear " + getName() + ", Notification from " + youtubeChannelName + ": " + event);
}
}
- Concrete Observer: The
YoutubeSubscriber
class follows theObserver
interface, outlining how it should react when it receives notifications from a subject.
public class ObserverMain {
public static void main(String[] args) throws InterruptedException {
YoutubeChannel myChannel = new YoutubeChannel("MyChannel");
Observer john = new YoutubeSubscriber("John");
Observer bob = new YoutubeSubscriber("Bob");
Observer tom = new YoutubeSubscriber("Tom");
myChannel.addSubscriber(john);
myChannel.addSubscriber(bob);
myChannel.addSubscriber(tom);
myChannel.notifyAllSubscribers(new YoutubeEvent(EventType.NEW_VIDEO, "Design patterns"));
myChannel.removeSubscriber(tom);
System.out.println();
Thread.sleep(5000);
myChannel.notifyAllSubscribers(new YoutubeEvent(EventType.LIVE_STREAM, "JAVA for beginners"));
}
}
- Primary Class: The
ObserverMain
class hosts themain
method which we use to test our implementation. Within it, we create aYoutubeChannel
instance, subscribe users to the channel, notify them about a new video upload, remove one subscriber, and then inform the remaining subscribers about a live stream event.
// output
Dear John, Notification from MyChannel: NEW_VIDEO on Design patterns
Dear Bob, Notification from MyChannel: NEW_VIDEO on Design patterns
Dear Tom, Notification from MyChannel: NEW_VIDEO on Design patterns
Dear John, Notification from MyChannel: LIVE_STREAM on JAVA for beginners
Dear Bob, Notification from MyChannel: LIVE_STREAM on JAVA for beginners
Leveraging the Observer design pattern, YouTube channels can efficiently inform all their subscribers each time a new video is uploaded, without creating a strong dependency between the channel and its subscribers. This approach encourages a more adaptable and manageable design.
In conclusion, design patterns are essential resources for Java developers, providing tried-and-tested solutions to frequent design challenges and encouraging code reusability, maintainability, and scalability. By grasping and applying these patterns properly, developers can create robust, adaptable, and easily manageable software solutions. Though mastering design patterns demands practice and experience, their advantages in software development are invaluable. Whether one is engaged in a small project or a large-scale enterprise application, utilizing design patterns enables writing cleaner, more efficient code and ultimately becoming a more skilled Java developer.