ByteByteGo Newsletter

ByteByteGo Newsletter

Share this post

ByteByteGo Newsletter
ByteByteGo Newsletter
OOP Design Patterns and Anti-Patterns: What Works and What Fails
Copy link
Facebook
Email
Notes
More

OOP Design Patterns and Anti-Patterns: What Works and What Fails

ByteByteGo's avatar
ByteByteGo
Apr 10, 2025
∙ Paid
187
  • Share this post with a friend

    Since you liked this post, why not share it to help spread the word?

Share this post

ByteByteGo Newsletter
ByteByteGo Newsletter
OOP Design Patterns and Anti-Patterns: What Works and What Fails
Copy link
Facebook
Email
Notes
More
1
20
Share

Writing clean, maintainable, and scalable code sounds easy as a requirement, but is a constant challenge when developing real-world applications. 

As projects grow, the task becomes more complex. One way to simplify it is by identifying recurring design problems, which can be solved using appropriate design patterns.

Design patterns are proven, reusable solutions to common software design problems. They provide best practices and structured approaches for solving recurring challenges. These patterns are not concrete implementations but templates or guidelines that can be adapted to specific use cases.

But what makes design patterns important? Here are a few basic reasons:

  • Patterns often encourage modular and reusable code.

  • Well-structured designs make the codebase easier to understand, modify, and extend.

  • Patterns allow new features to be added without major rewrites.

  • Design patterns provide a common vocabulary for discussing software architecture, improving collaboration among developers.

However, anti-patterns also exist. They are often bad practices that lead to unmaintainable, inefficient, and overly complex code.

From a developer’s perspective, understanding both design patterns and anti-patterns is essential for writing high-quality software. Knowing design patterns helps developers apply the right solutions to common problems while recognizing anti-patterns allows them to avoid common mistakes that lead to bad architecture.

In this article, we’ll first look at the most popular OOP Design Patterns. Then, we will also investigate the common anti-patterns that should be avoided.

Popular OOP Design Patterns

Let us now examine the most popular OOP Design Patterns. For each pattern, we will understand its core meaning, describe the pattern with an example, and discuss its benefits and potential pitfalls.

Factory Pattern

The factory pattern is a creational design pattern that provides a centralized mechanism for creating objects.

Creating objects using the new keyword can lead to tight coupling between the client code and specific implementations. See the example below:

// Shape Interface
interface Shape {
    void draw();
}

// Concrete Implementations
class Circle implements Shape {
    @Override
    public void draw() {
        System.out.println("Drawing a Circle");
    }
}

class Square implements Shape {
    @Override
    public void draw() {
        System.out.println("Drawing a Square");
    }
}

// Client Code (Violating OCP & Creating Hardcoded Dependencies)
public class DrawingApp {
    public static void main(String[] args) {
        // Direct object creation using 'new' keyword
        Shape shape1 = new Circle();
        shape1.draw(); // Output: Drawing a Circle

        Shape shape2 = new Square();
        shape2.draw(); // Output: Drawing a Square
    }
}

Here, the DrawingApp class directly depends on the Circle and Square classes. If a new shape is introduced, the client must be modified to accommodate it. It violates the open/closed principle. Also, changing implementations requires modifying all occurrences of the previous class. 

The factory pattern helps centralize object creation, ensuring the client code remains unaware of concrete implementations. New types can be added with minimal modifications. The factory pattern can be thought of as a restaurant kitchen. Customers do not cook their food. Instead, they place an order, and the kitchen prepares the dish and provides it to them.

See the code example below of a simple factory class for creating shape objects and its usage in the main class (which acts as the client code) in the example:

class ShapeFactory {
    // Factory method to create Shape objects based on input type
    public static Shape getShape(String shapeType) {
        if (shapeType == null) {
            throw new IllegalArgumentException("Shape type cannot be null");
        }
        if (shapeType.equalsIgnoreCase("Circle")) {
            return new Circle();
        } else if (shapeType.equalsIgnoreCase("Square")) {
            return new Square();
        }
        throw new IllegalArgumentException("Unknown shape type: " + shapeType);
    }
}

public class Main {
    public static void main(String[] args) {
        // Creating shapes using the factory
        Shape shape1 = ShapeFactory.getShape("Circle");
        shape1.draw();  // Output: Drawing a Circle

        Shape shape2 = ShapeFactory.getShape("Square");
        shape2.draw();  // Output: Drawing a Square
    }
}

Note that this is just a basic code example to demonstrate the concept.

As mentioned, the factory pattern helps reduce coupling and encapsulate the complexity of creating an object. The instantiation logic is now in one place, making it easier to manage. When dealing with families of related objects, the abstract factory pattern extends this idea by centralizing the creation of entire groups of objects, ensuring consistency and reducing duplication.

However, the factory pattern also has some downsides that should be considered:

  • For simple object creation, a factory may be an unnecessary overhead.

  • If a single factory manages too many different object types, the factory class can become too large and difficult to maintain.

  • If a new shape is added, the factory needs to be changed. Thus, the open/closed principle is not completely avoided, but its violation is restricted to the factory class. Techniques to prevent this violation in factory classes, such as using a registry-based factory, are also available.

Singleton Pattern

The singleton pattern is a creational design pattern that ensures a class has only one instance throughout an application's lifecycle.

See the diagram below that shows the concept of this pattern:

In many scenarios, multiple instances of a class can cause issues, such as redundant memory usage, race conditions, or inconsistent states. The singleton pattern helps side-step such problems.

Some common use cases of this pattern are in database connection objects, logging instances, and configuration manager instances.

To properly implement a singleton, it must satisfy a few basic conditions:

  • Restrict object creation.

  • Provide a global access method.

  • Ensure thread safety in multi-threaded environments.

Here’s a code example of a basic singleton, which is not thread-safe.

class BasicSingleton {
    private static BasicSingleton instance;

    private BasicSingleton() {} // Private constructor prevents direct instantiation

    public static BasicSingleton getInstance() {
        if (instance == null) {
            instance = new BasicSingleton();
        }
        return instance;
    }
}

To make the singleton thread safe, we use double-checked locking with the volatile keyword. 

class Singleton {
    private static volatile Singleton instance; // Volatile ensures visibility across threads

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

