Mastering Structural Design Patterns in Java: Advantages, Disadvantages, and Real-time Usage

Java Structural Design Patterns are used to define the relationship between the different entities within a software system. These patterns help to simplify and streamline the design of a software system, by organizing the components and objects in a more manageable and logical way. In this article, we will discuss the most commonly used Structural Design Patterns in Java, along with their advantages, disadvantages, and real-time usage scenarios.

Adapter Pattern

The Adapter pattern is used to convert one interface into another interface that clients expect. This pattern allows objects with incompatible interfaces to work together. Here's an example of the Adapter pattern in Java:

Advantages:

  • Helps to reuse existing code without modifying it.
  • Allows the code to work with third-party libraries or APIs that have incompatible interfaces.

Disadvantages:

  • Can add extra complexity to the code.
  • May result in additional performance overhead.

Real-time usage: 

An example of the Adapter pattern in real-world applications is when a new version of a software is released and needs to work with the existing codebase. In such cases, the Adapter pattern can be used to convert the new version's interface into the older interface expected by the existing codebase.


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

// Adaptee interface
public interface AdvancedMediaPlayer { 
   public void playVlc(String fileName); 
   public void playMp4(String fileName); 
}

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

// Adaptee implementation
public class Mp4Player implements AdvancedMediaPlayer{
   public void playVlc(String fileName) {
      // Do nothing
   }
   public void playMp4(String fileName) {
      System.out.println("Playing mp4 file. Name: "+ fileName);        
   }
}

// Adapter implementation
public class MediaAdapter implements MediaPlayer {

   AdvancedMediaPlayer advancedMusicPlayer;

   public MediaAdapter(String audioType){
      if(audioType.equalsIgnoreCase("vlc") ){
         advancedMusicPlayer = new VlcPlayer();            
      } else if (audioType.equalsIgnoreCase("mp4")){
         advancedMusicPlayer = new Mp4Player();
      }  
   }

   public void play(String audioType, String fileName) {
      if(audioType.equalsIgnoreCase("vlc")){
         advancedMusicPlayer.playVlc(fileName);
      }else if(audioType.equalsIgnoreCase("mp4")){
         advancedMusicPlayer.playMp4(fileName);
      }
   }
}

// Client code
public class AudioPlayer implements MediaPlayer {
   MediaAdapter mediaAdapter; 

   public void play(String audioType, String fileName) {    

      // Built-in support for mp3
      if(audioType.equalsIgnoreCase("mp3")){
         System.out.println("Playing mp3 file. Name: "+ fileName);         
      } 
      // Other formats are supported by MediaAdapter
      else if(audioType.equalsIgnoreCase("vlc") || audioType.equalsIgnoreCase("mp4")){
         mediaAdapter = new MediaAdapter(audioType);
         mediaAdapter.play(audioType, fileName);
      }
      // Unsupported formats
      else{
         System.out.println("Invalid media. " + audioType + " format not supported");
      }
   }   
}

// Demo
public class AdapterPatternDemo {
   public static void main(String[] args) {
      AudioPlayer audioPlayer = new AudioPlayer();

      audioPlayer.play("mp3", "beyond the horizon.mp3");
      audioPlayer.play("mp4", "alone.mp4");
      audioPlayer.play("vlc", "far far away.vlc");
      audioPlayer.play("avi", "mind me.avi");
   }
}

In this example, the MediaPlayer interface is the target interface that we want to use to play audio files. The AdvancedMediaPlayer interface is the adaptee interface, which has methods for playing VLC and MP4 audio files. The VlcPlayer and Mp4Player classes are implementations of the AdvancedMediaPlayer interface.

The MediaAdapter class is the adapter implementation, which takes an audio type as input and uses the appropriate implementation of the AdvancedMediaPlayer interface to play the audio file.

Finally, the AudioPlayer class is the client code, which uses the MediaPlayer interface to play audio files. If the audio file is in an unsupported format, it uses the MediaAdapter to convert the audio file to a supported format.

Bridge Pattern

