Structural Design Patterns: Building Better Java Applications

In the realm of Java application development, creating robust, scalable, and maintainable software is a constant pursuit. Structural design patterns play a crucial role in achieving these goals. These patterns focus on how classes and objects are composed to form larger structures. By leveraging structural design patterns, developers can enhance code modularity, improve re - usability, and simplify the overall architecture of Java applications. In this blog, we will explore the fundamental concepts, usage methods, common practices, and best practices of structural design patterns in Java.

Table of Contents

  1. Fundamental Concepts
    • What are Structural Design Patterns?
    • Types of Structural Design Patterns
  2. Usage Methods
    • Adapter Pattern
    • Bridge Pattern
    • Composite Pattern
    • Decorator Pattern
    • Facade Pattern
    • Flyweight Pattern
    • Proxy Pattern
  3. Common Practices
    • When to Use Each Pattern
    • Integration with Existing Code
  4. Best Practices
    • Code Readability and Maintainability
    • Performance Considerations
  5. Conclusion
  6. References

Fundamental Concepts

What are Structural Design Patterns?

Structural design patterns are a category of design patterns in software engineering that deal with the composition of classes or objects to form larger structures. They help in achieving a more flexible and efficient design by providing ways to combine classes and objects in a manner that is both modular and extensible. These patterns often involve modifying the structure of existing classes or objects to fit specific requirements without changing their core functionality.

Types of Structural Design Patterns

There are seven main types of structural design patterns:

  • Adapter Pattern: Converts the interface of a class into another interface clients expect.
  • Bridge Pattern: Decouples an abstraction from its implementation so that the two can vary independently.
  • Composite Pattern: Allows clients to treat individual objects and compositions of objects uniformly.
  • Decorator Pattern: Attaches additional responsibilities to an object dynamically.
  • Facade Pattern: Provides a simplified interface to a complex system.
  • Flyweight Pattern: Minimizes memory usage by sharing as much data as possible among similar objects.
  • Proxy Pattern: Provides a surrogate or placeholder for another object to control access to it.

Usage Methods

Adapter Pattern

The adapter pattern is used to make existing classes work with others without modifying their source code. Here is a simple Java example:

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

// Adaptee
class AdvancedMediaPlayer {
    public void playVlc(String fileName) {
        System.out.println("Playing vlc file. Name: " + fileName);
    }

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

// Adapter
class MediaAdapter implements MediaPlayer {
    AdvancedMediaPlayer advancedMediaPlayer;

    public MediaAdapter(String audioType) {
        if (audioType.equalsIgnoreCase("vlc")) {
            advancedMediaPlayer = new AdvancedMediaPlayer();
        }
    }

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

// Concrete implementation of MediaPlayer
class AudioPlayer implements MediaPlayer {
    MediaAdapter mediaAdapter;

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

// Client code
public class AdapterPatternDemo {
    public static void main(String[] args) {
        AudioPlayer audioPlayer = new AudioPlayer();
        audioPlayer.play("mp3", "test.mp3");
        audioPlayer.play("vlc", "test.vlc");
    }
}

Bridge Pattern

The bridge pattern separates the abstraction from its implementation. Here is an example:

// Implementor
interface Color {
    void applyColor();
}

// Concrete Implementor
class RedColor implements Color {
    @Override
    public void applyColor() {
        System.out.println("Applying red color");
    }
}

// Abstraction
abstract class Shape {
    protected Color color;

    public Shape(Color color) {
        this.color = color;
    }

    abstract void draw();
}

// Refined Abstraction
class Circle extends Shape {
    public Circle(Color color) {
        super(color);
    }

    @Override
    public void draw() {
        System.out.print("Circle drawn. ");
        color.applyColor();
    }
}

// Client code
public class BridgePatternDemo {
    public static void main(String[] args) {
        Shape redCircle = new Circle(new RedColor());
        redCircle.draw();
    }
}

Composite Pattern

The composite pattern allows you to treat individual objects and groups of objects in a uniform way.

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

// Component
interface Employee {
    void showDetails();
}

// Leaf
class Developer implements Employee {
    private String name;

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

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

// Composite
class Manager implements Employee {
    private String name;
    private List<Employee> employees = new ArrayList<>();

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

    public void addEmployee(Employee employee) {
        employees.add(employee);
    }

    @Override
    public void showDetails() {
        System.out.println("Manager: " + name);
        for (Employee employee : employees) {
            employee.showDetails();
        }
    }
}

// Client code
public class CompositePatternDemo {
    public static void main(String[] args) {
        Developer dev1 = new Developer("John");
        Developer dev2 = new Developer("Jane");

        Manager manager = new Manager("Mike");
        manager.addEmployee(dev1);
        manager.addEmployee(dev2);

        manager.showDetails();
    }
}

Decorator Pattern

The decorator pattern attaches additional responsibilities to an object dynamically.

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

// Concrete Component
class SimpleCoffee implements Coffee {
    @Override
    public double getCost() {
        return 1.0;
    }

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

// Decorator
abstract class CoffeeDecorator implements Coffee {
    protected Coffee decoratedCoffee;

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

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

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

// Concrete Decorator
class MilkDecorator extends CoffeeDecorator {
    public MilkDecorator(Coffee decoratedCoffee) {
        super(decoratedCoffee);
    }

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

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

// Client code
public class DecoratorPatternDemo {
    public static void main(String[] args) {
        Coffee simpleCoffee = new SimpleCoffee();
        System.out.println(simpleCoffee.getDescription() + " Cost: " + simpleCoffee.getCost());

        Coffee milkCoffee = new MilkDecorator(simpleCoffee);
        System.out.println(milkCoffee.getDescription() + " Cost: " + milkCoffee.getCost());
    }
}

Facade Pattern

The facade pattern provides a simplified interface to a complex system.

// Subsystem classes
class CPU {
    public void start() {
        System.out.println("CPU started");
    }
}

class Memory {
    public void load() {
        System.out.println("Memory loaded");
    }
}

class HardDrive {
    public void read() {
        System.out.println("Hard drive read");
    }
}

// Facade
class ComputerFacade {
    private CPU cpu;
    private Memory memory;
    private HardDrive hardDrive;

