Structural patterns are about composition — how you assemble objects into larger structures while keeping things flexible. If you’ve ever wrapped a library to make it fit your interface, or built a tree of components that all respond to the same method, you’ve already used these ideas. Here are 9 patterns that formalize them.
1. Adapter / Wrapper
The Adapter Pattern acts as a middleman between two classes, converting the interface of one or more existing classes into a different interface that the class you’re writing expects. This allows classes with incompatible interfaces to communicate through an intermediate interface, without modifying the existing class or the class being written. It’s also called the Wrapper Pattern because it provides a compatible “wrapper” interface for an existing system that has the right data and behavior but an incompatible interface.
You already know what an adapter is. Power adapters, laptop chargers, memory card adapters — they all do one thing: bridge two incompatible things so they work together. The Adapter pattern in code does exactly the same.
1.1. Why use the Adapter Pattern
I used to rewrite the same utility code from project to project. At some point I decided to build my own library for reuse. But guess what — the interface I designed fit the old project perfectly and was completely wrong for the new one. Back to square one. The Adapter Pattern saves you from this loop. Use it when:
- You need to use an existing class (or a third-party library) whose interface does not match what your code expects.
- You want to build a reusable class that can work with future classes that have incompatible interfaces.
To understand how the Adapter Pattern works, you need three concepts:
- Client: The class that will use your object (the object whose interface you want to convert)
- Adaptee: The class you want the Client to use, but whose current interface is incompatible
- Adapter: The intermediary class that converts the Adaptee’s interface and connects it to the Client
1.2. Types of adapters
- Composition: Class B becomes a field inside class A. Class A does not inherit B’s interface, but it gets all of B’s capabilities.
- Inheritance: A Derived class inherits from a Base class and gets everything the Base has. Inheritance is a core OOP concept — it improves code reuse and maintainability. But if you overuse it, your codebase turns into a mess. Game developers learned this the hard way, which is why many of them prefer composition.
These two approaches give us two ways to implement the adapter: Object Adapter and Class Adapter.
1.3. Class Adapter
Here, a new class (Adapter) inherits from the incompatible class (Adaptee) and implements the interface the user expects (Target). When you call Target’s methods on the Adapter, it internally calls the methods it inherited from the Adaptee.
1.3.1. Structure
1.3.2. Example
- Create the Target interface for the user
interface Target {
request(): string;
}
- Create the Adaptee class with the incompatible interface
class Adaptee {
specificRequest() : string {
return ".eetpadA eht fo roivaheb laicepS";
}
}
- Create the Adapter class that converts the interface to match Target
class Adapter extends Adaptee implements Target {
constructor() {
super();
}
request(){
return "Adapter: (TRANSLATED) "+this.specificRequest().split("").reverse().join("");
}
}
- Client usage
function clientCode(target: Target) {
console.log(target.request());
}
let adaptee = new Adaptee();
console.log("Client: The Adaptee class has a weird interface. See, I don't understand it");
console.log("Adaptee: "+adaptee.specificRequest());
console.log("Client: But I can work with it via the Adapter");
let adapter = new Adapter();
clientCode(adapter);
- Result
Client: The Adaptee class has a weird interface. See, I don't understand it
Adaptee: .eetpadA eht fo roivaheb laicepS
Client: But I can work with it via the Adapter
Adapter: (TRANSLATED) Special behavior of the Adaptee.
1.4. Object Adapter
This one uses composition instead of inheritance. The Adapter holds a reference to the Adaptee object and implements the Target interface. When you call Target’s methods, the Adapter delegates to the Adaptee through that reference. This avoids the multiple inheritance problem — which Java and C# do not support anyway. I recommend this approach.
1.4.1. Object Adapter Structure
1.4.2. Example
- Create the Target class (the original class used by the client)
class Target {
request() {
return "Target: The default target's behavior.";
}
}
- Create the Adaptee class with the incompatible interface
class Adaptee {
specificRequest() {
return ".eetpadA eht fo roivaheb laicepS";
}
}
- Create the Adapter class that converts the interface to match Target
class Adapter implements Target {
constructor(private obj: Adaptee) { }
request(){
return "Adapter: (TRANSLATED) "+this.obj.specificRequest().split("").reverse().join("");
}
}
- Client usage
function clientCode(target: Target) {
console.log(target.request());
}
let adaptee = new Adaptee();
console.log("Client: The Adaptee class has a weird interface. See, I don't understand it");
console.log("Adaptee: "+adaptee.specificRequest());
console.log("Client: But I can work with it via the Adapter");
let adapter = new Adapter(adaptee);
clientCode(adapter);
- Result
Client: The Adaptee class has a weird interface. See, I don't understand it
Adaptee: .eetpadA eht fo roivaheb laicepS
Client: But I can work with it via the Adapter
Adapter: (TRANSLATED) Special behavior of the Adaptee.
1.5. Conclusion
I find the Adapter Pattern incredibly useful in large applications that consume many external APIs. It acts as a buffer — when an API provider changes something, you only update the adapter, not your entire codebase. Yes, you end up creating extra classes and interfaces. But in a big system, that upfront cost pays for itself many times over.
2. Bridge Pattern
The Bridge Pattern is used when you want to split a large class with complex features into a main class (Abstraction) and multiple feature interfaces (Implementation) that evolve independently. They are connected by having the main class hold a reference to the feature interface — this is the bridge. Changes to the Abstraction won’t affect the Implementation and vice versa.
2.1. Problem
Say you have a Shape class with two subclasses: Circle and Square. Now you want to add colors. Since you already have two shape subclasses, you would need four combinations: BlueCircle, RedSquare, and so on.
This blows up fast. Add a Triangle? Two more subclasses (one per color). Add a new color? Three more subclasses (one per shape). Every new dimension multiplies the total number of classes.
That is exactly where the Bridge Pattern comes in.
2.2. When to use the Bridge Pattern
- When you want to split and organize a class that has variants of some feature (e.g., a class that needs to work with different database servers)
- When you need to extend a class along multiple independent dimensions
- When you need to swap Implementations at runtime
2.3. Structure
- Abstraction: High-level control logic. Delegates low-level work to Implementation objects.
- Implementation: Declares the interface for a feature.
- Concrete Implementations: Variants of the Implementation.
- Refined Abstractions: Variants of the Abstraction.
- Client: The user who wires Abstractions and Implementations together.
2.4. Implementation steps
- Identify the independent dimensions in your classes. Think abstraction/platform, domain/infrastructure, front-end/back-end, or interface/implementation.
- List the operations the client needs and declare them in the Abstraction class.
- Figure out what operations the Implementation variants need and declare them in the Implementation interface.
- Create concrete implementation classes for all variants, making sure they follow the Implementation interface.
- Add a reference field of the Implementation type to the Abstraction class. The Abstraction delegates most work to that referenced object.
- If you have several Abstraction variants, create Refined Abstractions by extending the base Abstraction.
- The Client passes an Implementation object into the Abstraction’s constructor. After that, the Client only works with the Abstraction.
2.5. Example
- Declare the Abstraction class
class Abstraction {
constructor(protected iA : ImplementationA) { }
operation() : string {
return `Abstraction - Base operation with: \n${this.iA.operation_implementation()}`;
}
}
- Create the interface for ImplementationA (a feature with multiple variants)
interface ImplementationA {
operation_implementation() : string;
}
- Declare the variants of ImplementationA (horizontal extension)
class ConcreteAImplementationA implements ImplementationA {
operation_implementation() : string {
return `Concrete A of Implementation A`;
}
}
class ConcreteBImplementationA implements ImplementationA {
operation_implementation() : string {
return `Concrete B of ImplementationA`;
}
}
- Client usage
function client_code(abstraction: Abstraction) {
console.log(abstraction.operation());
}
let concreteAimplementationA = new ConcreteAImplementationA();
let abstraction = new Abstraction(concreteAimplementationA);
client_code(abstraction);
- Result
Abstraction - Base operation with:
Concrete A of Implementation A
- We can extend the Abstraction class (vertical extension)
class ExtendedAbstraction extends Abstraction {
constructor(protected iA: ImplementationA) {
super(iA);
}
operation() : string {
return `ExtendedAbstraction - Extended operation with: \n${this.iA.operation_implementation()}`;
}
}
let concreteBimplementationA = new ConcreteBImplementationA();
let extendedAbstraction = new ExtendedAbstraction(concreteBimplementationA);
client_code(extendedAbstraction);
- Result
ExtendedAbstraction - Extended operation with:
Concrete B of ImplementationA
3. Composite
The Composite Pattern lets you work with similar objects in a tree structure.
3.1. When to use the Composite Pattern
- When you are building something with a natural tree structure — org charts, file systems, playlists, nested menus.
- When you want client code to treat individual elements and groups of elements the same way.
3.2. Structure
Three types of classes here:
- Component: The base class that defines the shared interface for everything in the tree.
- Leaf: A standalone element with no children. Cannot be broken down further.
- Composite: A higher-level element that is both a component and a container for other components.
3.3. Implementation
- Make sure your model can be represented as a tree. Break it into simple elements and containers. Containers must hold both simple elements and other containers.
- Declare the
Component interfacewith methods that make sense for both simple and complex components. - Create
Leafclasses for the simple elements. You can have many different leaf types. - Create a
Containerclass for complex elements. Give it an array to store child references. The array type should be the Component interface so it can hold both leaves and containers. - Implement add and remove methods on the container.
3.4. Example
- Create the Component interface
class Employee {
private subordinates: Array<Employee>;
constructor(
private name: string,
private dept: string,
private salary: number
) {
this.subordinates = new Array<Employee>();
}
add(e: Employee): void {
this.subordinates.push(e);
}
remove(e: Employee): void {
this.subordinates.splice(this.subordinates.indexOf(e), 1);
}
getChildren(): Array<Employee> {
return this.subordinates;
}
toString(): string {
return (`Employee :[ Name : ${this.name}, dept : ${this.dept}, salary : ${this.salary} ]`);
}
}
- Build the Component tree by adding nodes (employees), with the CEO at the top
let CEO: Employee = new Employee("John", "CEO", 30000);
let headSales : Employee = new Employee("Robert", "Head Sales", 20000);
let headMarketing : Employee = new Employee("Michel", "Head Marketing", 20000);
let clerk1 : Employee = new Employee("Laura", "Marketing", 10000);
let clerk2 : Employee = new Employee("Bob", "Marketing", 10000);
let salesExecutive1 : Employee = new Employee("Richard", "Sales", 10000);
let salesExecutive2 : Employee = new Employee("Rob", "Sales", 10000);
CEO.add(headSales);
CEO.add(headMarketing);
headSales.add(salesExecutive1);
headSales.add(salesExecutive2);
headMarketing.add(clerk1);
headMarketing.add(clerk2);
//print all employees of the organization
console.log(CEO);
for (let headEmployee of CEO.getChildren()) {
console.log(headEmployee);
for (let employee of headEmployee.getChildren())
console.log(employee);
}
- Result
Employee :[ Name : John, dept : CEO, salary : 30000 ]
Employee :[ Name : Robert, dept : Head Sales, salary : 20000 ]
Employee :[ Name : Richard, dept : Sales, salary : 10000 ]
Employee :[ Name : Rob, dept : Sales, salary : 10000 ]
Employee :[ Name : Michel, dept : Head Marketing, salary : 20000 ]
Employee :[ Name : Laura, dept : Marketing, salary : 10000 ]
Employee :[ Name : Bob, dept : Marketing, salary : 10000 ]
4. Decorator
Decorator is used when you want to add functionality to an existing object without affecting other objects of the same class.
4.1. Problem
Sometimes you need to extend a method on an object. The obvious solution is inheritance. But inheritance can make things way more complex than they need to be. The Decorator Pattern gives you a flexible alternative.
4.2. Difference between dynamic and static extension
- Static extension: You subclass and extend rigidly. The extension is baked into the subclass. Want more features? More subclasses. Want to remove a feature? Another subclass. It spirals.
- Dynamic extension: You provide a mechanism to modify an existing object without affecting other objects of the same class.
4.3. Example scenario
Picture this: you work at a Pizza shop. You make tomato pizza and chicken pizza. Customers can add toppings — chicken or pepper. That gives you combos like tomato chicken pizza, tomato pepper pizza, cheese chicken pizza, cheese pepper pizza, tomato chicken pepper pizza, and cheese chicken pepper pizza. With static extension, you would create a class for each combo: TomatoChickenPizza, TomatoPepperPizza, etc. The number of classes explodes. Dynamic extension is the way out.
4.4. Structure
The moving parts:
- Component (IPizza): The shared interface for creating objects.
- ConcreteComponent (TomatoPizza, ChickenPizza): Objects that implement the Component interface.
- Decorator (PizzaDecorator): An abstract class that holds a reference to the original object and sets up the plumbing for decoration.
- ConcreteDecorator (PepperDecorator, CheeseDecorator): Actual decorators that bolt on extra functionality to the wrapped object.
4.5. Implementation
- Create the Component interface (IPizza):
interface IPizza {
doPizza() : string;
}
- Create 2 concrete Components:
class TomatoPizza implements IPizza {
doPizza(): string {
return "I am a Tomato Pizza";
}
}
class ChickenPizza implements IPizza {
doPizza(): string {
return "I am a Chicken Pizza";
}
}
- Create an abstract Decorator class as the backbone for concrete Decorators
abstract class PizzaDecorator implements IPizza {
constructor(protected mPizza: IPizza) { }
getPizza(): IPizza {
return this.mPizza;
}
setPizza(mPizza: IPizza): void {
this.mPizza = mPizza;
}
abstract doPizza(): string;
}
- Create 2 concrete Decorators: PepperDecorator and CheeseDecorator. Each one adds a topping. The extension logic lives in methods like addPepper().
class CheeseDecorator extends PizzaDecorator {
constructor(pizza: IPizza) {
super(pizza);
}
doPizza(): string {
let type: string = this.mPizza.doPizza();
return type + this.addCheese();
}
// This is additional functionality
// It adds cheese to an existing pizza
addCheese(): string {
return " + Cheese";
}
}
class PepperDecorator extends PizzaDecorator {
constructor(pizza: IPizza) {
super(pizza);
}
doPizza(): string {
let type: string = this.mPizza.doPizza();
return type + this.addPepper();
}
// This is additional functionality
// It adds cheese to an existing pizza
addPepper(): string {
return " + Pepper";
}
}
- Stack decorators to add toppings (pepper, cheese) to pizzas
let tomato : IPizza = new TomatoPizza();
let chicken : IPizza = new ChickenPizza();
console.log(tomato.doPizza());
console.log(chicken.doPizza());
// Use Decorator pattern to extend existing pizza dynamically
// Add pepper to tomato-pizza
let pepperDecorator : PepperDecorator = new PepperDecorator(tomato);
console.log(pepperDecorator.doPizza());
// Add cheese to tomato-pizza
let cheeseDecorator : CheeseDecorator = new CheeseDecorator(tomato);
console.log(cheeseDecorator.doPizza());
// Add cheese and pepper to tomato-pizza
// We combine functionalities together easily.
let cheeseDecorator2 : CheeseDecorator = new CheeseDecorator(pepperDecorator);
console.log(cheeseDecorator2.doPizza());
- Result
I am a Tomato Pizza
I am a Chicken Pizza
I am a Tomato Pizza + Pepper
I am a Tomato Pizza + Cheese
I am a Tomato Pizza + Pepper + Cheese
5. Facade
Wraps a complex subsystem with a simple interface.
5.1. Advantages of the Facade Pattern
- Makes your library easier to use and understand.
- Reduces coupling between external code and library internals — most code talks to the Facade, giving you room to refactor behind it.
- Hides a collection of messy APIs behind a single, clean one.
5.2. Problem
Say you have a sequence of operations that runs in order, and you need it in multiple places across your app. So you copy-paste it everywhere. Feels fast. Then you realize the sequence needs to change. Now you are hunting through the entire codebase, fixing it in every spot, hoping you did not miss one. That is the problem. You lose control. You waste time. You introduce bugs.
5.3. Solution
Build a Facade with a method that encapsulates that repeated sequence. Now every call site just calls the Facade. Need to change the logic? One place. Done.
5.4. Structure
Subsystems inside the Facade can also use the Facade.
5.5. Example
Think about an online checkout flow on your website:
- Add product to cart
- Calculate shipping cost
- Calculate discount
- Create the order
// Checkout processing
let productId = "123456";
let product = Product.find(productId);
if(product.length > 0) {
// Add to cart
let cart = new Cart();
cart.addItem(product);
// Calculate shipping
let shipping = new ShippingCharge(product);
shipping.calculateCharge();
// Apply discount
let discount = new Discount(product);
discount.applyDiscount();
// Create order
let order = new Order();
order.generateOrder();
...
}
- That is a lot of objects wired together for one checkout flow. Copying this block everywhere is asking for trouble.
- Here is the same thing wrapped in a Facade:
class OrderFacade {
private product: any;
constructor(productId: string) {
this.product = Product.find(productId);
}
generateOrder() {
// Handle all logic
if(this.checkQuantity()) {
this.addToCart();
this.calulateShipping();
this.applyDiscount();
this.placeOrder();
}
}
private addToCart () {
let cart : Cart = new Cart();
cart.addItem(this.product);
}
private checkQuantity() {
return this.product.length != 0 ? true : false;
}
private function calulateShipping() {
let shipping : ShippingCharge = new ShippingCharge(this.product);
shipping.calculateCharge();
}
private applyDiscount() {
let discount : Discount = new Discount();
discount.applyDiscount(this.product);
}
private placeOrder() {
let order : Order = new Order();
order.generateOrder();
}
}
- Usage
let productId = "123456";
let order = new OrderFacade(productId);
order.generateOrder();
All that complexity now lives inside generateOrder. Need to change the flow? Edit the Facade. Need the flow somewhere else? Call generateOrder(). No more copy-pasting walls of code.
5.6. Conclusion
I think of the Facade Pattern like having a personal assistant. You tell them what you need done, and they figure out the steps. That is what a Facade object does — it orchestrates multiple tasks behind a single call.
6. Proxy
Proxy provides a surrogate class that stands in front of the real class to handle processing, improve security, and boost performance when working with the underlying class’s data.
6.1. When to Use Proxy Pattern?
- When you want to add security layers or control access to an object’s data
- When you need flexible data access strategies like Lazy Loading
6.2. Types of Proxy
- Remote Proxy: Represents an object that lives at a different address
- Virtual Proxy: For data that is expensive to initialize. Instead of loading everything upfront, it defers until the user actually asks for it. Lazy Loading is the classic use case.
- Protective Proxy: Controls access to the original object with security checks
- Smart Proxy: Adds extra behavior when you access or retrieve data from the object
6.3. Structure
Three participants:
- Subject: The interface shared by RealSubject and Proxy
- RealSubject: The actual object the user works with
- Proxy: Implements the Subject interface and sits in front of RealSubject. It handles pre/post-processing and security, and holds a reference to the RealSubject to access its data.
6.4. Example
- Create interface IItem and class Item implementing it
- Item is cheap to create, but fetching its content value is expensive (imagine each fetch hits a remote server — think of the IItem object as an index/ID for that content)
interface IItem {
delaytoResponseContent(): Promise<number>;
content: number;
}
class Item implements IItem {
// Content value lives on the server
protected _content: number = Math.round(Math.random() * 10);
get content() {
return this._content;
}
set content(n: number) {
this._content = n;
}
// Returning the content value is expensive
async delaytoResponseContent() : Promise<number> {
await this.sleep(1000);
return this.content;
}
// Simulate connection cost
sleep(ms: number) {
return new Promise((resolve) =>
setTimeout(resolve, ms)
)
}
}
- Create interface Subject and classes RealSubject, Proxy
interface Subject {
data: Array<IItem>;
Request(index: number): any;
}
class RealSubject {
constructor(private _data: Array<IItem>) { }
get data() : Array<IItem> {
return this._data;
}
Request(index: number) : Promise<number> {
return this.data[index].delaytoResponseContent();
}
}
class SubjectProxy implements Subject {
// Declared for interface compliance but unused
data!: Array<IItem>;
// Maintains a connection to RealSubject for data processing
private realSubject!: RealSubject;
// Connect to the host through RealSubject
constructor(data: Array<IItem>) {
this.realSubject = new RealSubject(data);
}
// - Override the Request method
// - Instead of fetching the expensive content value,
// return just the index/ID of that value
// - When the user needs the actual content, they call
// delaytoResponseContent to fetch it
Request(index: number) : IItem {
return this.realSubject.data[index];
}
}
- Create a mock host containing Item data
// Create a mock host containing content and Item index
let data : Array<IItem> = [
new Item(),
new Item(),
new Item(),
new Item(),
new Item(),
new Item(),
];
- Without Proxy: Imagine a slow connection that fetches one item at a time. We have to load everything sequentially, even though the user wants item 4 first.
let realSubject = new RealSubject(data);
(async () => {
let wanted : number = 3;
for(let i = 0; i < 5; i++) {
await realSubject.Request(i).then(
res => {
if(i!=wanted)
console.log("Result: "+res)
else
console.log("Wanted result: "+res)
}
);
}
})();
- Result without Proxy
Result: 4
Result: 5
Result: 5
Wanted result: 4
Result: 9
- With Proxy using Lazy Loading (Virtual Proxy): Load the needed data first, then the rest.
let proxy = new SubjectProxy(data);
(async () => {
let wanted : number = 3;
let results : Array<IItem> = new Array<IItem>();
for(let i = 0; i < 5; i++) {
results.push(proxy.Request(i));
if(i==wanted)
results[i].delaytoResponseContent()
.then(res => console.log("Wanted result via proxy: "+res));
}
for(let i = 0; i < 5; i++)
if(i!=wanted)
results[i].delaytoResponseContent()
.then(res => console.log("Result via proxy: "+res))
})();
- Result with Proxy
Wanted result via proxy: 1
Result via proxy: 0
Result via proxy: 7
Result via proxy: 2
Result via proxy: 1
- You can also layer in security checks and error handling on the proxy for better system protection (Smart Proxy).
6.5. Comparison with Other Structural Patterns
Proxy looks a lot like Adapter and Decorator, so it is worth calling out the differences:
- Unlike Adapter: Adapter gives you a different interface from the original object. Proxy gives you the same interface.
- Unlike Decorator: They can be implemented similarly, but the intent is different. Decorator adds responsibilities. Proxy controls access. Depending on the type, Proxy may look like a Decorator:
- Protection Proxy, Smart Proxy: Can be implemented like a Decorator
- Remote Proxy: Does not reference the real object directly — uses indirect references like host IDs and addresses
- Virtual Proxy: Uses indirect references (file names, indices) and switches to direct references only when needed
7. Flyweight
A shared object that can be used simultaneously in many different contexts, acting as an independent object in each. Each concrete object references the same shared instance from the Flyweight object pool.
7.1. Problem
Here is a scenario I find fun. You build a battle royale shooter like PUBG. You finish it, push a build, send it to a friend. The game crashes after a few minutes. After digging through error logs, you find out it ran out of RAM because of the particle system. Every bullet, missile, and piece of shrapnel is a separate object packed with data. During intense combat, new particles spawn non-stop until RAM is exhausted and the game dies.
7.2. Solution
Look closely at the Particle class. The color and sprite fields eat way more memory than the rest. Worse, they store nearly identical data across all objects — that is the Intrinsic state. All bullets share the same color and sprite. They only differ in coord, vector, and speed — that is the Extrinsic state.
- Intrinsic state: The constant, unchanging part of an object
- Extrinsic state: The part that varies across contexts
The fix: stop storing extrinsic state inside the object. Move it to method parameters instead. Now the object only holds intrinsic state. You reuse these stripped-down objects (flyweights) combined with extrinsic state per context. Same data, way less memory. An object that has shed its extrinsic state is called a flyweight.
Note: Since a flyweight is shared across many contexts, never modify its properties after initialization. Set them once and leave them alone.
Back to the game. Say there are three particle types: bullets, missiles, and shrapnel. They spawn in huge quantities, so we need to optimize. After extracting extrinsic state from their classes (turning them into flyweights), where does the extrinsic state go? It still needs to live somewhere. In most cases, it goes into a Container that manages both extrinsic states and flyweights. In this example, the Container is the Game class. Extrinsic state enters the Container through Context objects, which hold:
- The extrinsic state
- A reference to the matching flyweight
To manage flyweights better, we use a FlyweightFactory instead of a plain array.
- FlyweightFactory: Takes intrinsic state as input, looks for a matching flyweight, returns it if found, or creates a new one.
7.3. When to Use Flyweight Pattern?
Only when your program holds a massive number of nearly identical objects and you need to cut memory usage. Otherwise, the added complexity is not worth it.
7.4. Structure
The pieces:
- Flyweight: Holds intrinsic state
- FlyweightFactory: Manages Flyweight objects
- Context: Holds extrinsic state plus a reference to the matching flyweight
- Client: The Container. Manages the FlyweightFactory and Context objects.
7.5. Implementing Flyweight Pattern
- Split the target class’s fields into intrinsic state and extrinsic state
- Keep intrinsic state in the class. Make sure it is set only at initialization and never changes.
- Remove extrinsic state fields. Replace them with methods that accept extrinsic values as parameters.
- (Optional) Create a Factory class to manage and access flyweights
- Create a Client class to store the FlyweightFactory, extrinsic states, and references to matching flyweights via Context objects
7.6. Practice
- First, create the Flyweight class (everything else points to this, so we start here)
class Flyweight {
constructor(
public model: string,
public processor: string
) { }
operation(memory: number, tag: string) {
return `Computer: Model = ${this.model}, Proccessor = ${this.processor}, Memory = ${memory}, Tag = ${tag}`;
}
}
- Next, the FlyweightFactory to manage Flyweight objects
class FlyWeightFactory {
constructor() { }
// Manage flyweights
public cache: any = {};
getFlyweight(
model: string,
processor: string
) {
if (!this.cache[model + processor]) {
this.cache[model + processor] =
new Flyweight(model, processor);
}
return this.cache[model + processor];
}
count() {
var count = 0;
for (var f in this.cache) count++;
return count;
}
}
- Create the Context class to pair intrinsic state (in the flyweight) with extrinsic state (outside) via a reference to the matching flyweight
class Context {
constructor(
public flyweight: Flyweight,
public memory: number, // Unique state
public tag: string // Unique state
) { }
operation() {
return this.flyweight.operation(this.memory, this.tag);
}
}
- Finally, the Client class acting as the Container
class Client {
constructor() {}
public flyweightFactory : FlyWeightFactory = new FlyWeightFactory();
public contexts : Array<Context> = new Array<Context>();
public countContext : number = 0;
add(
model: string,
processor: string,
memory: number,
tag: string
) {
this.contexts.push(
new Context(
this.flyweightFactory.getFlyweight(
model, processor
),
memory, tag
)
);
this.countContext++;
}
getContext(
model: string,
processor: string,
memory: number,
tag: string
) {
return this.flyweightFactory.getFlyweight(
model, processor
).operation(memory, tag);
}
getCountContext() {
return this.countContext;
}
}
- Let’s add computers with similar model and processor values and see what happens.
var computers = new Client();
computers.add("Studio XPS", "Intel", 5, "Y755P");
computers.add("Studio XPS", "Intel", 6, "X997T");
computers.add("Studio XPS", "Intel", 2, "U8U80");
computers.add("Studio XPS", "Intel", 2, "NT777");
computers.add("Studio XPS", "Intel", 2, "0J88A");
computers.add("Envy", "Intel", 4, "CNU883701");
computers.add("Envy", "Intel", 2, "TXU003283");
console.log("Computer counting: " + computers.getCountContext());
console.log("Flyweight counting: " + computers.flyweightFactory.count());
- Result
Computer counting: 7
Flyweight counting: 2
- Seven computers, but only two flyweights. That is the whole point. But be careful — since flyweights are shared via references, modifying intrinsic state affects every context that uses that flyweight. Here is what happens when I change the processor of “Studio XPS Intel” to “AMD” through the FlyweightFactory:
console.log("Before changes: ");
for (let [k, v] of computers.contexts.entries()) {
console.log(`(${k + 1}) => ` + v.operation());
}
computers.flyweightFactory.getFlyweight("Studio XPS", "Intel").processor =
"AMD";
console.log("After changes: ");
for (let [k, v] of computers.contexts.entries()) {
console.log(`(${k + 1}) => ` + v.operation());
}
- Result
Before changes:
(1) => Computer: Model = Studio XPS, Proccessor = Intel, Memory = 5, Tag = Y755P
(2) => Computer: Model = Studio XPS, Proccessor = Intel, Memory = 6, Tag = X997T
(3) => Computer: Model = Studio XPS, Proccessor = Intel, Memory = 2, Tag = U8U80
(4) => Computer: Model = Studio XPS, Proccessor = Intel, Memory = 2, Tag = NT777
(5) => Computer: Model = Studio XPS, Proccessor = Intel, Memory = 2, Tag = 0J88A
(6) => Computer: Model = Envy, Proccessor = Intel, Memory = 4, Tag = CNU883701
(7) => Computer: Model = Envy, Proccessor = Intel, Memory = 2, Tag = TXU003283
After changes:
(1) => Computer: Model = Studio XPS, Proccessor = AMD, Memory = 5, Tag = Y755P
(2) => Computer: Model = Studio XPS, Proccessor = AMD, Memory = 6, Tag = X997T
(3) => Computer: Model = Studio XPS, Proccessor = AMD, Memory = 2, Tag = U8U80
(4) => Computer: Model = Studio XPS, Proccessor = AMD, Memory = 2, Tag = NT777
(5) => Computer: Model = Studio XPS, Proccessor = AMD, Memory = 2, Tag = 0J88A
(6) => Computer: Model = Envy, Proccessor = Intel, Memory = 4, Tag = CNU883701
(7) => Computer: Model = Envy, Proccessor = Intel, Memory = 2, Tag = TXU003283
8. Delegate
Remove complex functionality from the main class by delegating it to separate handler classes.
8.1. Problem
Real-world example: to ship packages, you can use bus, train, or plane. Each handles different cargo — lightweight urgent items go by air, bulky fast-delivery items go by bus, slow-shipping items go by train. So you have three classes: RailShipper, BusShipper, PlaneShipper, all with a delivery method. Normally you write conditional logic to pick the right one. As more shipping methods get added, your class balloons.
8.2. Solution
Pull the selection logic into a ShipperHandler class. It picks the right shipper and calls delivery for you.
8.3. Structure
Three participants:
- Shipper: Interface defining the contract for all shipping types
- RailShipper, BusShipper, PlaneShipper: Concrete implementations
- ShipperHandler: Implements the Shipper interface and handles the selection/delegation logic
8.4. Practice
interface Shipper {
delivery(): string;
}
class RailShipper implements Shipper {
delivery() {
return `Package is delivering by train`;
}
}
class BusShipper implements Shipper {
delivery() {
return `Package is delivering by bus`;
}
}
class PlaneShipper implements Shipper {
delivery() {
return `Package is delivering by plane`;
}
}
class ShipperHandler implements Shipper {
private shipper!: Shipper;
constructor(type: string) {
this.shipper = eval(`new ${type}Shipper();`);
}
delivery() {
return this.shipper.delivery()
}
}
let shipper: Shipper = new ShipperHandler('Bus');
console.log(shipper.delivery());
- Result
Package is delivering by bus
8.5. Closing Notes
Delegate Pattern resembles inheritance but is more flexible. It also looks like Proxy Pattern, though they shine in different situations. I think the key takeaway with all design patterns is: use them when they fit. Do not force a pattern onto a problem just because you know it exists.
9. Entity-Attribute-Value (EAV)
Entity-Attribute-Value Pattern (EAV) is a data model for working with entities that have a dynamically extensible set of attributes.
EAV is a database design technique for building highly customizable systems. If you have worked with Magento, you have seen it in action — it is the backbone of their product attribute system. I will cover the simplified version here: Product Attributes (Level 2 in Magento’s product management).
9.1. Structure
Three tables:
- Entity (entities): Basic object information
- Attribute (attributes): Attributes that can be attached to entities
- Value (attribute_values): Links Entity and Attribute with their values, using two foreign keys
Table relationships:
- Value - Attribute: 1-to-many (one Attribute has many Values)
- Value - Entity: many-to-many (one Entity has many Values, one Value belongs to many Entities)
- Entity - Attribute: many-to-many (one product has many Attributes, one Attribute belongs to many products)
Example of Entity-Attribute-Value relationships:
| value_id | entity_id | value | attribute_id |
|---|---|---|---|
| 1 | 1 | ”S” | 1 |
| 2 | 1 | ”White” | 2 |
| 3 | 1 | 30 | 3 |
| 4 | 1 | 100 | 4 |
| 5 | 2 | ”S” | 1 |
| 6 | 2 | ”Black” | 2 |
| 7 | 2 | 20 | 3 |
| 8 | 2 | 200 | 4 |
9.2. Example
You are building an e-commerce site — say, for clothing. The database is straightforward:
- products: Stores product information
- categories: Stores product categories
This is the simplest approach and fine for getting started. But it falls apart fast when the system grows (Product Management Level 1).
When the company scales and starts selling phones, computers, and home appliances, you need fields like color, size, weight, chip, ram, etc. With a flat table, you add dozens of columns — but each query only uses a handful of them. Wasteful. This is when EAV earns its keep (Product Management Level 2).
With EAV, the table structure becomes:
Now you can add any attribute from the admin panel without touching the database schema.
- Add a record to the attributes table for the
colorattribute:
id=1, name="color"
- Add attribute values and corresponding product IDs to the attribute_value table:
id = 1, attribute_id = 1, value = "Red", product_id = 1
id = 2, attribute_id = 1, value = "Blue", product_id = 2
id = 3, attribute_id = 1, value = "Yellow", product_id = 7
id = 4, attribute_id = 1, value = "White", product_id = 5
Much more dynamic. If you want to sharpen your system design skills — especially for e-commerce — I recommend studying open-source platforms like Magento. The way they structure their database taught me a lot about thinking at scale.