Explore structural design patterns, their purpose, and common examples like Adapter, Bridge, Composite, and more to improve software architecture.
In the realm of software design, structural patterns play a crucial role by providing solutions to ease the design process through the identification of simple ways to realize relationships between entities. These patterns focus on how classes and objects can be composed to form larger structures, facilitating the design of complex systems with a focus on flexibility and reusability. In this section, we will delve into the essence of structural patterns, explore common examples, and understand their benefits in software architecture.
Definition:
Structural patterns are design patterns that simplify the design process by identifying straightforward methods to establish relationships between entities. They primarily utilize inheritance and composition to assemble interfaces and define ways to compose objects to obtain new functionalities.
Purpose:
The primary goal of structural patterns is to facilitate the design of software systems by identifying simple ways to realize relationships between entities. This is typically achieved through the use of inheritance for interface composition and composition for defining object relationships. By doing so, structural patterns help in creating flexible and reusable code structures, which are easier to manage and extend.
Structural patterns provide a variety of ways to organize code for better maintainability and flexibility. Here are some of the most commonly used structural patterns:
Adapter Pattern:
The Adapter pattern allows the interface of an existing class to be used as another interface. It is particularly useful when integrating new components into existing systems without modifying the existing codebase.
Bridge Pattern:
The Bridge pattern decouples an abstraction from its implementation, allowing the two to vary independently. This pattern is beneficial in scenarios where a system may need to switch between different implementations dynamically.
Composite Pattern:
The Composite pattern composes objects into tree structures to represent part-whole hierarchies. It allows clients to treat individual objects and compositions of objects uniformly, which is particularly useful in building complex UI components or file systems.
Decorator Pattern:
The Decorator pattern adds additional responsibilities to an object dynamically. It is a flexible alternative to subclassing for extending functionality, enabling behaviors to be added or removed at runtime.
Facade Pattern:
The Facade pattern provides a simplified interface to a complex subsystem. It hides the complexities of the subsystem from the client, making the subsystem easier to use.
Flyweight Pattern:
The Flyweight pattern reduces the cost of creating and manipulating a large number of similar objects. It achieves this by sharing common parts of objects between multiple contexts.
Proxy Pattern:
The Proxy pattern provides a surrogate or placeholder for another object to control access to it. This pattern is useful in scenarios such as lazy loading, access control, and logging.
Structural patterns offer several advantages in software design:
The Adapter pattern is one of the most widely used structural patterns. It acts as a bridge between two incompatible interfaces, allowing them to work together. Let’s explore an example in JavaScript to understand how the Adapter pattern works.
// Existing interface
class OldPaymentSystem {
pay(amount) {
console.log(`Paid ${amount} using Old Payment System`);
}
}
// New interface
class NewPaymentSystem {
makePayment(amount) {
console.log(`Paid ${amount} using New Payment System`);
}
}
// Adapter
class PaymentAdapter {
constructor() {
this.newPaymentSystem = new NewPaymentSystem();
}
pay(amount) {
this.newPaymentSystem.makePayment(amount);
}
}
// Client code
function processPayment(paymentSystem, amount) {
paymentSystem.pay(amount);
}
const oldPaymentSystem = new OldPaymentSystem();
processPayment(oldPaymentSystem, 100);
const adapter = new PaymentAdapter();
processPayment(adapter, 200);
Explanation:
In this example, the OldPaymentSystem
and NewPaymentSystem
classes have different interfaces for processing payments. The PaymentAdapter
class acts as an adapter, allowing the NewPaymentSystem
to be used with the existing processPayment
function, which expects the pay
method. This demonstrates how the Adapter pattern enables the integration of new components without altering existing code.
To better understand the relationships in the Adapter pattern, let’s visualize it using a class diagram.
classDiagram class OldPaymentSystem { +pay(amount) } class NewPaymentSystem { +makePayment(amount) } class PaymentAdapter { -NewPaymentSystem newPaymentSystem +pay(amount) } OldPaymentSystem <|.. PaymentAdapter PaymentAdapter o--> NewPaymentSystem
While the Adapter pattern is a powerful tool for interface compatibility, other structural patterns offer unique benefits for different scenarios. Let’s briefly explore each of them:
The Bridge pattern is designed to separate an abstraction from its implementation, allowing both to evolve independently. This pattern is particularly useful in scenarios where a system may need to switch between different implementations dynamically.
Example Scenario:
Consider a drawing application where shapes can be rendered in different formats (e.g., vector and raster). The Bridge pattern allows the shape abstraction to be decoupled from the rendering implementation, enabling each to vary independently.
// Abstraction
class Shape {
constructor(renderer) {
this.renderer = renderer;
}
draw() {
throw new Error("This method should be overridden");
}
}
// Refined Abstraction
class Circle extends Shape {
constructor(renderer, radius) {
super(renderer);
this.radius = radius;
}
draw() {
this.renderer.renderCircle(this.radius);
}
}
// Implementor
class Renderer {
renderCircle(radius) {
throw new Error("This method should be overridden");
}
}
// Concrete Implementor A
class VectorRenderer extends Renderer {
renderCircle(radius) {
console.log(`Drawing a circle with radius ${radius} in vector format`);
}
}
// Concrete Implementor B
class RasterRenderer extends Renderer {
renderCircle(radius) {
console.log(`Drawing a circle with radius ${radius} in raster format`);
}
}
// Client code
const vectorCircle = new Circle(new VectorRenderer(), 5);
vectorCircle.draw();
const rasterCircle = new Circle(new RasterRenderer(), 5);
rasterCircle.draw();
In this example, the Shape
class is the abstraction, and Renderer
is the implementor. The Circle
class is a refined abstraction, while VectorRenderer
and RasterRenderer
are concrete implementors. The Bridge pattern allows the Circle
to use different rendering implementations without changing its code.
The Composite pattern is used to compose objects into tree structures to represent part-whole hierarchies. It allows clients to treat individual objects and compositions of objects uniformly.
Example Scenario:
Consider a file system where files and directories are represented as nodes in a tree structure. The Composite pattern enables operations to be performed uniformly on both files and directories.
// Component
class FileSystemComponent {
getName() {
throw new Error("This method should be overridden");
}
display(indent = 0) {
throw new Error("This method should be overridden");
}
}
// Leaf
class File extends FileSystemComponent {
constructor(name) {
super();
this.name = name;
}
getName() {
return this.name;
}
display(indent = 0) {
console.log(`${' '.repeat(indent)}- ${this.name}`);
}
}
// Composite
class Directory extends FileSystemComponent {
constructor(name) {
super();
this.name = name;
this.children = [];
}
add(component) {
this.children.push(component);
}
remove(component) {
this.children = this.children.filter(child => child !== component);
}
getName() {
return this.name;
}
display(indent = 0) {
console.log(`${' '.repeat(indent)}+ ${this.name}`);
for (const child of this.children) {
child.display(indent + 2);
}
}
}
// Client code
const root = new Directory("root");
const file1 = new File("file1.txt");
const file2 = new File("file2.txt");
const subDir = new Directory("subdir");
const file3 = new File("file3.txt");
root.add(file1);
root.add(subDir);
subDir.add(file2);
subDir.add(file3);
root.display();
In this example, the FileSystemComponent
is the component interface, File
is the leaf, and Directory
is the composite. The Composite pattern allows the Directory
to contain both File
and other Directory
objects, enabling hierarchical structures.
The Decorator pattern is used to add additional responsibilities to an object dynamically. It provides a flexible alternative to subclassing for extending functionality.
Example Scenario:
Consider a text editor where text can have various styles (e.g., bold, italic). The Decorator pattern allows styles to be added or removed dynamically without modifying the text class.
// Component
class Text {
getContent() {
throw new Error("This method should be overridden");
}
}
// Concrete Component
class PlainText extends Text {
constructor(content) {
super();
this.content = content;
}
getContent() {
return this.content;
}
}
// Decorator
class TextDecorator extends Text {
constructor(text) {
super();
this.text = text;
}
getContent() {
return this.text.getContent();
}
}
// Concrete Decorators
class BoldText extends TextDecorator {
getContent() {
return `<b>${super.getContent()}</b>`;
}
}
class ItalicText extends TextDecorator {
getContent() {
return `<i>${super.getContent()}</i>`;
}
}
// Client code
const plainText = new PlainText("Hello, World!");
const boldText = new BoldText(plainText);
const italicBoldText = new ItalicText(boldText);
console.log(plainText.getContent()); // Output: Hello, World!
console.log(boldText.getContent()); // Output: <b>Hello, World!</b>
console.log(italicBoldText.getContent()); // Output: <i><b>Hello, World!</b></i>
In this example, the Text
class is the component interface, PlainText
is the concrete component, and TextDecorator
is the decorator. The BoldText
and ItalicText
classes are concrete decorators that add styles dynamically.
The Facade pattern provides a simplified interface to a complex subsystem. It hides the complexities of the subsystem from the client, making the subsystem easier to use.
Example Scenario:
Consider a home theater system with various components like a TV, sound system, and DVD player. The Facade pattern can provide a simple interface to control all these components.
// Subsystem classes
class TV {
on() {
console.log("TV is on");
}
off() {
console.log("TV is off");
}
}
class SoundSystem {
on() {
console.log("Sound system is on");
}
off() {
console.log("Sound system is off");
}
}
class DVDPlayer {
play() {
console.log("DVD is playing");
}
stop() {
console.log("DVD is stopped");
}
}
// Facade
class HomeTheaterFacade {
constructor(tv, soundSystem, dvdPlayer) {
this.tv = tv;
this.soundSystem = soundSystem;
this.dvdPlayer = dvdPlayer;
}
watchMovie() {
this.tv.on();
this.soundSystem.on();
this.dvdPlayer.play();
console.log("Enjoy your movie!");
}
endMovie() {
this.dvdPlayer.stop();
this.soundSystem.off();
this.tv.off();
console.log("Movie ended");
}
}
// Client code
const tv = new TV();
const soundSystem = new SoundSystem();
const dvdPlayer = new DVDPlayer();
const homeTheater = new HomeTheaterFacade(tv, soundSystem, dvdPlayer);
homeTheater.watchMovie();
homeTheater.endMovie();
In this example, the HomeTheaterFacade
class provides a simplified interface for controlling the TV, sound system, and DVD player, hiding the complexities of the subsystem from the client.
The Flyweight pattern reduces the cost of creating and manipulating a large number of similar objects by sharing common parts of objects between multiple contexts.
Example Scenario:
Consider a text editor where each character is represented as an object. The Flyweight pattern can be used to share character objects to reduce memory usage.
// Flyweight
class Character {
constructor(char) {
this.char = char;
}
display(position) {
console.log(`Character ${this.char} at position ${position}`);
}
}
// Flyweight Factory
class CharacterFactory {
constructor() {
this.characters = {};
}
getCharacter(char) {
if (!this.characters[char]) {
this.characters[char] = new Character(char);
}
return this.characters[char];
}
}
// Client code
const factory = new CharacterFactory();
const text = "hello world";
for (let i = 0; i < text.length; i++) {
const char = factory.getCharacter(text[i]);
char.display(i);
}
In this example, the CharacterFactory
class creates and manages Character
objects, ensuring that each character is created only once and shared across multiple contexts.
The Proxy pattern provides a surrogate or placeholder for another object to control access to it. This pattern is useful in scenarios such as lazy loading, access control, and logging.
Example Scenario:
Consider a large image that takes time to load. The Proxy pattern can provide a placeholder image until the actual image is loaded.
// Real Subject
class RealImage {
constructor(filename) {
this.filename = filename;
this.loadFromDisk();
}
loadFromDisk() {
console.log(`Loading ${this.filename} from disk`);
}
display() {
console.log(`Displaying ${this.filename}`);
}
}
// Proxy
class ProxyImage {
constructor(filename) {
this.filename = filename;
this.realImage = null;
}
display() {
if (!this.realImage) {
this.realImage = new RealImage(this.filename);
}
this.realImage.display();
}
}
// Client code
const image = new ProxyImage("large_image.jpg");
image.display(); // Image is loaded from disk
image.display(); // Image is displayed without loading again
In this example, the ProxyImage
class acts as a proxy for the RealImage
class, controlling access to the real image and delaying its loading until necessary.
Structural patterns are essential tools in software design that provide solutions for organizing code and managing complex relationships between objects. By understanding and applying these patterns, developers can create flexible, reusable, and maintainable software systems. Whether it’s using the Adapter pattern to integrate new components or the Composite pattern to build hierarchical structures, structural patterns offer a wide range of benefits that enhance software architecture.