OOP Series

SOLID: Five Rules From Marco's Kitchen

Carroll Gardens, Brooklyn. Marco’s Kitchen on Smith Street.

Ten years running. The kind of place with handwritten specials on a chalkboard and a line out the door on Friday nights. Marco started it himself. Five tables, ten items. Now it’s doubled.

So have the problems.

Walk in on any given morning and Tony is doing everything: cooking, taking orders, running the register, and handling deliveries. When a Friday order got mixed up, nobody could say which “Tony” made the mistake.

Software has the same problem. When one class does too many things, bugs get lost. These five principles together are called SOLID.

Five rules. Five different problems. Five solutions.


1. S: Single Responsibility Principle

Tony cooks, takes orders, handles cash, and delivers. Four jobs, one person.

An order gets mixed up. Who’s responsible? Cook Tony? Order-taker Tony? Cashier Tony? There’s no way to tell.

Marco makes a decision: split it up.

Rafael only cooks. Carlos only takes orders. Maria only handles the register. Luis only delivers.

Now when an order goes wrong: Carlos’s mistake. Short on cash: Maria’s mistake. Clear.

This principle is called the Single Responsibility Principle (SRP): a class should have only one reason to change.

class KitchenWorker:  # SRP violation — one class doing everything
    def cook(self, order: str):
        print(f"Cooking: {order}")

    def take_order(self, customer: str) -> str:
        print(f"Taking order from {customer}")
        return order

    def calculate_bill(self, order: str) -> float:
        print(f"Calculating bill for: {order}")
        return 250.0

    def deliver(self, address: str):
        print(f"Delivering to: {address}")


class Chef:  # SRP applied — one responsibility each
    def cook(self, order: str):
        print(f"Cooking: {order}")


class OrderTaker:
    def take_order(self, customer: str) -> str:
        print(f"Taking order from {customer}")
        return "kacchi biryani"


class Cashier:
    def calculate_bill(self, order: str) -> float:
        print(f"Calculating bill for: {order}")
        return 250.0


class DeliveryBoy:
    def deliver(self, address: str):
        print(f"Delivering to: {address}")
chef     = Chef()
taker    = OrderTaker()
cashier  = Cashier()
delivery = DeliveryBoy()

chef.cook("kacchi")
taker.take_order("James")
cashier.calculate_bill("kacchi")
delivery.deliver("Smith Street")

With KitchenWorker, a bug could hide in four places. Split into Chef, OrderTaker, Cashier, DeliveryBoy — you know exactly where to look.

%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#F5F5F5', 'primaryTextColor': '#000', 'primaryBorderColor': '#333', 'lineColor': '#333', 'background': '#fff'}}}%% graph TD classDef box fill:#F5F5F5,color:#000,stroke:#333 subgraph BEFORE ["SRP violation"] K["KitchenWorker cook() take_order() calculate_bill() deliver()"]:::box end subgraph AFTER ["SRP applied"] C["Chef cook()"]:::box O["OrderTaker take_order()"]:::box CA["Cashier calculate_bill()"]:::box D["DeliveryBoy deliver()"]:::box end

2. O: Open/Closed Principle

Marco wants to add weekend brunch to the menu.

But the kitchen system is written so tightly that adding eggs benedict means touching the existing prepare_item function. That function already handles the lunch menu. Touch it wrong and the whole lunch service breaks.

The fix: each menu item is its own class. Adding something new means adding a new class. Nothing existing gets touched.

This principle is called the Open/Closed Principle (OCP): open for extension, closed for modification.

from abc import ABC, abstractmethod


class MenuItem(ABC):  # open for extension
    @abstractmethod
    def prepare(self) -> str:
        pass

    @abstractmethod
    def price(self) -> float:
        pass


class Kacchi(MenuItem):
    def prepare(self) -> str:
        return "slow-cooked kacchi biryani"

    def price(self) -> float:
        return 350.0


class Tehari(MenuItem):
    def prepare(self) -> str:
        return "tehari with beef"

    def price(self) -> float:
        return 200.0


class Biryani(MenuItem):  # new item added — zero existing code changed
    def prepare(self) -> str:
        return "chicken biryani"

    def price(self) -> float:
        return 280.0


class Kitchen:  # closed for modification
    def serve(self, item: MenuItem):
        print(f"Serving: {item.prepare()} — ${item.price():.2f}")
kitchen = Kitchen()

kitchen.serve(Kacchi())
# Serving: slow-cooked kacchi biryani — $350.00

kitchen.serve(Tehari())
# Serving: tehari with beef — $200.00

kitchen.serve(Biryani())  # new item — zero existing code changed
# Serving: chicken biryani — $280.00

Adding Biryani didn’t touch a single line inside Kitchen. Extended without breaking.


3. L: Liskov Substitution Principle

Tony calls in sick. Marco brings in Luis as a substitute.

Luis can cook the standard menu. But he doesn’t know Tony’s Friday pasta special — the one regulars order every week. A customer asks for it. Luis says he can’t make it.

The customer leaves. The substitute didn’t fully substitute.

The same failure happens in code. If a SubstituteCook breaks the program when used in place of Cook, it isn’t really a substitute.

This principle is called the Liskov Substitution Principle (LSP): a subclass must be usable in place of its parent class without changing the program’s behavior.

class Cook:
    def __init__(self, name: str):
        self.name = name

    def cook_standard_menu(self):
        print(f"{self.name}: cooking standard menu items")

    def take_new_order(self, item: str):
        print(f"{self.name}: accepted order for {item}")


class HeadCook(Cook):
    def cook_standard_menu(self):
        print(f"{self.name}: cooking full standard menu")

    def take_new_order(self, item: str):
        print(f"{self.name}: accepted order for {item}")

    def cook_special(self, dish: str):  # extra capability — does not remove anything
        print(f"{self.name}: preparing special {dish}")


