Structural Design Patterns in Java
Structural Design Patterns in Java

Structural Design Patterns in Java

Structural design patterns are like blueprints for organizing and combining objects in your code. They help you build larger, more complex structures while keeping your code flexible, easy to maintain, and ready to grow as your application evolves.

In this post, we’ll explore some common structural design patterns and how they work in Java.

1. Adapter Pattern

What It Does: The Adapter pattern helps two incompatible interfaces work together. It acts like a translator, converting one interface into another so they can communicate.

When to Use It:

  • When you have two classes or systems that need to work together but have incompatible interfaces.
  • When you want to reuse an existing class, but its interface doesn’t match what you need.

Example:
Imagine you have a European plug (Adaptee) and an American socket (Target). The Adapter is like a travel adapter that lets the European plug work in the American socket.

interface Target {
    void request();
}

class Adaptee {
    public void specificRequest() {
        System.out.println("Adaptee's specific request.");
    }
}

class Adapter implements Target {
    private Adaptee adaptee;

    public Adapter(Adaptee adaptee) {
        this.adaptee = adaptee;
    }

    @Override
    public void request() {
        adaptee.specificRequest(); // Translate the request
    }
}

public class Client {
    public static void main(String[] args) {
        Target target = new Adapter(new Adaptee());
        target.request(); // Works seamlessly!
    }
}

2. Bridge Pattern

What It Does: The Bridge pattern separates an object’s abstraction (what it does) from its implementation (how it does it). This allows you to change the implementation without affecting the abstraction.

When to Use It:

  • When you want to avoid a permanent binding between an abstraction and its implementation.
  • When you want to extend a class in multiple independent ways.

Example:
Think of a remote control (Abstraction) and a TV (Implementation). The remote can work with different TVs without needing to know how each TV works.

interface Implementor {
    void operation();
}

class ConcreteImplementorA implements Implementor {
    @Override
    public void operation() {
        System.out.println("Implementation A");
    }
}

class ConcreteImplementorB implements Implementor {
    @Override
    public void operation() {
        System.out.println("Implementation B");
    }
}

abstract class Abstraction {
    protected Implementor implementor;

    public Abstraction(Implementor implementor) {
        this.implementor = implementor;
    }

    abstract void operation();
}

class RefinedAbstraction extends Abstraction {
    public RefinedAbstraction(Implementor implementor) {
        super(implementor);
    }

    @Override
    public void operation() {
        implementor.operation();
    }
}

public class Client {
    public static void main(String[] args) {
        Abstraction abstraction = new RefinedAbstraction(new ConcreteImplementorA());
        abstraction.operation(); // Uses Implementation A

        abstraction = new RefinedAbstraction(new ConcreteImplementorB());
        abstraction.operation(); // Uses Implementation B
    }
}

3. Composite Pattern

What It Does: The Composite pattern lets you treat individual objects and groups of objects (like a tree structure) in the same way. It’s useful for representing part-whole hierarchies.

When to Use It:

  • When you want to represent a hierarchy of objects (e.g., files and folders in a file system).
  • When you want to perform operations on both individual objects and groups of objects uniformly.

Example:
Think of a file system. A file is a single object, and a folder is a composite object that can contain files or other folders. You can perform operations (like “delete”) on both files and folders uniformly.

interface Component {
    void operation();
}

class Leaf implements Component {
    @Override
    public void operation() {
        System.out.println("Leaf operation.");
    }
}

class Composite implements Component {
    private List<Component> children = new ArrayList<>();

    @Override
    public void operation() {
        for (Component child : children) {
            child.operation(); // Perform operation on all children
        }
    }

    public void add(Component component) {
        children.add(component);
    }

    public void remove(Component component) {
        children.remove(component);
    }
}

public class Client {
    public static void main(String[] args) {
        Component leaf1 = new Leaf();
        Component leaf2 = new Leaf();
        Composite composite = new Composite();

        composite.add(leaf1);
        composite.add(leaf2);
        composite.operation(); // Calls operations on both leaves
    }
}

4. Decorator Pattern

What It Does: The Decorator pattern lets you add new features to an object without changing its structure. It’s like adding toppings to a pizza—you can customize it without altering the base.

When to Use It:

  • When you want to add responsibilities to objects dynamically without affecting other objects.
  • When you want to extend functionality without subclassing.

Example:

interface Component {
    void operation();
}

class ConcreteComponent implements Component {
    @Override
    public void operation() {
        System.out.println("Basic operation.");
    }
}

abstract class Decorator implements Component {
    protected Component component;

    public Decorator(Component component) {
        this.component = component;
    }

    @Override
    public void operation() {
        component.operation();
    }
}

class ConcreteDecoratorA extends Decorator {
    public ConcreteDecoratorA(Component component) {
        super(component);
    }

    @Override
    public void operation() {
        super.operation();
        System.out.println("Added behavior from Decorator A.");
    }
}

public class Client {
    public static void main(String[] args) {
        Component component = new ConcreteComponent();
        Component decoratedComponent = new ConcreteDecoratorA(component);
        decoratedComponent.operation(); // Basic + Decorator behavior
    }
}

5. Facade Pattern

What It Does: The Facade pattern provides a simple interface to a complex system. It hides the complexity and makes it easier to use.

When to Use It:

  • When you want to simplify interactions with a complex system or library.
  • When you want to provide a unified interface to a set of interfaces in a subsystem.

