How to compose objects? It's an excellent question; Thinking about composition is, first and foremost, the right way to relate classes. Structural patterns guide us on how and when to wrap some instances in others.
Well-designed applications end up with dozens of classes. Some are like legacy-sealed stones, while others seem living creatures evolving. Sometimes we must deal with complex subsystems or add complexity to a simple buddy.
This article will show how to solve these situations with four structural patterns with examples in TypeScript. Let's dive in!
ð Prerequisites
To complete this tutorial, you will need the following:
-
A local development environment for TypeScript
-
Basic knowledge of Object-Oriented Programming
ð Adapter
Decouples third-party libraries (or legacy code) from the application
The problem
You depend on a third-party library but want to change her for a new one. Or the library has changed in ways you can support by now. Changing this library will be a nightmare if it spreads across the application.
A similar situation occurs when you have to work with several legacy systems. You can't change them but must integrate them with the new application.
// ❌ Bad example not using adapter
// ToDo: ðĪĒ an external library with proprietary format
export type ExternalEventData = {
date: Date;
host: string;
device: string;
severity: number;
extension: string[];
};
export class ExternalEventService {
createMessage(event: ExternalEventData): string[] {
return [
`Date: ${event.date}`,
`Host: ${event.host}`,
`Device: ${event.device}`,
`Severity: ${event.severity}`,
`Extension: ${event.extension.join(", ")}`,
];
}
writeMessage(message: string[]): string {
const eventMessage = message.join("\n");
console.log(eventMessage);
return eventMessage;
}
}
export class Client {
// ! ðĪĒ client classes depending on concrete implementations
private readonly logger: ExternalEventService;
constructor() {
// ! ðĪĒ client classes are coupled to the library
this.logger = new ExternalEventService();
}
public doThings() {
// ! ðĪĒ client classes are coupled to data format
const event: ExternalEventData = {
date: new Date(),
host: "localhost",
device: "myApp",
severity: 0,
extension: ["msg=Hello World"],
};
// ! ðĪĒ client classes are coupled to the interface
const message = this.logger.createMessage(event);
return this.logger.writeMessage(message);
}
}
The solution with the Adapter pattern
The adapter pattern solves the issue by encapsulating the proprietary workflow in a class that implements the desired interface. The rest of the world only sees the desired interface.
// * ✅ Adapter solution
// * Define your desired Interface (or use an existing one)
export interface Logger {
log(entry: LogEntry): string;
}
// * make an adapter implementing the desired interface
export class CommonEventAdapter implements Logger {
// * ð The adapted class is wrapped in a private property
private commonEventService: ExternalEventService = new ExternalEventService();
// * ð The rest of the world only sees the desired interface
log(entry: LogEntry): string {
// * ð knowledge of the proprietary workflow is encapsulated in the adapter
const commonEvent = this.adaptLogEntryToExternalEvent(entry);
const commonEventMessage = this.commonEventService.createMessage(commonEvent);
return this.commonEventService.writeMessage(commonEventMessage);
}
// * ð all the ugly stuff is hidden in the adapter
private adaptLogEntryToExternalEvent(entry: LogEntry): ExternalEventData {
return {
date: entry.timestamp,
host: "localhost",
device: "myApp",
severity: entry.category === "info" ? 0 : 1,
extension: [`msg=${entry.message}`],
};
}
}
ð Bridge
Allows several (usually two) complex subsystems to evolve independently
The problem
It is the generalization of the adapter pattern. It allows the decoupling of objects using interfaces (the bridge) between their abstractions so that the two can vary independently.
Abstraction and implementation are the main concepts you need to understand, having a role play like boss-employee. The abstraction is the high-level interface of a system, while the implementation is the concrete object doing hard work.
Both can evolve independently, only respecting the contract of their interfaces.
// ❌ Bad example not using a bridge
// implicit abstraction
export class Logger {
// ! ðĪĒ The abstraction is coupled to the implementation
log(message: string): void {
const fileWriter = new FileWriter();
fileWriter.write(message);
}
}
// implicit implementor
export class FileWriter {
write(message: string): void {
console.log(`Writing message to file: ${message}`);
}
}
The solution with the Bridge pattern
The bridge pattern solves the issue by decoupling the abstraction from the implementation. Both are interfaces connected by an abstract class wrapping implementor and exposing abstraction.
Structural patterns encourage composition, but we can find an exceptional use of inheritance in this case.
// ✅ Bridge solution
// IMPLEMENTOR
// * implementor interface
export interface Writer {
write(message: string): void;
}
// * ð concrete (refined) implementor
export class FileWriter implements Writer {
write(message: string): void {
console.log(`Writing message to file: ${message}`);
}
}
// * ð another concrete (refined) implementor
export class ApiWriter implements Writer {
write(message: string): void {
console.log(`Writing message to API: ${message}`);
}
}
// ABSTRACTION
// * Abstraction interface
export interface Logger {
readonly writer: Writer;
log(message: string): void;
}
// * ð bridge abstraction
export abstract class LoggerBase implements Logger {
// * ð wraps low-level interface
writer: Writer;
constructor(writer: Writer) {
this.writer = writer;
}
// * ð exposes high-level interface
abstract log(message: string): void;
}
// * ð concrete (refined) abstraction
export class LoggerApp extends LoggerBase {
log(message: string): void {
this.writer.write(message);
}
}
// * ð another concrete (refined) abstraction
export class BrowserLoggerApp extends LoggerBase {
log(message: string): void {
this.writer.write(message + " " + navigator.userAgent);
}
}
ð§ðžðĻ Decorator
Adds functionality to a class without modifying it,
The problem
Having a legacy codebase, you may need to add new functionality to a class without modifying it. The solution comes in the form of a decorator.
// ! ❌ Bad example not using decorator
export class Logger {
log(message: string): void {
console.log(`Logging message: ${message}`);
}
// ToDo: ðą add error logging functionality
// you are forced to modify the original class
}
The solution with the Decorator pattern
The solution is to rewrap the original class into a decorator that implements the same interface and delegates the functionality. The new type should accommodate the new feature.
The decorator pattern heavily impacts the code, so use it cautiously.
// ✅ Decorator solution
// * original class not modified
export class Logger {
log(message: string): void {
console.log(`Logging message: ${message}`);
}
}
// * generate an interface for the current functionality
export interface Log {
log(message: string): void;
}
// * generate an interface for the new functionality
export interface ErrorLog {
errorLog(error: Error): void;
}
// * Create a decorator class that implements the interface by wrapping the original class
export class LoggerDecorator implements Log, ErrorLog {
// * The decorator wraps a reference to the original class
private logger: Logger = new Logger();
// * The decorator class delegates the original functionality to the original class
log(message: string): void {
// ð * could change the functionality if needed
this.logger.log(message);
}
// * ð The decorator class adds new functionality
errorLog(error: Error): void {
console.log(`Logging error: ${error.message}`);
}
}
ð§ą Facade
Provides a simple interface to a set of complex interfaces in a subsystem.
The problem
When you find an object with too many dependencies, it is a sign that you need to use a facade. For example, there is a legacy codebase with much complexity, and you need to simplify it.
// ❌ Bad example not using a Facade
export class Application {
// ToDo : ðĪĒ too many dependencies
private writer = new Writer();
private formatter = new Formatter();
private authService = new AuthService();
doThings(): void {
// ToDo : ðĪĒ too much coupling and knowledge of the subsystems
const user = this.authService.getUser();
const logMessage = this.formatter.format("Doing things", user);
this.writer.write(logMessage);
}
}
The solution with the Facade pattern
You can use a facade to isolate the client from the subsystem, to simplify or hide the details of the subsystem. Using this pattern, you are delegating (i.e., segregating) responsibilities to the facade.
Some facades are created to solve a use case, while others are generic simplifications many clients can use.
// ✅ Facade solution
// * Facade class
export class Logger {
private writer = new Writer();
private formatter = new Formatter();
private authService = new AuthService();
log(message: string): void {
// * ð The complexity of the subsystem is hidden from the client code
const user = this.authService.getUser();
const logMessage = this.formatter.format(message, user);
this.writer.write(logMessage);
}
}
export class Application {
// reduce the number of dependencies
private logger = new Logger();
doThings(): void {
// * ð the client code does his job
this.logger.log("Doing things");
}
}
Here you have a list of other Structural Patterns and their cases of use:
ð Proxy
Provides a placeholder for a wrapper object to control the access to a target.
A proxy wrapper controls access to the original object, allowing you to perform something before or after the request.
ðŠķ Flyweight
Reduces the cost of creating and manipulating a large number of similar objects.
The flyweight pattern reduces the memory footprint by sharing data between similar objects. The wrapper saves the transmitted data acting like a cache.
ð§° Composite
Wraps a group of objects into a single object.
The best example of the composite pattern is the DOM tree. The DOM tree is a composite of nodes, and each node is a composite of other nodes. An invoice is another example of a composite of line items, payments, and addresses.
ð Conclusion
When you go to create structures wrapping objects inside objects, you will find yourself in certain situations repeatedly. There are proven solutions to those problems, called structural patterns. In this article, I showed you their implementation in TypeScript. As a cheat sheet, I remind you of the pattern's names and their use cases.
-
ð Adapter: Decouples third-party libraries (or legacy code) from the application
-
ð Bridge: Allows several (usually two) complex subsystems to evolve independently
-
ð§ðžðĻ Decorator: Adds functionality to a class without modifying it
-
ð§ą Facade: Provides a simple interface to a set of complex interfaces in a subsystem
-
ð Proxy: Provides a placeholder for another object to control access
-
ðŠķ Flyweight: Reduces the cost of creating and manipulating a large number of similar objects
-
ð§° Composite: Wraps a group of objects into a single object
I hope you enjoyed it and learned something new. If you have any questions or suggestions, please leave a comment below.
learn, code, enjoy, repeat
Alberto Basalo