class SubstituteCook(Cook):  # LSP — fulfills every promise Cook makes
    def cook_standard_menu(self):
        print(f"{self.name}: cooking standard menu items")

    def take_new_order(self, item: str):
        print(f"{self.name}: accepted order for {item}")
def start_shift(cook: Cook):
    cook.cook_standard_menu()
    cook.take_new_order("pasta")

head = HeadCook("Tony")
sub  = SubstituteCook("Luis")

start_shift(head)
# Tony: cooking full standard menu
# Tony: accepted order for pasta

start_shift(sub)   # LSP satisfied — behavior identical
# Luis: cooking standard menu items
# Luis: accepted order for pasta

start_shift doesn’t care who shows up. HeadCook or SubstituteCook, both deliver. Whatever the parent promises, the child keeps.


4. I: Interface Segregation Principle

Marco hires a new delivery driver. To be on the team, the driver has to sign the “kitchen staff contract.”

The contract says: must know how to cook, take orders, handle billing, and deliver.

The driver just drives. He doesn’t know how to cook. But the contract requires it.

That’s not fair. He’s being forced to commit to things that have nothing to do with his job.

This principle is called the Interface Segregation Principle (ISP): a client should not be forced to implement methods it doesn’t use.

from abc import ABC, abstractmethod


class Cookable(ABC):  # ISP — one interface, one concern
    @abstractmethod
    def cook(self): pass


class OrderTakeable(ABC):
    @abstractmethod
    def take_order(self): pass


class Billable(ABC):
    @abstractmethod
    def handle_billing(self): pass


class Deliverable(ABC):
    @abstractmethod
    def deliver(self): pass


class Chef(Cookable, OrderTakeable):  # takes only what it needs
    def cook(self):
        print("Chef: cooking")

    def take_order(self):
        print("Chef: taking order")


class DeliveryBoy(Deliverable):  # only what it needs — nothing forced
    def deliver(self):
        print("DeliveryBoy: out for delivery")
chef  = Chef()
carlos = DeliveryBoy()

chef.cook()
# Chef: cooking

chef.take_order()
# Chef: taking order

carlos.deliver()
# DeliveryBoy: out for delivery

DeliveryBoy never had to implement cook() or handle_billing(). Each role signs only the contract it needs.


5. D: Dependency Inversion Principle

Every morning Marco calls Vinnie’s Produce directly for vegetables and herbs.

One morning Vinnie retires. No call, no notice. Marco has no produce. Kitchen closes for the day.

The problem: Marco’s kitchen depends on a specific shop, not on the idea of a supplier.

The fix: depend on the concept of a supplier. Whoever fulfills that role can step in.

This principle is called the Dependency Inversion Principle (DIP): high-level modules should depend on abstractions, not on concrete implementations.

from abc import ABC, abstractmethod


class IngredientSupplier(ABC):  # the abstraction — DIP depends on this
    @abstractmethod
    def get_ingredients(self) -> list:
        pass


class KarimShop(IngredientSupplier):
    def get_ingredients(self) -> list:
        return ["turmeric", "cumin", "cardamom"]


class WholesaleMarket(IngredientSupplier):
    def get_ingredients(self) -> list:
        return ["turmeric", "cumin", "cardamom", "saffron"]


class Kitchen:
    def __init__(self, supplier: IngredientSupplier):  # depends on abstraction, not a specific shop
        self.supplier = supplier

    def cook(self):
        ingredients = self.supplier.get_ingredients()
        print(f"Cooking with: {ingredients}")
vinnies   = KarimShop()       # same interface, different name
wholesale = WholesaleMarket()

kitchen1 = Kitchen(supplier=vinnies)
kitchen2 = Kitchen(supplier=wholesale)

kitchen1.cook()
# Cooking with: ['turmeric', 'cumin', 'cardamom']

kitchen2.cook()
# Cooking with: ['turmeric', 'cumin', 'cardamom', 'saffron']

Kitchen works with any IngredientSupplier. Vinnie retires? Pass in a new supplier. Not one line of Kitchen changes.

%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#F5F5F5', 'primaryTextColor': '#000', 'primaryBorderColor': '#333', 'lineColor': '#333', 'background': '#fff'}}}%% graph LR classDef box fill:#F5F5F5,color:#000,stroke:#333 subgraph BAD ["DIP violation"] K1["Kitchen"]:::box -->|"directly depends on"| KS["KarimShop"]:::box end subgraph GOOD ["DIP applied"] K2["Kitchen"]:::box -->|"depends on"| IS["IngredientSupplier (abstract)"]:::box IS -->|"implemented by"| K3["KarimShop"]:::box IS -->|"implemented by"| WM["WholesaleMarket"]:::box end

Summary

In the story In code
Tony doing everything means no one’s accountable SRP: one class, one responsibility
Adding biryani means rewriting the order flow OCP: extend, don’t modify
The substitute doesn’t know the Friday special LSP: subclass must work wherever the parent works
The driver forced to learn cooking ISP: implement only what you need
Depending on one specific produce shop DIP: depend on abstractions, not concrete classes

SRP: a class should have only one reason to change.

OCP: new features mean new classes, not edits to existing ones.

LSP: a subclass must always be substitutable for its parent without breaking behavior.

ISP: break large interfaces into small ones. Each role takes only what it needs.

DIP: high-level modules depend on abstractions, never directly on low-level implementations.


Next question: SOLID keeps code clean, but when the same problems keep coming up across different projects — is there a catalog of ready-made solutions? Those solutions are called Design Patterns.

Next in the OOP series: Design Patterns — proven solutions to recurring problems