Direct answer
There is no official number of design principles.
Unlike design patterns, design principles do not have one standard catalogue. Different developers may focus on different principles depending on the software they build.
For example:
- Web developers may prioritize separation of concerns.
- Software architects may focus on dependencies and system boundaries.
- Enterprise developers may emphasize maintainability and scalability.
This lesson introduces:
- 4 core principles
- 5 SOLID principles
- 9 principles in total
However, the instructor explains that developers generally recognize around a dozen or so important principles, with others appearing in different areas of software development.
Four Core Design Principles
1. Encapsulate What Varies
Separate the parts that frequently change from the parts that remain stable.
Suppose an application supports different discount calculations. The discount rules may change regularly, while the checkout process remains mostly stable.
Instead of placing every discount rule directly inside the checkout code, we separate the discount behaviour.
Stable checkout process
↓ uses
Changeable discount behaviour
Now discount rules can change without rewriting the complete checkout process.
Code example
class NoDiscount:
def apply(self, price):
return price
class StudentDiscount:
def apply(self, price):
return price * 0.90
class SeasonalDiscount:
def apply(self, price):
return price * 0.80
class Checkout:
def __init__(self, discount):
self.discount = discount
def calculate_total(self, price):
return self.discount.apply(price)
checkout = Checkout(StudentDiscount())
print(checkout.calculate_total(100)) # 90
The checkout process remains stable while each discount rule is kept in a separate class.
Simple meaning: Find what changes and keep it separate.
2. Favor Composition Over Inheritance
Prefer building objects from smaller objects instead of always using inheritance.
Inheritance creates a strong relationship between a parent and its children. Changes to the parent can affect every child.
Composition lets an object receive the behaviour it needs from another object.
Inheritance: ElectricCar is a Car
Composition: Car has an Engine
This principle does not mean inheritance is bad. It means inheritance should not be the automatic choice for reusing code.
Code example
class PetrolEngine:
def start(self):
print("Petrol engine started")
class ElectricEngine:
def start(self):
print("Electric engine started")
class Car:
def __init__(self, engine):
self.engine = engine
def start(self):
self.engine.start()
petrol_car = Car(PetrolEngine())
electric_car = Car(ElectricEngine())
petrol_car.start()
electric_car.start()
The Car contains an engine object. We can change the engine without changing the Car class.
Simple meaning: Reuse behaviour by combining objects when that gives you more flexibility.
3. Program to Interfaces, Not Implementations
Depend on what something can do, rather than the exact class doing it.
An interface is simply an agreement that says:
“Any class used here must provide these operations.”
For example, instead of making an order service depend directly on one specific payment provider, it can depend on a general PaymentService interface.
OrderService
↓ depends on
PaymentService interface
↓ implemented by
CardPayment / BankPayment / WalletPayment
The payment provider can then be replaced without rewriting the order service.
This idea also works in languages without formal interfaces. The important part is depending on an agreed behaviour rather than one fixed class.
Code example
from abc import ABC, abstractmethod
class PaymentService(ABC):
@abstractmethod
def pay(self, amount):
pass
class CardPayment(PaymentService):
def pay(self, amount):
print(f"Paid £{amount} by card")
class WalletPayment(PaymentService):
def pay(self, amount):
print(f"Paid £{amount} using a wallet")
class OrderService:
def __init__(self, payment_service: PaymentService):
self.payment_service = payment_service
def place_order(self, amount):
self.payment_service.pay(amount)
print("Order placed")
order_service = OrderService(CardPayment())
order_service.place_order(100)
OrderService depends on the general PaymentService interface instead of one specific payment class.
Simple meaning: Depend on capabilities, not specific classes.
4. Loose Coupling
Keep connections between objects as small and simple as possible.
Two objects are tightly coupled when one knows too much about the other. A change in one object may then force changes in the other.
Objects are loosely coupled when they only share the information necessary to work together.
Code example
class EmailNotifier:
def send(self, message):
print(f"Email: {message}")
class SMSNotifier:
def send(self, message):
print(f"SMS: {message}")
class OrderService:
def __init__(self, notifier):
self.notifier = notifier
def place_order(self):
print("Order placed")
self.notifier.send("Your order has been placed")
email_order = OrderService(EmailNotifier())
email_order.place_order()
sms_order = OrderService(SMSNotifier())
sms_order.place_order()
OrderService does not need to know how email or SMS works. It only expects the notifier to provide a send() method.
Simple meaning: Objects should work together without becoming unnecessarily dependent on each other.
The Five SOLID Principles
SOLID is a name formed from the first letter of five design principles.
S: Single Responsibility Principle
A class should have one main responsibility and one reason to change.
For example, one class should not:
- Calculate invoices
- Save invoices
- Email invoices
- Create invoice PDFs
These responsibilities can change for different reasons, so they should normally be separated.
This does not mean a class can only contain one method. It means its methods should support one clear responsibility.
Code example
class Invoice:
def __init__(self, items):
self.items = items
class InvoiceCalculator:
def calculate_total(self, invoice):
return sum(invoice.items)
class InvoiceRepository:
def save(self, invoice):
print("Invoice saved")
class InvoiceEmailer:
def send(self, invoice):
print("Invoice emailed")
class InvoicePDFGenerator:
def generate(self, invoice):
print("Invoice PDF generated")
Each class now handles one clear responsibility.
Simple meaning: One class, one clear job.
O: Open/Closed Principle
Software should be open for extension but closed for modification.
This means we should be able to add new behaviour without repeatedly changing stable, existing code.
It does not mean existing code can never be edited. It means the design should make common extensions possible without rewriting working code every time.
Code example
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius**2
def calculate_total_area(shapes):
return sum(shape.area() for shape in shapes)
shapes = [
Rectangle(10, 5),
Circle(4),
]
print(calculate_total_area(shapes))
A new shape can be added without changing calculate_total_area():
class Triangle(Shape):
def __init__(self, base, height):
self.base = base
self.height = height
def area(self):
return 0.5 * self.base * self.height
Simple meaning: Add new behaviour with minimal changes to existing behaviour.
L: Liskov Substitution Principle
A child type should be able to replace its parent type without causing unexpected problems.
If the application expects an Animal, then providing a Dog should not break the expected behaviour.
A child class should follow the promises made by its parent.
Code example
from abc import ABC, abstractmethod
class Animal(ABC):
@abstractmethod
def make_sound(self):
pass
class Dog(Animal):
def make_sound(self):
return "Woof"
class Cat(Animal):
def make_sound(self):
return "Meow"
def play_animal_sound(animal: Animal):
print(animal.make_sound())
play_animal_sound(Dog()) # Woof
play_animal_sound(Cat()) # Meow
Both Dog and Cat can replace Animal without breaking play_animal_sound().
Simple meaning: A child class should behave properly wherever its parent is expected.
I: Interface Segregation Principle
Prefer small, focused interfaces over large interfaces.
A class should not be forced to provide methods it does not need.
For example, a simple printer should not be forced to implement scanning and faxing methods just because they are included in one large machine interface.
It would be better to have separate interfaces:
PrintableScannableFaxable
Code example
from abc import ABC, abstractmethod
class Printable(ABC):
@abstractmethod
def print_document(self):
pass
class Scannable(ABC):
@abstractmethod
def scan_document(self):
pass
class Faxable(ABC):
@abstractmethod
def fax_document(self):
pass
class BasicPrinter(Printable):
def print_document(self):
print("Printing document")
class MultiFunctionPrinter(Printable, Scannable, Faxable):
def print_document(self):
print("Printing document")
def scan_document(self):
print("Scanning document")
def fax_document(self):
print("Faxing document")
BasicPrinter only implements printing. It is not forced to provide scanning or faxing methods.
Simple meaning: Do not force classes to depend on operations they do not use.
D: Dependency Inversion Principle
High-level rules should not depend directly on low-level technical details. Both should depend on an abstraction.
In this example:
Storeis the high-level business logic.StripeandPayPalare low-level payment tools.- Payment processor classes provide a common way to use either provider.
Without dependency inversion
The Store depends directly on Stripe:
class Store {
constructor(user) {
this.stripe = new Stripe(user)
}
purchaseBike(quantity) {
this.stripe.makePayment(200 * quantity * 100)
}
purchaseHelmet(quantity) {
this.stripe.makePayment(15 * quantity * 100)
}
}
class Stripe {
constructor(user) {
this.user = user
}
makePayment(amountInCents) {
console.log(
`${this.user} paid $${amountInCents / 100} with Stripe`
)
}
}
const store = new Store("John")
store.purchaseBike(2)
store.purchaseHelmet(2)
The problem is that Store knows several Stripe-specific details:
- It creates a
Stripeobject. - It calls Stripe’s
makePayment()method. - It converts dollars into cents for Stripe.
If we switch to PayPal, we must rewrite the Store class because PayPal works differently.
With dependency inversion
We give Store a payment processor instead of making it depend directly on Stripe:
class Store {
constructor(paymentProcessor) {
this.paymentProcessor = paymentProcessor
}
purchaseBike(quantity) {
this.paymentProcessor.pay(200 * quantity)
}
purchaseHelmet(quantity) {
this.paymentProcessor.pay(15 * quantity)
}
}
Both processors provide the same pay() method:
class StripePaymentProcessor {
constructor(user) {
this.stripe = new Stripe(user)
}
pay(amountInDollars) {
this.stripe.makePayment(amountInDollars * 100)
}
}
class PayPalPaymentProcessor {
constructor(user) {
this.user = user
this.paypal = new PayPal()
}
pay(amountInDollars) {
this.paypal.makePayment(this.user, amountInDollars)
}
}
Each processor handles its provider’s different requirements:
class Stripe {
constructor(user) {
this.user = user
}
makePayment(amountInCents) {
console.log(
`${this.user} paid $${amountInCents / 100} with Stripe`
)
}
}
class PayPal {
makePayment(user, amountInDollars) {
console.log(
`${user} paid $${amountInDollars} with PayPal`
)
}
}
We can now switch payment providers without changing Store:
const stripeStore = new Store(
new StripePaymentProcessor("John")
)
stripeStore.purchaseBike(2)
const paypalStore = new Store(
new PayPalPaymentProcessor("John")
)
paypalStore.purchaseBike(2)
JavaScript does not require a formal interface here. The shared pay() method acts as the agreement:
Store
↓ depends on
Payment processor with pay()
↑ implemented by
StripePaymentProcessor / PayPalPaymentProcessor
Store no longer needs to understand Stripe’s cents or PayPal’s different method requirements. Those details are handled by their respective payment processors.
Simple meaning: Important business logic should depend on a common agreement, not directly on one specific technical tool.
How These Principles Connect
Many of these principles support each other:
- Programming to interfaces helps create loose coupling.
- Dependency inversion also encourages programming to interfaces.
- Encapsulating changing behaviour often makes the Open/Closed Principle easier to follow.
- Composition can help avoid inheritance problems and support easier extension.
They are separate principles, but they often lead towards the same goal: software that can change without breaking everything around it.
Final Summary
| Question | Answer |
|---|---|
| Is there an official number? | No |
| How many are introduced here? | 9 |
| Core principles | 4 |
| SOLID principles | 5 |
| Commonly recognized overall | Roughly a dozen or more |
Design principles are not rules to collect or follow blindly. They are tools for making better design decisions when a real problem requires them.