The Bridge pattern is used to separate the abstraction from the implementation, so that the two can be modified independently of each other. This pattern is useful when there are multiple implementations of a class, and the application needs to switch between them at runtime. Here's an example of the Bridge pattern in Java:

Advantages:

  • Allows changes to the abstraction or the implementation to be made without affecting the other.
  • Provides flexibility in the application's architecture by decoupling the abstraction and implementation.

Disadvantages:

  • Can add additional complexity to the code.
  • Requires careful planning and design to ensure proper separation of the abstraction and implementation.

Real-time usage: 

The Bridge pattern is often used in user interface design, where the user interface can be separated from the underlying data and business logic.


// Implementor interface
interface DrawingAPI {
    void drawCircle(double x, double y, double radius);
}

// Concrete Implementor 1
class DrawingAPI1 implements DrawingAPI {
    @Override
    public void drawCircle(double x, double y, double radius) {
        System.out.printf("API1.circle at %f:%f radius %f%n", x, y, radius);
    }
}

// Concrete Implementor 2
class DrawingAPI2 implements DrawingAPI {
    @Override
    public void drawCircle(double x, double y, double radius) {
        System.out.printf("API2.circle at %f:%f radius %f%n", x, y, radius);
    }
}

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

// Refined Abstraction
class CircleShape implements Shape {
    private double x, y, radius;
    private DrawingAPI drawingAPI;

    public CircleShape(double x, double y, double radius, DrawingAPI drawingAPI) {
        this.x = x;
        this.y = y;
        this.radius = radius;
        this.drawingAPI = drawingAPI;
    }

    @Override
    public void draw() {
        drawingAPI.drawCircle(x, y, radius);
    }
}

// Client
public class BridgePatternExample {
    public static void main(String[] args) {
        Shape[] shapes = new Shape[]{
                new CircleShape(1, 2, 3, new DrawingAPI1()),
                new CircleShape(5, 7, 11, new DrawingAPI2())
        };

        for (Shape shape : shapes) {
            shape.draw();
        }
    }
}

In this example, the Bridge pattern separates the implementation details of drawing circles from the abstraction of a circle shape. The DrawingAPI interface represents the implementation, with two concrete implementations, DrawingAPI1 and DrawingAPI2. The Shape interface represents the abstraction, with a single refined implementation, CircleShape.

In the main method, two circle shapes are created, each with a different drawing API implementation. When the draw method is called on each shape, the circle is drawn using the appropriate drawing API implementation. This allows the client to use different implementations of the drawing API without affecting the shape abstraction.

The advantages of using the Bridge pattern include:

  • Separating implementation details from abstraction, which makes the code easier to maintain and extend.
  • Encapsulating implementation details in separate classes, which makes the code more modular and reusable.
  • Allowing the client to use different implementations of the implementation class without affecting the abstraction.

The main disadvantage of using the Bridge pattern is that it can add complexity to the code, since it requires additional classes and interfaces to separate the implementation and abstraction. However, this is usually outweighed by the benefits of increased modularity and maintainability.

Real-world examples of the Bridge pattern include GUI toolkits, where the abstraction represents the UI component (such as a button), and the implementation represents the platform-specific code that draws the component on the screen.

Composite Pattern

The Composite pattern is used to represent a hierarchy of objects as a single object. This pattern allows clients to treat individual objects and groups of objects in the same way. Here's an example of the Composite pattern in Java:

Advantages:

  • Simplifies the handling of complex hierarchical structures.
  • Provides a consistent way to manipulate individual objects and groups of objects.

Disadvantages:

  • Can add additional overhead to the application.
  • May require a more complex implementation compared to a non-composite design.

Real-time usage: 

The Composite pattern is commonly used in file system design, where folders and files can be represented as a single object.


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

// Component interface
interface Shape {
    void draw(String fillColor);
}

// Leaf class
class Circle implements Shape {
    @Override
    public void draw(String fillColor) {
        System.out.println("Drawing Circle with color " + fillColor);
    }
}