    public ComputerFacade() {
        cpu = new CPU();
        memory = new Memory();
        hardDrive = new HardDrive();
    }

    public void startComputer() {
        cpu.start();
        memory.load();
        hardDrive.read();
    }
}

// Client code
public class FacadePatternDemo {
    public static void main(String[] args) {
        ComputerFacade computerFacade = new ComputerFacade();
        computerFacade.startComputer();
    }
}

Flyweight Pattern

The flyweight pattern minimizes memory usage by sharing data.

import java.util.HashMap;
import java.util.Map;

// Flyweight interface
interface ShapeFlyweight {
    void draw(int x, int y);
}

// Concrete Flyweight
class CircleFlyweight implements ShapeFlyweight {
    private String color;

    public CircleFlyweight(String color) {
        this.color = color;
    }

    @Override
    public void draw(int x, int y) {
        System.out.println("Drawing a " + color + " circle at (" + x + ", " + y + ")");
    }
}

// Flyweight Factory
class ShapeFlyweightFactory {
    private static final Map<String, ShapeFlyweight> circleMap = new HashMap<>();

    public static ShapeFlyweight getCircle(String color) {
        CircleFlyweight circle = (CircleFlyweight) circleMap.get(color);

        if (circle == null) {
            circle = new CircleFlyweight(color);
            circleMap.put(color, circle);
            System.out.println("Creating circle of color: " + color);
        }
        return circle;
    }
}

// Client code
public class FlyweightPatternDemo {
    private static final String[] colors = {"Red", "Green", "Blue", "White", "Black"};

    public static void main(String[] args) {
        for (int i = 0; i < 20; ++i) {
            CircleFlyweight circle = (CircleFlyweight) ShapeFlyweightFactory.getCircle(getRandomColor());
            circle.draw((int) (Math.random() * 100), (int) (Math.random() * 100));
        }
    }

    private static String getRandomColor() {
        return colors[(int) (Math.random() * colors.length)];
    }
}

Proxy Pattern

The proxy pattern controls access to an object.

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

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

    public RealImage(String fileName) {
        this.fileName = fileName;
        loadFromDisk(fileName);
    }

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

    private void loadFromDisk(String fileName) {
        System.out.println("Loading " + fileName);
    }
}

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

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

    @Override
    public void display() {
        if (realImage == null) {
            realImage = new RealImage(fileName);
        }
        realImage.display();
    }
}

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

        // Image will be loaded from disk
        image.display();
        System.out.println("");

        // Image will not be loaded from disk
        image.display();
    }
}

Common Practices

When to Use Each Pattern

  • Adapter Pattern: Use when you need to make an existing class work with another interface without modifying its source code.
  • Bridge Pattern: When you want to separate an abstraction from its implementation so that they can vary independently.
  • Composite Pattern: When you need to treat individual objects and groups of objects uniformly.
  • Decorator Pattern: When you want to add additional responsibilities to an object dynamically.
  • Facade Pattern: When you have a complex system and want to provide a simplified interface to it.
  • Flyweight Pattern: When you need to minimize memory usage by sharing data among similar objects.
  • Proxy Pattern: When you need to control access to an object.

Integration with Existing Code

When integrating these patterns with existing code, it is important to ensure that the new code does not break the existing functionality. Start by identifying the areas where the pattern can be applied without causing conflicts. For example, when using the adapter pattern, make sure that the adapter class can be easily integrated with the existing codebase without requiring major changes.

Best Practices

Code Readability and Maintainability

  • Use Meaningful Names: Use descriptive names for classes, methods, and variables to make the code more understandable.
  • Follow Coding Standards: Adhere to the Java coding standards to ensure consistency in the codebase.
  • Document the Code: Add comments to explain the purpose of each class, method, and pattern usage.

Performance Considerations

  • Flyweight Pattern: Use the flyweight pattern when dealing with a large number of similar objects to reduce memory usage.
  • Proxy Pattern: Use the proxy pattern to control access to expensive objects and defer their creation until necessary.

Conclusion

Structural design patterns are powerful tools in Java application development. They provide a way to build more modular, scalable, and maintainable applications. By understanding the fundamental concepts, usage methods, common practices, and best practices of these patterns, developers can make informed decisions when designing and implementing Java applications. Whether it’s adapting existing classes, separating abstractions from implementations, or simplifying complex systems, structural design patterns offer solutions to a wide range of design challenges.

References