    public static Singleton getInstance() {
        if (instance == null) { // First check
            synchronized (Singleton.class) {
                if (instance == null) { // Second check (after acquiring lock)
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

The synchronized block ensures only one thread can initialize the instance at a time and the volatile keyword provides visibility of the instance across threads. Double-checking is helpful to avoid synchronizing the method every time because synchronization can be costly if done repeatedly.

On a side note, this singleton implementation can be broken using reflection in Java. To avoid that, another way of creating singletons is by using Enums in Java. However, the Enum approach does not support lazy initialization.

Overall, the singleton pattern is a great way to manage shared resources within an application context. Since a singleton is initialized only when required, it also potentially saves memory.

However, the pattern also has downsides:

  • Singletons introduce a global state, which can lead to unexpected side effects in large applications.

  • Since singletons cannot be easily replaced or mocked, they make unit testing harder.

  • Since singletons live throughout the application lifecycle, poorly designed singletons can cause memory leaks.

Strategy Pattern

The strategy pattern is a behavioral design pattern that allows an object’s behavior to be selected dynamically at runtime without modifying the existing code.

In many applications, different behaviors need to be selected at runtime. Without the strategy pattern, developers often rely on conditional logic (if-else or switch-case), which violates OCP and makes the code difficult to extend and maintain.

Imagine an e-commerce website where customers can pay using different payment methods such as Credit Card or PayPal. Instead of hardcoding every possible payment method into the system, the strategy pattern allows each payment method to be implemented independently and selected at runtime. The diagram below shows the strategy pattern scenario.

Also, see the code example below for a basic implementation of the strategy pattern:

// Step 1: Define the Strategy Interface
interface PaymentStrategy {
    void pay(int amount);
}

// Step 2: Implement Concrete Payment Strategies

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

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

// Step 3: Implement the Context Class
// ShoppingCart class uses different payment strategies
class ShoppingCart {
    private PaymentStrategy paymentStrategy;

    // Allows setting a payment method dynamically
    public void setPaymentMethod(PaymentStrategy strategy) {
        this.paymentStrategy = strategy;
    }

    public void checkout(int amount) {
        if (paymentStrategy == null) {
            throw new IllegalStateException("Payment method not set.");
        }
        paymentStrategy.pay(amount);
    }
}

// Step 4: Demonstration of the Strategy Pattern
public class Main {
    public static void main(String[] args) {
        ShoppingCart cart = new ShoppingCart();

        // Selecting Credit Card Payment at runtime
        cart.setPaymentMethod(new CreditCardPayment());
        cart.checkout(100); // Output: Paid $100 using Credit Card.

        // Switching to PayPal Payment dynamically
        cart.setPaymentMethod(new PayPalPayment());
        cart.checkout(200); // Output: Paid $200 using PayPal.
    }
}

In this example, new payment methods can be added without modifying the existing ShoppingCart class. The existing code remains unchanged, reducing risk and improving maintainability.

One thing to note is that the Main class is still tightly coupled to the CreditCardPayment and the PayPalPayment class in this example. This isn’t ideal as we saw earlier and can be improved by using a factory or registry to provide the instances dynamically. 

While the strategy pattern improves flexibility and promotes open/closed principle, it can also increase the complexity of maintaining multiple strategy classes. 

Observer Pattern

The observer pattern is a behavioral design pattern that automatically allows one object (the subject) to notify multiple dependent objects (observers) about state changes.

See the diagram below for reference:

Common use cases of the observer pattern are notification systems, event listeners, stock market feeds, and message broadcast systems.

Here’s a simple example of the observer pattern implementation in Java.

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

// Step 1: Define the Observer Interface
interface Subscriber {
    void update(String videoTitle);
}

// Step 2: Implement Concrete Observers (Users)
class User implements Subscriber {
    private String userName;

    public User(String name) {
        this.userName = name;
    }

    @Override
    public void update(String videoTitle) {
        System.out.println(userName + " received notification: New video uploaded - " + videoTitle);
    }
}

// Step 3: Implement the Subject (Observable)
class Channel {
    private List<Subscriber> subscribers = new ArrayList<>();
    private String channelName;

    public Channel(String name) {
        this.channelName = name;
    }

    // Method to subscribe users
    public void subscribe(Subscriber subscriber) {
        subscribers.add(subscriber);
    }

    // Method to unsubscribe users
    public void unsubscribe(Subscriber subscriber) {
        subscribers.remove(subscriber);
    }

    // Notify all subscribers when new content is uploaded
    public void uploadVideo(String videoTitle) {
        System.out.println(channelName + " uploaded a new video: " + videoTitle);
        notifySubscribers(videoTitle);
    }

    // Notify each subscriber
    private void notifySubscribers(String videoTitle) {
        for (Subscriber subscriber : subscribers) {
            subscriber.update(videoTitle);
        }
    }
}

// Step 4: Demonstrate the Observer Pattern in Action
public class Main {
    public static void main(String[] args) {
        // Create a YouTube Channel (Subject)
        Channel techChannel = new Channel("ByteByteGo");

        // Create Users (Observers)
        User alice = new User("Alice");
        User bob = new User("Bob");
        User charlie = new User("Charlie");

        // Users subscribe to the channel
        techChannel.subscribe(alice);
        techChannel.subscribe(bob);
        techChannel.subscribe(charlie);

        // The channel uploads a new video
        techChannel.uploadVideo("Observer Pattern Explained");

        // Bob unsubscribes
        techChannel.unsubscribe(bob);

        // Another video is uploaded
        techChannel.uploadVideo("Strategy Pattern in Java");
    }
}

The main parts of this code are as follows:

  • Subscriber Interface: Defines the observer contract (how subscribers react to updates).

  • User Class (Concrete Observer): Implements Subscriber and defines how users react to video uploads.

  • Channel Class (Subject/Observable): Maintains a list of subscribers and notifies them when new content is uploaded.

  • Main Class (Client Code): Demonstrates the pattern in action.

The observer pattern helps reduce coupling and enhances scalability. It also encapsulates the behavior of each observer, which can be customized independently of other observers.

However, the observer pattern also has downsides such as:

  • If a subject has hundreds or thousands of observers, notifying all of them can cause performance bottlenecks.

  • Since observers react to changes automatically, debugging becomes harder because it’s not always clear which objects are triggering updates.

  • If an observer modifies shared data during an update, it may lead to unexpected behavior.

Decorator Pattern

The decorator pattern is a structural design pattern that allows dynamically adding behavior to objects without modifying their existing code.

See the diagram below that show this pattern:

Imagine ordering coffee at a restaurant. After starting with a basic coffee, we want to customize it with milk, sugar, caramel, or extra shots of espresso. Without the decorator pattern, the restaurant would need separate classes like MilkCoffee, SugarCoffee, MilkSugarCoffee, CaramelCoffee, and so on. However, with the decorator pattern, the base Coffee remains the same, and additional features are added dynamically at runtime.

Here’s a basic example of the decorator pattern implementation.

// Step 1: Define the Coffee Interface (Component)
interface Coffee {
    String getDescription();
    double cost();
}

// Step 2: Implement the Base Coffee Class (Concrete Component)
class SimpleCoffee implements Coffee {
    @Override
    public String getDescription() {
        return "Plain Coffee";
    }

    @Override
    public double cost() {
        return 5.0;
    }
}

// Step 3: Create an Abstract Decorator Class
abstract class CoffeeDecorator implements Coffee {
    protected Coffee decoratedCoffee; // Composition: Wraps another Coffee object

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

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

    @Override
    public double cost() {
        return decoratedCoffee.cost();
    }
}

// Step 4: Implement Concrete Decorators

// Adds Milk to Coffee
class MilkDecorator extends CoffeeDecorator {
    public MilkDecorator(Coffee coffee) {
        super(coffee);
    }

    @Override
    public String getDescription() {
        return decoratedCoffee.getDescription() + ", Milk";
    }

    @Override
    public double cost() {
        return decoratedCoffee.cost() + 1.5;
    }
}

// Adds Sugar to Coffee
class SugarDecorator extends CoffeeDecorator {
    public SugarDecorator(Coffee coffee) {
        super(coffee);
    }

    @Override
    public String getDescription() {
        return decoratedCoffee.getDescription() + ", Sugar";
    }

    @Override
    public double cost() {
        return decoratedCoffee.cost() + 0.5;
    }
}

// Step 5: Demonstrate the Decorator Pattern in Action
public class Main {
    public static void main(String[] args) {
        // Start with Plain Coffee
        Coffee myCoffee = new SimpleCoffee();
        System.out.println(myCoffee.getDescription() + " - $" + myCoffee.cost());

        // Add Milk
        myCoffee = new MilkDecorator(myCoffee);
        System.out.println(myCoffee.getDescription() + " - $" + myCoffee.cost());

        // Add Sugar
        myCoffee = new SugarDecorator(myCoffee);
        System.out.println(myCoffee.getDescription() + " - $" + myCoffee.cost());
    }
}

The decorator pattern makes it easy to add or remove features. It also lets us extend functionality without modifying existing code and adding deep inheritance hierarchies.

Some downsides of the decorator pattern are as follows:

  • Instead of one large class, there are many small decorator classes. If too many decorators are used, it can become difficult to manage.

  • Since behavior is distributed across multiple small classes, debugging may take more effort.

Adapter Pattern

The adapter pattern is a structural design pattern that allows two incompatible interfaces to work together without modifying their existing code.

Some common use cases of the adapter pattern are as follows:

  • Connecting legacy systems to new applications.

  • Integrating third-party APIs that have different data formats.

  • Making different hardware components communicate.

See the code example below that shows the adapter pattern in action using the analogy of a USB to HDMI adapter:

// Step 1: Define the HDMI Monitor Interface (Target)
interface HDMIMonitor {
    void displayHDMI(String content);
}

// Step 2: Implement the Concrete HDMI Monitor
class MyHDMIMonitor implements HDMIMonitor {
    @Override
    public void displayHDMI(String content) {
        System.out.println("Displaying on HDMI Monitor: " + content);
    }
}

// Step 3: Define the USB Device Interface (Incompatible Interface)
interface USBDevice {
    void sendUSB(String data);
}

// Step 4: Implement the Concrete USB Device
class FlashDrive implements USBDevice {
    @Override
    public void sendUSB(String data) {
        System.out.println("USB Device sending data: " + data);
    }
}

// Step 5: Implement the Adapter (USB to HDMI)
class USBToHDMIAdapter implements HDMIMonitor {
    private USBDevice usbDevice;

    // Constructor accepts a USB device
    public USBToHDMIAdapter(USBDevice usbDevice) {
        this.usbDevice = usbDevice;
    }

    @Override
    public void displayHDMI(String content) {
        System.out.println("Adapter converting USB data to HDMI format...");
        usbDevice.sendUSB(content); // Converts USB data to HDMI
    }
}

// Step 6: Demonstrate the Adapter Pattern in Client Code
public class Main {
    public static void main(String[] args) {
        // Directly using an HDMI Monitor
        HDMIMonitor hdmiMonitor = new MyHDMIMonitor();
        hdmiMonitor.displayHDMI("Direct HDMI Signal");

        // Using a USB Device (Incompatible)
        USBDevice usbFlashDrive = new FlashDrive();

        // Using an Adapter to connect USB to HDMI Monitor
        HDMIMonitor adapter = new USBToHDMIAdapter(usbFlashDrive);
        adapter.displayHDMI("USB Data Stream");
    }
}

The various parts of this code example are as follows:

  • HDMIMonitor Interface (Target): Represents HDMI monitors that accept HDMI input.

  • MyHDMIMonitor Class (Concrete Target): A real HDMI monitor that displays content.

  • USBDevice Interface (Incompatible Interface): Represents USB devices that send USB signals.

  • FlashDrive Class (Concrete Adaptee): A USB flash drive that sends USB data.

  • USBToHDMIAdapter Class (Adapter): Converts USB signals into HDMI-compatible format.

  • Main Class (Client): Demonstrates how adapter allows a USB device to work with an HDMI monitor.

The adapter pattern helps support different input and output formats. It also follows the open/closed principle and single responsibility principle.

However, the adapter pattern also has some downsides:

  • It introduces extra classes, which can increase code complexity.

  • If the adapter requires significant data transformation, it may introduce processing delays.

  • If we control the original class, it may be better to refactor it instead of using an adapter.

Command Pattern

The command pattern is a behavioral design pattern that encapsulates operations as objects.

When a system directly calls methods on objects, it creates tight coupling between the invoker (caller) and the receiver (actual object performing the action).

The command pattern solves this by encapsulating each operation as a command object. This pattern can be understood by looking at the remote control where each button on the remote is a command.

The diagram shows this example:

See the code example below for a sample implementation of the command pattern:

// Step 1: Define the Command Interface
interface Command {
    void execute();
    void undo();
}

// Step 2: Create the Receiver (Light)
class Light {
    public void turnOn() {
        System.out.println("Light is ON");
    }

    public void turnOff() {
        System.out.println("Light is OFF");
    }
}

// Step 3: Implement Concrete Commands
// Command to Turn Light On
class TurnOnCommand implements Command {
    private Light light;

    public TurnOnCommand(Light light) {
        this.light = light;
    }

    @Override
    public void execute() {
        light.turnOn();
    }

    @Override
    public void undo() {
        light.turnOff();
    }
}

// Command to Turn Light Off
class TurnOffCommand implements Command {
    private Light light;

    public TurnOffCommand(Light light) {
        this.light = light;
    }

    @Override
    public void execute() {
        light.turnOff();
    }

    @Override
    public void undo() {
        light.turnOn();
    }
}

// Step 4: Implement the Invoker (Remote Control)
class RemoteControl {
    private Command lastCommand;

    public void pressButton(Command command) {
        command.execute();
        lastCommand = command; // Store last command for undo
    }

    public void pressUndo() {
        if (lastCommand != null) {
            lastCommand.undo();
        } else {
            System.out.println("No command to undo.");
        }
    }
}

// Step 5: Demonstrate the Command Pattern in Client Code
public class Main {
    public static void main(String[] args) {
        // Create Receiver (Light)
        Light livingRoomLight = new Light();

        // Create Commands
        Command turnOn = new TurnOnCommand(livingRoomLight);
        Command turnOff = new TurnOffCommand(livingRoomLight);

        // Create Invoker (Remote Control)
        RemoteControl remote = new RemoteControl();

        // Execute Commands
        remote.pressButton(turnOn);  // Output: Light is ON
        remote.pressUndo();          // Output: Light is OFF

        remote.pressButton(turnOff); // Output: Light is OFF
        remote.pressUndo();          // Output: Light is ON
    }
}

Here’s what each part of the code is doing:

  • Command Interface: Defines the structure for executing and undoing actions.

  • Light Class (Receiver): The actual object being controlled.

  • TurnOnCommand & TurnOffCommand (Concrete Commands): Encapsulate the logic for turning the light on/off.

  • RemoteControl (Invoker): Triggers commands and supports undo functionality.

  • Main (Client Code): Demonstrates the Command Pattern in action.

As mentioned, the command pattern helps decouple command execution from the sender and is easy to extend.

However, it can also increase complexity since it requires multiple extra classes for different commands.

Proxy Pattern

The proxy pattern is a structural design pattern that provides a substitute or placeholder for another object.

Sometimes, direct access to an object is costly or risky. The proxy pattern solves this by intercepting requests and deciding when and how the real object should be accessed.

Common use cases are security proxy, virtual proxy (loading heavy objects when needed), remote proxy (handling network communication between objects), caching proxy, and logging proxy.

See the example below for a basic proxy pattern implementation:

// Step 1: Define the Image Interface (Subject)
interface Image {
    void display();
}

// Step 2: Implement the RealImage Class (Actual Heavy Object)
class RealImage implements Image {
    private String fileName;

    public RealImage(String fileName) {
        this.fileName = fileName;
        loadImageFromDisk(); // Simulate expensive operation
    }

    private void loadImageFromDisk() {
        System.out.println("Loading high-resolution image: " + fileName);
    }

    @Override
    public void display() {
        System.out.println("Displaying image: " + fileName);
    }
}

// Step 3: Implement the Proxy Class
class ImageProxy implements Image {
    private RealImage realImage;
    private String fileName;

    public ImageProxy(String fileName) {
        this.fileName = fileName;
    }

    @Override
    public void display() {
        if (realImage == null) {
            realImage = new RealImage(fileName); // Load only when needed
        }
        realImage.display();
    }
}

// Step 4: Demonstrate the Proxy Pattern in Client Code
public class Main {
    public static void main(String[] args) {
        // Using Proxy instead of directly creating RealImage
        Image image1 = new ImageProxy("Photo1.jpg");
        Image image2 = new ImageProxy("Photo2.jpg");

        // The images are not loaded yet
        System.out.println("Proxy created, but images are not loaded yet.");

        // Now we display the image (this triggers loading)
        image1.display(); // Loads and displays
        image1.display(); // Displays instantly (no loading again)

        // Display another image
        image2.display(); // Loads and displays
    }
}

The main parts of this code are as follows:

  • Image Interface (Subject): Defines the contract for displaying images.

  • RealImage Class (Heavy Object): Simulates a high-resolution image that is slow to load.

  • ImageProxy Class (Proxy): Delays image loading until display() is called.

  • Main Class (Client Code): Uses the proxy to manage image loading.

The diagram below shows this setup:

The proxy pattern helps delay expensive operations until they are needed. Also, it allows developers to add functionality (such as logging and caching) without modifying code.

As usual, the downsides of the proxy pattern include increased code complexity, potential increase in latency, and hidden dependencies.

Builder Pattern

The builder pattern is a creational design pattern that simplifies the construction of complex objects by separating the object creation process from its representation.

When creating objects with multiple parameters, we often face two issues:

  • Too many constructor overloads

  • Unclear parameter order

The builder pattern solves this by providing a step-by-step object construction process.

Let's implement a Car customization system, where:

  • Car (Product): Represents the final car object.

  • CarBuilder (Builder): Constructs Car step by step.

  • Main (Client Code): Uses CarBuilder to create different car configurations.

See the example below:

// Product: Car
class Car {
    private String engine;
    private int wheels;
    private boolean hasSunroof;
    private String color;

    // Private constructor to enforce object creation via Builder
    private Car(CarBuilder builder) {
        this.engine = builder.engine;
        this.wheels = builder.wheels;
        this.hasSunroof = builder.hasSunroof;
        this.color = builder.color;
    }

    @Override
    public String toString() {
        return "Car [Engine: " + engine + ", Wheels: " + wheels + ", Sunroof: " + hasSunroof + ", Color: " + color + "]";
    }

    // Builder Class
    public static class CarBuilder {
        private String engine;
        private int wheels;
        private boolean hasSunroof;
        private String color;

        // Step-by-step methods to set attributes
        public CarBuilder setEngine(String engine) {
            this.engine = engine;
            return this; // Enables method chaining
        }

        public CarBuilder setWheels(int wheels) {
            this.wheels = wheels;
            return this;
        }

        public CarBuilder setSunroof(boolean hasSunroof) {
            this.hasSunroof = hasSunroof;
            return this;
        }

        public CarBuilder setColor(String color) {
            this.color = color;
            return this;
        }

        // Method to construct the final Car object
        public Car build() {
            return new Car(this);
        }
    }
}

public class Main {
    public static void main(String[] args) {
        // Building a sports car with a sunroof
        Car sportsCar = new Car.CarBuilder()
                .setEngine("V8")
                .setWheels(4)
                .setSunroof(true)
                .setColor("Red")
                .build();

        System.out.println(sportsCar);

        // Building a basic sedan with only an engine and wheels
        Car basicCar = new Car.CarBuilder()
                .setEngine("V4")
                .setWheels(4)
                .build();

        System.out.println(basicCar);
    }
}

The builder pattern improves code readability and eliminates constructor overload issues. If new attributes are added, no changes are needed in the existing client code.

However, the potential downside is the increased complexity if the object is already simple. Therefore, this pattern is more suitable in the case of complex object construction.

Composite Pattern

The composite pattern is a structural design pattern that allows individual objects (leaf nodes) and groups of objects (composites) to be treated uniformly.

It is useful when objects are organized in a tree structure, such as company hierarchies and file systems. The problem with handling hierarchies manually is that objects can have sub-objects and are difficult to manage without a unified interface.

The composite pattern provides a unified interface. To demonstrate the same, we will implement a basic file system with the following classes:

  • FileSystemComponent (Component): Defines common operations for Files and Folders.

  • File (Leaf Node): Represents individual files.

  • Folder (Composite Node): Represents directories that contain files and subfolders.

See the code example below:

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

// Step 1: Define the Component Interface
interface FileSystemComponent {
    void showDetails();
}

// Step 2: Implement the File Class (Leaf Node)
class File implements FileSystemComponent {
    private String name;

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

    @Override
    public void showDetails() {
        System.out.println("File: " + name);
    }
}

// Step 3: Implement the Folder Class (Composite Node)
class Folder implements FileSystemComponent {
    private String name;
    private List<FileSystemComponent> components = new ArrayList<>();

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

    public void addComponent(FileSystemComponent component) {
        components.add(component);
    }

    public void removeComponent(FileSystemComponent component) {
        components.remove(component);
    }

    @Override
    public void showDetails() {
        System.out.println("Folder: " + name);
        for (FileSystemComponent component : components) {
            component.showDetails(); // Recursively call on sub-components
        }
    }
}

// Step 4: Demonstrate the Composite Pattern in Client Code
public class Main {
    public static void main(String[] args) {
        // Create Files
        FileSystemComponent file1 = new File("Document.pdf");
        FileSystemComponent file2 = new File("Photo.jpg");
        FileSystemComponent file3 = new File("Video.mp4");

        // Create a Folder and add Files
        Folder folder1 = new Folder("My Folder");
        folder1.addComponent(file1);
        folder1.addComponent(file2);

        // Create another Folder and add a File
        Folder folder2 = new Folder("Sub Folder");
        folder2.addComponent(file3);

        // Add subfolder to parent folder
        folder1.addComponent(folder2);

        // Display File System Structure
        folder1.showDetails();
    }
}

The composite pattern helps encapsulate complex hierarchies and follows the open/closed principle. 

However, potential downsides include overhead and managing parent-child relationships.

OOP Anti-Patterns

While the design patterns make a developer’s life easier, a developer should also be careful about avoiding anti-patterns. 

Anti-patterns in OOP lead to poor software design, making code harder to maintain, test, and extend. Some common anti-patterns are as follows:

1 - God Object

A God object is a class that takes on too many responsibilities, violating the single responsibility principle. 

Instead of delegating tasks, it controls multiple aspects of the system, making it difficult to modify or test. Here’s an example of a class that acts like a God object.

class GodObject {
    void processPayroll() { /* Payroll logic */ }
    void handleCustomerService() { /* Customer support */ }
    void manageHR() { /* Employee records */ }
}

This class is responsible for multiple concerns, making the system fragile.

2 - Circular Dependencies

Circular dependencies occur when two or more classes depend on each other, creating a dependency loop. 

This can lead to runtime errors, infinite loops, or difficulty in dependency injection frameworks like Spring. See the example below of a piece of code that creates an infinite loop when initializing objects.

class ClassA { private ClassB b; ClassA(ClassB b) { this.b = b; } }
class ClassB { private ClassA a; ClassB(ClassA a) { this.a = a; } }

3 - Tight Coupling

Tight coupling occurs when one class is highly dependent on another, making changes difficult and violating the open/closed principle.

See the example below:

class Car { 
   private Engine engine = new Engine(); 
   void start() { engine.start(); 
   } 
}

Summary

In this article, we have looked at OOP design-patterns and anti-patterns in detail with appropriate examples:

Let’s summarize the key learning points in brief:

  • Design patterns provide reusable solutions to common software design problems, making code more scalable, maintainable, and flexible.

  • Factory pattern encapsulates object creation to reduce tight coupling and improve code extensibility.

  • Singleton pattern ensures only one instance of a class exists within the context of an application.

  • Strategy pattern allows dynamic selection of behaviors at runtime without modifying existing code.

  • Observer pattern enables event-driven communication, where multiple observers react to changes in a subject.

  • Decorator pattern dynamically adds behavior to objects at runtime without modifying their structure.

  • Adapter pattern bridges incompatible interfaces, allowing reuse of existing code with new systems.

  • Command pattern encapsulates operations as objects.

  • Proxy pattern controls access to objects, improving security, performance, and lazy loading.

  • Builder pattern constructs complex objects step by step, improving readability and avoiding constructor overloads.

  • Composite pattern treats individual and grouped objects uniformly, simplifying hierarchical structures.

Ambika P's avatar
waldonhendricks's avatar
Olumide Ajose's avatar
Alessandro Gambin da Silva's avatar
Mário's avatar
187 Likes∙
20 Restacks
187
  • Share this post with a friend

    Since you liked this post, why not share it to help spread the word?

Share this post

ByteByteGo Newsletter
ByteByteGo Newsletter
OOP Design Patterns and Anti-Patterns: What Works and What Fails
Copy link
Facebook
Email
Notes
More
1
20
Share

Discussion about this post

W croco's avatar
Ravishing Rudey's avatar
Ravishing Rudey
Ravishing Rudey
4d

What you should be doing is disintangling these patterns from OOP.

Expand full comment
Like
Reply
Share
Understanding Database Types
The success of a software application often hinges on the choice of the right databases. As developers, we're faced with a vast array of database…
Apr 19, 2023 • 
Alex Xu
1,082

Share this post

ByteByteGo Newsletter
ByteByteGo Newsletter
Understanding Database Types
Copy link
Facebook
Email
Notes
More
12
System Design PDFs (2024 Edition - Latest)
High Resolution PDFs/Images Big Archive: System Design Blueprint: Kuberntes tools ecosystem: ByteByteGo Newsletter is a reader-supported publication. To…
May 17, 2022 • 
Alex Xu
2,943

Share this post

ByteByteGo Newsletter
ByteByteGo Newsletter
System Design PDFs (2024 Edition - Latest)
Copy link
Facebook
Email
Notes
More
129
Speedrunning Guide: Junior to Staff Engineer in 3 years
This is a guest newsletter by Ryan Peterman, who was promoted from Junior to Staff Engineer in 3 years at Meta.
Nov 14, 2024 • 
ByteByteGo
802

Share this post

ByteByteGo Newsletter
ByteByteGo Newsletter
Speedrunning Guide: Junior to Staff Engineer in 3 years
Copy link
Facebook
Email
Notes
More
A Crash Course in Networking
The Internet has become an integral part of our daily lives, shaping how we communicate, access information, and conduct business.
Jan 18, 2024 • 
ByteByteGo
955

Share this post

ByteByteGo Newsletter
ByteByteGo Newsletter
A Crash Course in Networking
Copy link
Facebook
Email
Notes
More
5
Clean Architecture 101: Building Software That Lasts
Modern software development often involves complex systems that need to adapt quickly to changes, whether it's user requirements, technology updates, or…
Jan 30 • 
ByteByteGo
539

Share this post

ByteByteGo Newsletter
ByteByteGo Newsletter
Clean Architecture 101: Building Software That Lasts
Copy link
Facebook
Email
Notes
More
1
Software Architecture Patterns
Software architects often encounter similar goals and problems repeatedly throughout their careers.
Sep 26, 2024 • 
ByteByteGo
416

Share this post

ByteByteGo Newsletter
ByteByteGo Newsletter
Software Architecture Patterns
Copy link
Facebook
Email
Notes
More
8
A Crash Course in CI/CD
Introduction
Apr 4, 2024 • 
ByteByteGo
591

Share this post

ByteByteGo Newsletter
ByteByteGo Newsletter
A Crash Course in CI/CD
Copy link
Facebook
Email
Notes
More
4
Mastering Idempotency: Building Reliable APIs
Idempotency is the property of an operation that ensures performing the same action multiple times produces the same outcome as doing it once.
Feb 6 • 
ByteByteGo
392

Share this post

ByteByteGo Newsletter
ByteByteGo Newsletter
Mastering Idempotency: Building Reliable APIs
Copy link
Facebook
Email
Notes
More
4
Kubernetes Made Easy: A Beginner’s Roadmap to Container Orchestration
Containers, led by technologies like Docker, offer a lightweight, portable, and consistent way to package applications and their dependencies.
Jan 2 • 
ByteByteGo
376

Share this post

ByteByteGo Newsletter
ByteByteGo Newsletter
Kubernetes Made Easy: A Beginner’s Roadmap to Container Orchestration
Copy link
Facebook
Email
Notes
More
7
HTTP1 vs HTTP2 vs HTTP3 - A Deep Dive
What has powered the incredible growth of the World Wide Web? There are several factors, but HTTP or Hypertext Transfer Protocol has played a…
May 9, 2024 • 
ByteByteGo
540

Share this post

ByteByteGo Newsletter
ByteByteGo Newsletter
HTTP1 vs HTTP2 vs HTTP3 - A Deep Dive
Copy link
Facebook
Email
Notes
More
6
© 2025 ByteByteGo
Privacy ∙ Terms ∙ Collection notice
Start WritingGet the app
Substack is the home for great culture

Share

Copy link
Facebook
Email
Notes
More

Update your profile

undefined subscriptions will be displayed on your profile (edit)

Skip for now

Only paid subscribers can comment on this post

Check your email

For your security, we need to re-authenticate you.

Click the link we sent to wenranlu@gmail.com, or click here to sign in.