// Composite class
class Drawing implements Shape {
    private List shapes = new ArrayList<>();

    @Override
    public void draw(String fillColor) {
        for (Shape shape : shapes) {
            shape.draw(fillColor);
        }
    }

    public void add(Shape shape) {
        shapes.add(shape);
    }

    public void remove(Shape shape) {
        shapes.remove(shape);
    }
}

// Client
public class CompositePatternExample {
    public static void main(String[] args) {
        Shape circle1 = new Circle();
        Shape circle2 = new Circle();

        Drawing drawing = new Drawing();
        drawing.add(circle1);
        drawing.add(circle2);

        drawing.draw("Red");

        drawing.remove(circle2);

        drawing.draw("Green");
    }
}


In this example, the Composite pattern represents a group of shapes as a single object. The Shape interface represents the component, with two implementations: Circle as the leaf class and Drawing as the composite class.

The Drawing class contains a list of shapes, and its draw method delegates the drawing to each shape in the list. The add and remove methods allow the client to add or remove shapes from the drawing. The client can treat a single Drawing object as a composite of multiple shapes, and call the draw method to draw all the shapes together.

The advantages of using the Composite pattern include:

  • Representing complex hierarchical structures as a single object, which makes the code more modular and reusable.
  • Treating individual objects and composite objects uniformly, which simplifies the code for the client.
  • Encapsulating the implementation details of the individual objects and composite objects, which improves maintainability and extensibility.

The main disadvantage of using the Composite pattern is that it can add overhead to the code, since it requires additional classes and interfaces to represent the composite object. However, this is usually outweighed by the benefits of improved modularity and maintainability.

Real-world examples of the Composite pattern include GUI toolkits, where a GUI component (such as a window) can contain other GUI components (such as buttons and text fields).

Decorator Pattern

The Decorator pattern is used to add new functionality to an object without changing its underlying structure. This pattern is useful when you need to add new behavior to an object at runtime, without affecting the existing behavior. Here's an example of the Decorator pattern in Java:

Advantages:

  • Allows the addition of new functionality to an object without changing its structure.
  • Provides a flexible way to modify the behavior of an object at runtime.

Disadvantages:

  • Can result in a large number of small objects, which can affect performance.
  • Requires careful planning and design to ensure that the decorator objects do not become too complex.

Real-time usage: 

The Decorator pattern is commonly used in GUI applications, where different styles, colors, and fonts can be applied to a base object without affecting its underlying structure.


// Component interface
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
abstract class CoffeeDecorator implements Coffee {
    protected final Coffee decoratedCoffee;

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

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

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

// Concrete decorator
class Milk extends CoffeeDecorator {
    public Milk(Coffee decoratedCoffee) {
        super(decoratedCoffee);
    }

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

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

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

        coffee = new Milk(coffee);
        System.out.println("Cost: " + coffee.getCost() + ", Description: " + coffee.getDescription());
    }
}

In this example, the Decorator pattern allows the client to add behavior to an object dynamically, without having to modify the object's code. The Coffee interface represents the component, with SimpleCoffee as the concrete component.

The CoffeeDecorator abstract class represents the decorator, which can add behavior to the component. The Milk class is a concrete decorator that adds milk to the coffee.

The client can create a SimpleCoffee object, and then wrap it with one or more decorators to add behavior to the coffee. The getCost and getDescription methods of the decorators delegate to the corresponding methods of the wrapped object, and add their own behavior on top.

The advantages of using the Decorator pattern include:

  • Adding behavior to an object dynamically, without having to modify the object's code.
  • Composing objects at runtime, rather than at compile-time, which makes the code more flexible and extensible.
  • Encapsulating the implementation details of the object and its behavior, which improves maintainability and modularity.

The main disadvantage of using the Decorator pattern is that it can lead to a large number of small classes, which can be hard to manage. However, this can be mitigated by using composition and inheritance effectively, and by using the pattern only where it is appropriate.

Real-world examples of the Decorator pattern include the java.io package in Java, where various decorators are used to add functionality to input and output streams.

