Structural Design Patterns

Saurav Kumar
6 min readNov 22, 2023

--

Structural design patterns deal with the composition of classes and objects to form larger structures, such as relationships between objects, and focus on how these structures can be used to solve specific problems. There are seven commonly recognized structural design patterns:

1. Adapter Pattern:

  • Purpose: Allows the interface of an existing class to be used as another interface.
  • Use Case: When you want to make an existing class work with others without modifying its source code or when integrating legacy code with modern systems.
  • Example: Consider a system that expects a USB interface but you have a PS2 device. You can create an adapter class to make the PS2 device compatible with the USB interface.
// Existing class with a different interface
class PS2Device {
public void connectPS2() {
System.out.println("Connected via PS2");
}
}

// Target interface expected by the client
interface USBDevice {
void connectUSB();
}

// Adapter class to make PS2Device compatible with USB
class PS2ToUSBAdapter implements USBDevice {
private PS2Device ps2Device;

public PS2ToUSBAdapter(PS2Device device) {
this.ps2Device = device;
}

@Override
public void connectUSB() {
ps2Device.connectPS2();
System.out.println("Adapter converts PS2 to USB");
}
}

In this example, the PS2ToUSBAdapter allows a PS2Device to work as a USBDevice.

2. Decorator Pattern:

  • Purpose: Attaches additional responsibilities to an object dynamically.
  • Use Case: When you want to add behavior to objects without subclassing or when you need to combine multiple behaviors or features flexibly.
  • Example: Imagine a text editor where you can add features like spell-checking, formatting, or encryption to the text without changing the core text handling class. Decorator classes wrap the core text class to provide these features.
// Component interface
interface Coffee {
String getDescription();
double getCost();
}

// Concrete component
class Espresso implements Coffee {
@Override
public String getDescription() {
return "Espresso";
}

@Override
public double getCost() {
return 1.0;
}
}

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

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

// Concrete decorators
class MilkDecorator extends CoffeeDecorator {
public MilkDecorator(Coffee coffee) {
super(coffee);
}

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

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

You can use decorators like MilkDecorator to add extra features (e.g., milk) to a base Coffee object (e.g., Espresso).

3. Composite Pattern:

  • Purpose: Composes objects into tree structures to represent part-whole hierarchies.
  • Use Case: When you want clients to treat individual objects and compositions of objects uniformly, such as creating complex structures from simpler ones.
  • Example: A graphic application might have shapes like circles, squares, and triangles, but it also allows you to group these shapes into composite objects like diagrams or figures. The composite pattern lets you treat individual shapes and compositions the same way.
// Component interface
interface Graphic {
void draw();
}

// Leaf
class Circle implements Graphic {
@Override
public void draw() {
System.out.println("Drawing Circle");
}
}

// Leaf
class Square implements Graphic {
@Override
public void draw() {
System.out.println("Drawing Square");
}
}

// Composite
class Picture implements Graphic {
private List<Graphic> graphics = new ArrayList<>();

public void addGraphic(Graphic graphic) {
graphics.add(graphic);
}

@Override
public void draw() {
System.out.println("Drawing Picture");
for (Graphic graphic : graphics) {
graphic.draw();
}
}
}

The Picture class can contain individual Circle and Square objects, treating them uniformly as graphics.

4. Proxy Pattern:

  • Purpose: Provides a surrogate or placeholder for another object to control access to it.
  • Use Case: When you want to add a level of control over the interaction with an object, for example, to implement lazy initialization, access control, logging, or monitoring.
  • Example: In a multimedia player, you might use a proxy for loading large video files. The proxy would load the video only when requested, helping to conserve resources until needed.
// Subject interface
interface Image {
void display();
}

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

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

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

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

// Proxy
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);
}
realImage.display();
}
}

The ImageProxy controls access to the RealImage object, delaying its creation until it's needed.

5. Bridge Pattern:

  • Purpose: Separates an object’s abstraction from its implementation, allowing it to vary independently.
  • Use Case: When you want to avoid a permanent binding between an abstraction and its implementation or when you need to support multiple platforms or database backends.
  • Example: Consider a shape hierarchy with different drawing implementations. The bridge pattern lets you decouple the shape abstraction (e.g., circle, square) from the drawing implementation (e.g., raster, vector), allowing you to mix and match them.
// Implementor interface
interface DrawingAPI {
void drawCircle(int x, int y, int radius);
}

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

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

// Abstraction
abstract class Shape {
protected DrawingAPI drawingAPI;

protected Shape(DrawingAPI drawingAPI) {
this.drawingAPI = drawingAPI;
}

public abstract void draw();
}

// Refined Abstraction
class CircleShape extends Shape {
private int x, y, radius;

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

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

The Bridge Pattern separates the shape abstraction from its drawing implementation, allowing you to use different drawing APIs (API1, API2) with various shapes.

6. Flyweight Pattern:

  • Purpose: Minimizes memory usage or computational expenses by sharing as much as possible with other similar objects.
  • Use Case: When you have a large number of objects with similar properties and you want to reduce memory consumption or improve performance.
  • Example: In a word processor, individual characters can be represented as flyweights, sharing common properties like font, size, and color, to reduce memory usage when rendering a document.
// Flyweight interface
interface CoffeeOrder {
void serveCoffee(CoffeeOrderContext context);
}

// Concrete Flyweight
class CoffeeFlavor implements CoffeeOrder {
private final String flavor;

public CoffeeFlavor(String flavor) {
this.flavor = flavor;
}

@Override
public void serveCoffee(CoffeeOrderContext context) {
System.out.println("Serving coffee flavor " + flavor + " to table " + context.getTableNumber());
}
}

// Context for extrinsic state (table number)
class CoffeeOrderContext {
private int tableNumber;

public CoffeeOrderContext(int tableNumber) {
this.tableNumber = tableNumber;
}

public int getTableNumber() {
return tableNumber;
}
}

In this example, CoffeeFlavor objects represent coffee flavors, and the CoffeeOrderContext contains the extrinsic state (table number). Multiple orders for the same flavor can share a CoffeeFlavor object to save memory.

7. Facade Pattern:

  • Purpose: Provides a unified interface to a set of interfaces in a subsystem, making it easier to use.
  • Use Case: When you want to simplify a complex system by providing a high-level interface to hide its complexities.
  • Example: In a computer, you might have complex subsystems for CPU, memory, storage, etc. A facade pattern can provide a simplified interface for booting the computer, abstracting away the low-level details.
// Complex subsystems
class CPU {
void start() {
System.out.println("CPU started");
}
}

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

class HardDrive {
void readData(){
System.out.println("Reading data from hard drive");
}
}

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

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

// A simplified method to start the computer
public void startComputer() {
cpu.start();
memory.load();
hardDrive.readData();
System.out.println("Computer started successfully");
}
}

The ComputerFacade provides a simplified interface to start the computer by orchestrating the actions of the CPU, memory, and hard drive subsystems. Clients can use the startComputer method without needing to know the details of how each subsystem works.

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

The client uses the ComputerFacade to start the computer without having to interact directly with the CPU, memory, or hard drive components. This simplifies the client's code and reduces the complexity of managing the subsystems.

These structural design patterns help in designing flexible and maintainable systems by defining clear relationships between objects and classes. They are powerful tools for achieving better code organization and reusability.

--

--

Saurav Kumar

Experienced Software Engineer adept in Java, Spring Boot, Microservices, Kafka & Azure.