Skip to content
sang.id.vn
Go back

Structural Design Patterns Explained with TypeScript

Tiếng Việt

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:

To understand how the Adapter Pattern works, you need three concepts:

1.2. Types of adapters

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

interface Target {
    request(): string;
}
class Adaptee {
    specificRequest() : string {
        return ".eetpadA eht fo roivaheb laicepS";
    }
}
class Adapter extends Adaptee implements Target {
    constructor() {
        super();
    }
    request(){
        return "Adapter: (TRANSLATED) "+this.specificRequest().split("").reverse().join("");
    }
}
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);
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

class Target {
  request() {
    return "Target: The default target's behavior.";
  }
}
class Adaptee {
  specificRequest() {
    return ".eetpadA eht fo roivaheb laicepS";
  }
}
class Adapter implements Target {
    constructor(private obj: Adaptee) { }
    request(){
        return "Adapter: (TRANSLATED) "+this.obj.specificRequest().split("").reverse().join("");
    }
}
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);
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

2.3. Structure

2.4. Implementation steps

2.5. Example

class Abstraction {
    constructor(protected iA : ImplementationA) { }
    operation() : string {
        return `Abstraction - Base operation with: \n${this.iA.operation_implementation()}`;
    }
}
interface ImplementationA {
    operation_implementation() : string;
}
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`;
    }
}
function client_code(abstraction: Abstraction) {
    console.log(abstraction.operation());
}
let concreteAimplementationA = new ConcreteAImplementationA();
let abstraction = new Abstraction(concreteAimplementationA);
client_code(abstraction);
Abstraction - Base operation with:
Concrete A of Implementation A
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);
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

3.2. Structure

Three types of classes here:

3.3. Implementation

3.4. Example

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} ]`);
    }
}
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);
}
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

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:

4.5. Implementation

interface IPizza {
    doPizza() : string;
}
class TomatoPizza implements IPizza {
    doPizza(): string {
        return "I am a Tomato Pizza";
    }
}
class ChickenPizza implements IPizza {
    doPizza(): string {
        return "I am a Chicken Pizza";
    }
}
abstract class PizzaDecorator implements IPizza {
    constructor(protected mPizza: IPizza) { }

    getPizza(): IPizza {
        return this.mPizza;
    }

    setPizza(mPizza: IPizza): void {
        this.mPizza = mPizza;
    }

    abstract doPizza(): string;
}
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";
    }
}
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());
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

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:

// 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();
    ...
}
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();
        }
    }
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?

6.2. Types of Proxy

6.3. Structure

Three participants:

6.4. Example

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)
        )
    }
}
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 content and Item index
let data : Array<IItem> = [
    new Item(),
    new Item(),
    new Item(),
    new Item(),
    new Item(),
    new Item(),
];
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: 4
Result: 5
Result: 5
Wanted result: 4
Result: 9
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))
})();
Wanted result via proxy: 1
Result via proxy: 0
Result via proxy: 7
Result via proxy: 2
Result via proxy: 1

6.5. Comparison with Other Structural Patterns

Proxy looks a lot like Adapter and Decorator, so it is worth calling out the differences:

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.

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:

To manage flyweights better, we use a FlyweightFactory instead of a plain array.

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:

7.5. Implementing Flyweight Pattern

7.6. Practice

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}`;
    }
}
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;
    }
}
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);
    }
}
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;
    }
}
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());
Computer counting: 7
Flyweight counting: 2
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());
}
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:

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());
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:

Table relationships:

Example of Entity-Attribute-Value relationships:

value_identity_idvalueattribute_id
11”S”1
21”White”2
31303
411004
52”S”1
62”Black”2
72203
822004

9.2. Example

You are building an e-commerce site — say, for clothing. The database is straightforward:

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.

  1. Add a record to the attributes table for the color attribute:
id=1, name="color"
  1. 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.


Share this post on:

Previous Post
Creational Design Patterns Explained with TypeScript