Facade Pattern

The Facade pattern is used to provide a simplified interface to a complex subsystem. This pattern hides the complexity of the subsystem from the client, and provides a simpler interface that the client can use to interact with the subsystem. Here's an example of the Facade pattern in Java:

Advantages:

Simplifies the interface to a complex


// Complex subsystem
class SubsystemA {
    public void operationA() {
        System.out.println("Subsystem A operation");
    }
}

// Another complex subsystem
class SubsystemB {
    public void operationB() {
        System.out.println("Subsystem B operation");
    }
}

// Facade
class Facade {
    private final SubsystemA subsystemA;
    private final SubsystemB subsystemB;

    public Facade() {
        subsystemA = new SubsystemA();
        subsystemB = new SubsystemB();
    }

    public void operation() {
        subsystemA.operationA();
        subsystemB.operationB();
    }
}

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

In this example, the Facade pattern provides a simple interface to a complex subsystem, by wrapping the subsystem in a single class. The SubsystemA and SubsystemB classes represent the complex subsystem, and the Facade class represents the facade.

The Facade class has a simple interface that encapsulates the complexity of the subsystem, and delegates to the appropriate classes in the subsystem. The client can use the facade to perform complex operations on the subsystem, without having to understand the details of the subsystem's implementation.

The advantages of using the Facade pattern include:

  • Providing a simple interface to a complex subsystem, which improves usability and maintainability.
  • Encapsulating the complexity of the subsystem, which reduces the impact of changes to the subsystem on the client code.
  • Providing a decoupling layer between the client and the subsystem, which improves modularity and flexibility.

The main disadvantage of using the Facade pattern is that it can add a layer of indirection between the client and the subsystem, which can reduce performance. However, this can be mitigated by designing the facade to be as efficient as possible, and by using the pattern only where it is appropriate.

Real-world examples of the Facade pattern include the java.net.URL class in Java, which provides a simple interface to the complex networking subsystem, and the javax.faces.context.FacesContext class in JavaServer Faces (JSF), which provides a simple interface to the complex web application framework.

Flyweight Pattern

The Flyweight pattern is used to minimize the memory footprint of an application by sharing objects that have similar properties. This pattern allows the application to reuse objects instead of creating new ones, which can reduce memory usage and improve performance. Here's an example of the Flyweight pattern in Java:

Advantages:

  • Reduces memory usage and improves performance.
  • Allows the application to handle large amounts of data efficiently.

Disadvantages:

  • May add complexity to the code.
  • Requires careful planning and design to ensure that objects are shared correctly.

Real-time usage: 

The Flyweight pattern is commonly used in graphical applications, where many objects with similar properties are used, such as pixels in an image.


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

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

// Concrete flyweight
class Circle implements Shape {
    private final String color;

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

    @Override
    public void draw() {
        System.out.println("Drawing Circle of color " + color);
    }
}

// Flyweight factory
class ShapeFactory {
    private final Map shapeMap = new HashMap<>();

    public Shape getCircle(String color) {
        Circle circle = (Circle) shapeMap.get(color);

        if (circle == null) {
            circle = new Circle(color);
            shapeMap.put(color, circle);
            System.out.println("Creating Circle of color " + color);
        }

        return circle;
    }
}

// Client code
public class FlyweightPatternExample {
    private static final String[] COLORS = {"Red", "Green", "Blue"};
    private static final int MAX_CIRCLES = 100;

    public static void main(String[] args) {
        ShapeFactory shapeFactory = new ShapeFactory();

        for (int i = 0; i < MAX_CIRCLES; i++) {
            Circle circle = (Circle) shapeFactory.getCircle(getRandomColor());
            circle.draw();
        }
    }

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


In this example, the Flyweight pattern is used to share common objects to reduce memory usage. The Shape interface represents the flyweight, and the Circle class represents the concrete flyweight.

The ShapeFactory class represents the flyweight factory, which is responsible for creating and managing flyweights. The factory maintains a map of flyweights, which can be reused by the client.

The client uses the flyweight factory to obtain flyweights, and can customize the flyweights with intrinsic and extrinsic state. Intrinsic state is shared among flyweights and extrinsic state is passed in from the client.

The advantages of using the Flyweight pattern include:

  • Reducing memory usage by sharing common objects.
  • Improving performance by avoiding the creation of duplicate objects.
  • Simplifying the design of the client by hiding the complexity of the flyweight objects.

The main disadvantage of using the Flyweight pattern is that it can add complexity to the design of the factory and the client. Additionally, the use of flyweights can reduce the encapsulation of the objects, since the intrinsic state is shared among the flyweights.

Real-world examples of the Flyweight pattern include the java.lang.Integer and java.lang.String classes in Java, which use flyweights to cache commonly used objects. Other examples include graphic editors, where flyweights are used to represent shapes with shared properties such as color or line style.

Proxy Pattern

The Proxy pattern is used to control access to an object, by creating a proxy object that intercepts requests and forwards them to the real object. This pattern is useful when you need to restrict access to an object or add additional functionality, such as caching or logging. Here's an example of the Proxy pattern in Java:

Advantages:

  • Provides a way to control access to an object.
  • Allows the addition of additional functionality, such as caching or logging.

Disadvantages:

  • Can add additional complexity to the code.
  • May result in additional performance overhead.

Real-time usage: 

The Proxy pattern is commonly used in distributed systems, where network latency and security concerns require additional layers of control and monitoring.



interface Internet {
    public void connectTo(String serverHost) throws Exception;
}

class RealInternet implements Internet {
    @Override
    public void connectTo(String serverHost) {
        System.out.println("Connecting to " + serverHost);
    }
}

class ProxyInternet implements Internet {
    private Internet internet = new RealInternet();

    @Override
    public void connectTo(String serverHost) throws Exception {
        if (isBlocked(serverHost)) {
            throw new Exception("Access Denied to " + serverHost);
        }
        internet.connectTo(serverHost);
    }

    private boolean isBlocked(String serverHost) {
        // Check if the server is blocked
        return serverHost.equalsIgnoreCase("blocked.com");
    }
}

public class ProxyPatternExample {
    public static void main(String[] args) throws Exception {
        Internet internet = new ProxyInternet();
        internet.connectTo("google.com");
        internet.connectTo("blocked.com");
    }
}


In this example, we have an interface Internet that declares a connectTo method, which is implemented by two classes: RealInternet and ProxyInternet. The RealInternet class represents the real object that the client wants to access, while the ProxyInternet class acts as a proxy to the real object.

The ProxyInternet class checks if the requested server is blocked before allowing the client to access it. If the server is blocked, an exception is thrown. If the server is not blocked, the ProxyInternet class delegates the request to the RealInternet object.

The main method creates an instance of the ProxyInternet class and calls the connectTo method twice - once for a server that is not blocked (google.com), and once for a server that is blocked (blocked.com). When the client tries to access the blocked server, an exception is thrown, and the client is denied access.

The advantage of using the Proxy design pattern is that it allows us to control access to the real object and provide additional functionality as needed. This can be useful in scenarios where we want to restrict access to certain objects, or when we want to perform additional processing before or after accessing an object.

The disadvantage of using the Proxy design pattern is that it can add additional complexity to the system, especially if we need to create multiple proxies to manage access to different objects. Additionally, the use of a proxy can add a performance overhead, as each request has to go through the proxy before it can be processed by the real object.

Real-world examples of the Proxy design pattern include firewalls, caching systems, and remote procedure call (RPC) protocols, where a proxy is used to access a remote server.

Conclusion:

Structural Design Patterns are an essential part of software development, as they help to organize and simplify the design of complex applications. By using the right design pattern for the right problem, developers can create more maintainable, scalable, and efficient software systems. In this article, we have discussed some of the most commonly used Structural Design Patterns in Java, along with their advantages, disadvantages, and real-time usage scenarios.


Post a Comment

Previous Post Next Post