Example:
Think of a home theater system. Instead of turning on the TV, sound system, and DVD player separately, a Facade provides a single “Watch Movie” button that does everything.

class Subsystem1 {
    public void operation1() {
        System.out.println("Subsystem 1 operation.");
    }
}

class Subsystem2 {
    public void operation2() {
        System.out.println("Subsystem 2 operation.");
    }
}

class Facade {
    private Subsystem1 subsystem1;
    private Subsystem2 subsystem2;

    public Facade() {
        subsystem1 = new Subsystem1();
        subsystem2 = new Subsystem2();
    }

    public void operation() {
        subsystem1.operation1();
        subsystem2.operation2();
    }
}

public class Client {
    public static void main(String[] args) {
        Facade facade = new Facade();
        facade.operation(); // Calls both subsystems
    }
}

6. Flyweight Pattern

The Flyweight pattern is all about saving memory by sharing as much data as possible between similar objects. Instead of creating new data for every object, you reuse existing data. It’s like having a shared library of resources that multiple objects can use.

When to Use It?

Use the Flyweight pattern when:

  • Your application creates a large number of similar objects.
  • These objects use a lot of memory because they store the same data repeatedly.
  • You want to optimize memory usage by sharing common data.

Example:

Imagine you’re designing a text editor. Each character in the document (like ‘A’, ‘B’, ‘C’) has properties like font, size, and color. Instead of storing these properties for every single character, you can use the Flyweight pattern to store shared properties (like font and size) once and reuse them.

Here’s how it works in Java:

package com.javainfotech;
import java.util.HashMap;
import java.util.Map;

// Flyweight class
class CharacterStyle {
    private String font;
    private int size;
    private String color;

    public CharacterStyle(String font, int size, String color) {
        this.font = font;
        this.size = size;
        this.color = color;
    }

    public void applyStyle() {
        System.out.println("Font: " + font + ", Size: " + size + ", Color: " + color);
    }
}

// Flyweight Factory
class CharacterStyleFactory {
    private static Map<String, CharacterStyle> styles = new HashMap<>();

    public static CharacterStyle getStyle(String font, int size, String color) {
        String key = font + size + color;
        if (!styles.containsKey(key)) {
            styles.put(key, new CharacterStyle(font, size, color)); // Create new style if it doesn't exist
        }
        return styles.get(key); // Return the shared style
    }
}

// Client class
public class TextEditor {
    public static void main(String[] args) {
        CharacterStyle style1 = CharacterStyleFactory.getStyle("Arial", 12, "Black");
        CharacterStyle style2 = CharacterStyleFactory.getStyle("Arial", 12, "Black"); // Reuses the same style

        style1.applyStyle(); // Output: Font: Arial, Size: 12, Color: Black
        style2.applyStyle(); // Output: Font: Arial, Size: 12, Color: Black
    }
}
  • Key Idea: Instead of creating a new CharacterStyle for every character, the CharacterStyleFactory reuses existing styles. This saves memory.

7. Proxy Pattern

The Proxy pattern provides a placeholder for another object. It controls access to the real object, allowing you to add extra functionality (like lazy loading, access control, or logging) without changing the real object.

When to Use It?

Use the Proxy pattern when:

  • You want to control access to an object (e.g., restrict access based on permissions).
  • You want to delay the creation of an expensive object until it’s needed (lazy loading).
  • You want to add logging or monitoring to an object’s operations.

Example:

Imagine you’re building a system to display high-resolution images. Loading these images can be slow and memory-intensive. Instead of loading the image immediately, you can use a Proxy to load it only when it’s actually needed.

Here’s how it works in Java:

// Subject interface
interface Image {
    void display();
}

// Real Subject
class RealImage implements Image {
    private String filename;

    public RealImage(String filename) {
        this.filename = filename;
        loadFromDisk(); // Simulate loading the image
    }

    private void loadFromDisk() {
        System.out.println("Loading image: " + filename);
    }

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

// Proxy
class ProxyImage implements Image {
    private String filename;
    private RealImage realImage;

    public ProxyImage(String filename) {
        this.filename = filename;
    }

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

// Client class
public class ImageViewer {
    public static void main(String[] args) {
        Image image = new ProxyImage("photo.jpg");

        // Image is not loaded yet
        System.out.println("Image will be loaded now...");
        image.display(); // Image is loaded and displayed
    }
}
  • Key Idea: The ProxyImage acts as a placeholder for the RealImage. The real image is only loaded when the display() method is called, saving resources.

Comparison of Flyweight and Proxy Patterns

AspectFlyweight PatternProxy Pattern
PurposeSaves memory by sharing data.Controls access to an object.
Use CaseWhen many objects share common data.When you need to add functionality (e.g., lazy loading, access control).
ExampleReusing font styles in a text editor.Loading high-resolution images on demand.

Why Use Structural Design Patterns?

  • Flexibility: You can change parts of your system without breaking the rest.
  • Reusability: You can reuse components in different parts of your application.
  • Simplicity: Patterns like Facade make complex systems easier to use.
  • Scalability: Patterns like Composite make it easy to add new features.

Conclusion

Structural design patterns help you organize and combine objects in a way that makes your code more flexible, maintainable, and scalable. In this post, we covered the Adapter, Bridge, Composite, Decorator, and Facade patterns. Try using these patterns in your projects to see how they can improve your code!

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply