Structural Design Patterns
Focus on how classes and objects are composed to form larger structures
1. Adapter
The Adapter is a structural design pattern that enables you to make make different interfaces with different methods work together without changing their code. The purpose of an Adapter is to make two incompatible interfaces work together seamlessly.
1.1 Components of the Adapter
Target Interface/Class
This is the interface or class that the client will work with. It contains all the methods and properties that the client code will use.
Adaptee
The Adaptee is the old interface/class that contains properties and methods that are incompatible with the new interface/class.
Adapter
The Adapter is what bridges the gap between the Adaptee and the Target interface/class
1.2. Benefits of Adapters
Easy Integration
Adapters create an easy way for new code or systems to interact with existing ones. By using Adapters, integrating new code becomes smoother and less error-prone.
Compatibility and Reusability
Adapters promote code reuse and extends the usability of existing code by making older code compatible with newer code.
Gradual System Integration
In situations where a new system needs to be implemented gradually, Adapters can serve as intermediaries, allowing new features to come in slowly while maintaining compatibility with the existing system.
Improved Testability
Adapters facilitate easier testing by allowing mocking or stubbing of the adaptee during testing of the client code. This improves the testability of the client code and helps in wrtiting more comprehensible unit tests.
1.3. Example
// Adaptee: EU charging brick
class EUChargingBrick {
chargeWithEUPlug() {
console.log('Charging with EU plug');
}
}
// Adaptee: US charging brick
class USChargingBrick {
chargeWithUSPlug() {
console.log('Charging with US plug');
}
}
// Target: Common charging interface expected by the client
class ChargingInterface {
charge() {
console.log('Charging...');
}
}
// Adapter for EU charging brick
class EUChargingAdapter extends ChargingInterface {
constructor(euChargingBrick) {
super();
this.euChargingBrick = euChargingBrick;
}
charge() {
this.euChargingBrick.chargeWithEUPlug();
}
}
// Adapter for US charging brick
class USChargingAdapter extends ChargingInterface {
constructor(usChargingBrick) {
super();
this.usChargingBrick = usChargingBrick;
}
charge() {
this.usChargingBrick.chargeWithUSPlug();
}
}
// Client
function chargeDevice(chargingInterface) {
chargingInterface.charge();
}
// Usage
const euChargingBrick = new EUChargingBrick();
const euAdapter = new EUChargingAdapter(euChargingBrick);
const usChargingBrick = new USChargingBrick();
const usAdapter = new USChargingAdapter(usChargingBrick);
console.log('Charging with EU charging brick:');
chargeDevice(euAdapter);
console.log('Charging with US charging brick:');
chargeDevice(usAdapter);
2. Bridge
The Bridge is a structural design pattern that is designed to split a very large class into two separate hierarchies which can be developed independendently. The two hierarchies are referred to as the Abstraction level and the Implementation level. Basically if you have a class that has multiple variants of some functionality, you can use a Bridge pattern to divide and organize the class into two easier to understand hierarchies.
2.1. Components of the Bridge
Abstraction
This is the high-level interface or abstraction. It defines the abstract functionality that the clients will use.
Refined Abstraction
These are subclasses or extensions of the abstraction layer. These provide additional features or refinements. It extends the functionality defined by the abstraction.
Implementor
This is the interface that defines the implementation methods, It usually doesn't mirror the abstraction interface, but its a lower-level interface that the abstraction relies upon.
Concrete Implementor
Concrete classes that implement the implementor interface. Theses classes provide specific implementations of the methods defined by the implementor interface.
2.2. Benefits of the Bridge Pattern
Decouples Abstraction from Implementation
The primary benefit of the Bridge pattern is it splits the abstraction layer from the implementation layer. This allows both sections to evolve independently, making the code base easier to modify.
Improves Maintainability
Since the code base is split into two sections, making changes to one part of the system is most likely not going to impact the other part. Which makes maintaining the code base easier and more efficient
Improves Testing
Testing is a lot easier when you have a bridge pattern in your code base because you can focus on testing the abstraction layer separately from testing the implementation layer. This allows for easier and more targeted testing.
Improves Readability
The Bridge pattern creates a clear hierarchy in the code base. Organzing the code base in this way helps in understanding the relationships between different parts of the system.
2.3. Example
// Abstraction
class Shape {
constructor(color) {
this.color = color;
}
draw() {
console.log(`Drawing a shape with color ${this.color}`);
}
}
// Implementations
class RedColor {
applyColor() {
console.log('Applying red color');
}
}
class BlueColor {
applyColor() {
console.log('Applying blue color');
}
}
// Bridge
class ShapeWithColor extends Shape {
constructor(color, colorImplementation) {
super(color);
this.colorImplementation = colorImplementation;
}
draw() {
super.draw();
this.colorImplementation.applyColor();
}
}
// Usage
const redShape = new ShapeWithColor('red', new RedColor());
const blueShape = new ShapeWithColor('blue', new BlueColor());
redShape.draw(); // Output: Drawing a shape with color red, Applying red color
blueShape.draw(); // Output: Drawing a shape with color blue, Applying blue color
3. Composite
The composite design pattern allows for the creation of objects with properties that are primitive items or a collection of objects. Imagine a tree like structure, where you have single objects (leaf nodes) or groups of objects (branches). The composite design pattern allows you to create this type of structure and be able to perform operations on each level in a consistent manner.
3.1 Components of the Composite
Component
This is the interface/class that represents both leaf nodes (individual elements) and composite nodes (collection of elements). The component defines operations that can be applied to both types of nodes.
Leaf
This represents individual objects in the tree that do not have any children. They implement the operations that are defined in the component interface.
Composite
This represents the composites or containers that can hold a collection of leaf nodes or other composite nodes.
3.2. Benefits of Composites
Uniformly and Consistency
The Composite design pattern provides a uniform way to treat both individual objects and compositions of objects. Clients have one common interface to use to operate on these objects which simplifes the code base and object interactions.
Flexibility
This design pattern allows for flexibility in adding new types of components or modifying existing ones without affecting the client code. You can introduce new types of leaf or composite objects easily.
Simplified Client Code
The client code doesn't need to distinguish between individual and composite components, making it simpler and more intuitive to work with the structure.
3.3. Example
class SingleBlock {
constructor(name) {
this.name = name;
}
display() {
console.log('Block:', this.name);
}
}
class BlockCollection {
constructor(name) {
this.name = name;
this.blocks = [];
}
add(block) {
this.blocks.push(block);
}
remove(block) {
this.blocks = this.blocks.filter(b => b !== block);
}
display() {
console.log('Block Collection:', this.name);
for (const block of this.blocks) {
block.display();
}
}
}
// Usage
const block1 = new SingleBlock('Block 1');
const block2 = new SingleBlock('Block 2');
const block3 = new SingleBlock('Block 3');
const blockGroup1 = new BlockCollection('Block Group 1');
blockGroup1.add(block1);
blockGroup1.add(block2);
const blockGroup2 = new BlockCollection('Block Group 2');
blockGroup2.add(block3);
const megaStructure = new BlockCollection('Mega Structure');
megaStructure.add(blockGroup1);
megaStructure.add(blockGroup2);
megaStructure.display();
4. Decorator
The Decorator design pattern can be used to modify an objects behavior either statically or dynamically without affecting the behavior of other objects from the same class. Decorators are particularly useful when you want to add features to an object in a flexible and reusable way.
4.1. Components of a Decorator
Component Interface
This defines the logic for the objects that can have resposibilities added to them dynamically.
Concrete Components
This is the initial object to which additional functionalities can be added.
Decorator
This is an interface that extends the functionality of the concrete components. It has a reference to a component instance and implements the component interface.
Concrete Decorators
These are the concrete implementations of the decorator class, they add specific behavior to the desired component by extending the decorator class.
4.2. Benefits of Decorators
Extensibility and Flexibility
Decorators allow you to add new functionalities or behaviors to objects dynamically at runtime. This promotes extensibility without modifying the existing codebase and provides flexibility in how you can compose and use these additional functionalities.
Modularity
Decorators enable a more modular approach to code by breaking down functionality into smaller, more manageable units. These units can be combined and reused in various ways.
Runtime Configuration
Decorators allow you to dynamically configure an object at runtime. This allows you to add or remove functionalities without impacting the core components or needing to recompile the code.
Reduce Subclassing
Without Decorators, extending functionalities often involves creating numerous subclasses for each combination of behaviors. Decorators eliminate the need for subclasses which results in an cleaner and easier to understand code base.
4.3. Example
class Coffee {
cost() {
return 5;
}
}
class MilkDecorator {
constructor(coffee) {
this.coffee = coffee;
}
cost() {
return this.coffee.cost() + 2;
}
}
class SugarDecorator {
constructor(coffee) {
this.coffee = coffee;
}
cost() {
return this.coffee.cost() + 1;
}
}
// Usage
let coffee = new Coffee();
console.log('Cost of plain coffee:', coffee.cost());
let milkCoffee = new MilkDecorator(coffee);
console.log('Cost of milk coffee:', milkCoffee.cost());
let sugarMilkCoffee = new SugarDecorator(milkCoffee);
console.log('Cost of sugar milk coffee:', sugarMilkCoffee.cost());
5. Facade
The Facade design pattern is basically a simplified interface that the client can interact with to use other low level operations hidden elsewhere in the code base. This design pattern is often used in systems that are built around a multi-layer architecture. Facades allow the client to perform certain tasks without needing to understand the complexity of the underlying system.
5.1. Components of the Facade
Facade
The facade is the interface that the client will interact with. It provides a simple and unified interface that delegates the clients requests to the appropriate objects within the subsystem
Subsystem
The subsystem consists of all the various components and functionalities that the Facade wraps around. The subsystem and the Facade interact with eachother but operate independently.
5.2. Benefits of the Facade
Simplified Interface
The Facade provides a simple and easy to understand interface
Code Organization
The Facade helps organize the code by encapsulating the subsystem's functionality and providing a clear separation of concerns
Easier Maintenance
Changes to the subsystem can be isolated within the facade, reducing the impact on the client code.
5.3. Example
// Plumbing subsystem
class PlumbingSubsystem {
constructor() {}
turnOnWater() {
console.log('Plumbing: Water turned on');
}
turnOffWater() {
console.log('Plumbing: Water turned off');
}
}
// Electrical subsystem
class ElectricalSubsystem {
constructor() {}
turnOnElectricity() {
console.log('Electrical: Electricity turned on');
}
turnOffElectricity() {
console.log('Electrical: Electricity turned off');
}
}
// House facade
class HouseFacade {
constructor() {
this.plumbingSubsystem = new PlumbingSubsystem();
this.electricalSubsystem = new ElectricalSubsystem();
}
enterHouse() {
this.plumbingSubsystem.turnOnWater();
this.electricalSubsystem.turnOnElectricity();
console.log('You have entered the house.');
}
leaveHouse() {
this.plumbingSubsystem.turnOffWater();
this.electricalSubsystem.turnOffElectricity();
console.log('You have left the house.');
}
}
// Client
const client = () => {
const house = new HouseFacade();
// Enter the house
house.enterHouse();
// Leave the house
house.leaveHouse();
};
// Run the client
client();
6. Flyweight
The Flyweight design pattern aims to minimize memory usage or computaional expenses by storing intrinsic values (similar properties) of similar object in an application, reducing the amount of duplicate code. This is particularly useful when dealing with a large number of similar objects in an application.
6.1. Components of a Flyweight
FlyweightFactory
The flyweight factory creates the flyweight objects. It contains a function that will create a flyweight if one does not already exist and it stores newly created flyweights for future request.
Flyweight
The flyweight contains the intrinsic data that will be shared across the application
6.2. Benefits of Flyweights
Memory Efficiency
By sharing intrinsic data among multiple objects, the Flyweight pattern significantly reduces memory usage especially when dealing with a large number of instances.
Performance Improvements
Due to reduced memory usage, the application's overall performance improves. Lower memory usage typically leads to faster execution times and smoother application performance.
Simplifies State Management
By separating intrinsic data (shared values) and extrinisc data (unique values), Flyweights simplify the management of these states. It allows for a cleaner separation of concerns and more organized approach to state handling.
6.3. Example
// Flyweight object for Camera
function Camera(make, model, resolution) {
this.make = make;
this.model = model;
this.resolution = resolution;
}
// Flyweight factory for Camera
var FlyWeightCameraFactory = (function () {
var flyweights = {};
return {
get: function (make, model, resolution) {
if (!flyweights[make + model]) {
flyweights[make + model] = new Camera(make, model, resolution);
}
return flyweights[make + model];
},
getCount: function () {
var count = 0;
for (var f in flyweights) count++;
return count;
}
};
})();
// Camera collection
function CameraCollection() {
var cameras = {};
var count = 0;
return {
add: function (make, model, resolution, serial) {
cameras[serial] = {
flyweight: FlyWeightCameraFactory.get(make, model, resolution),
serial: serial
};
count++;
},
get: function (serial) {
return cameras[serial];
},
getCount: function () {
return count;
}
};
}
// Run the example
function run() {
var cameras = new CameraCollection();
cameras.add("Canon", "EOS R5", "45MP", "A1234");
cameras.add("Nikon", "D850", "45.7MP", "B5678");
cameras.add("Sony", "A7R III", "42.4MP", "C9101");
cameras.add("Canon", "EOS R5", "45MP", "D1212"); // Reusing existing flyweight
console.log("Cameras: " + cameras.getCount());
console.log("Flyweights: " + FlyWeightCameraFactory.getCount());
}
// Run the example
run();
7. Proxy
The Proxy design pattern is a structural design pattern that allows you to provide a substitute or placeholder for another object. This proxy object can control access to the original object. In Javascript, the proxy
object is built into the language and facilitates the implementation of the Proxy design pattern.
7.1. Components of the Proxy
Proxy
The Proxy contains an interface that is similar to the real object, it maintains a reference that lets the proxy access the real object and it handles requests and forwards them to the real object.
RealSubject
This is the actual object that the Proxy is substituting for.
7.2. Benefits of Proxies
Controlled Access
Proxies allow you to control access to the original object, enabling you to implement access control logic such as permissions, restrictions, or validations before allowing access to the underlying object.
Behavior Augmentation
Proxies can add additional behavior or functionality before or after the invocation of methods or access to properties of the original object. This is useful for implementing cross-cutting concerns like logging, caching, or error handling.
Caching
Proxies can implement caching mechanism to store results of expensive operations, improving performance and efficiency
Lazy Initialization
Proxies enable lazy initialization, where you can delay the creation of the actual object until its needed. This can improve performance by reducing upfront resource usage.
7.3. Example
// Original object representing a bank account
const bankAccount = {
balance: 1000,
deposit(amount) {
this.balance += amount;
console.log(`Deposited ${amount}. New balance: ${this.balance}`);
},
withdraw(amount) {
if (amount <= this.balance) {
this.balance -= amount;
console.log(`Withdrew ${amount}. New balance: ${this.balance}`);
} else {
console.log('Insufficient funds.');
}
}
};
// Create a proxy for the bank account
const bankAccountProxy = new Proxy(bankAccount, {
// Intercept property access
get(target, property) {
if (property === 'balance') {
// Add some custom behavior before accessing 'balance'
console.log('Balance accessed.');
}
return target[property];
},
// Intercept method invocation
apply(target, thisArg, args) {
// Add some custom behavior before invoking a method
console.log(`Method "${args[0]}" called.`);
return target.apply(thisArg, args);
}
});
// Accessing the proxy
console.log(bankAccountProxy.balance); // This will trigger the custom behavior
bankAccountProxy.deposit(500); // This will trigger the custom behavior for method invocation