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.
There are seven main types of structural design patterns:
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");
}
}
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();
}
}
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();
}
}
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());
}
}
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();
}
}
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)];
}
}
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();
}
}
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.
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.