Good design principles lead to code that is maintainable, testable, and extensible. These principles are fundamental to writing professional-quality software. They exist not because someone declared them from an ivory tower, but because thousands of engineers spent decades discovering what makes code survive contact with changing requirements, growing teams, and production pressure. Think of them as distilled experience from millions of hours of debugging and refactoring.A word of caution: Principles are guardrails, not handcuffs. A 200-line script for a one-time data migration does not need SOLID architecture. Knowing when to apply a principle — and when the cost of the abstraction exceeds its benefit — is what separates senior engineers from pattern-obsessed juniors.
The key insight is “reason to change,” not “does one thing.” A User class that stores user data and validates email format might seem like two responsibilities, but if both change when user requirements change, that is one reason. However, if the email validation logic changes because the email team updated their rules while the user data model changes because of a schema migration — those are two independent reasons to change, and the class should be split.
# ❌ Bad: Multiple responsibilities -- this class changes when the database# schema changes AND when the email provider changes. Two unrelated teams# (backend + communications) would both need to modify this file.class User: def __init__(self, name, email): self.name = name self.email = email def save_to_database(self): # Database logic -- changes when schema/ORM changes pass def send_email(self): # Email logic -- changes when email provider changes pass# ✅ Good: Single responsibility -- each class has exactly one reason to change.# If you switch from PostgreSQL to MongoDB, only UserRepository changes.# If you switch from SendGrid to Mailgun, only EmailService changes.class User: """Pure data + business rules. No I/O, no side effects.""" def __init__(self, name, email): self.name = name self.email = emailclass UserRepository: """Knows HOW to persist users. One reason to change: storage concerns.""" def save(self, user): passclass EmailService: """Knows HOW to send emails. One reason to change: communication concerns.""" def send(self, user, message): pass
You should be able to add new behavior without touching existing, tested code. Every time you modify a working function to handle a new case (adding another elif), you risk breaking the cases that already work. The Open/Closed Principle says: design so that new features plug in rather than edit in. Think of USB ports — your laptop supports devices that did not exist when it was manufactured, without requiring a motherboard redesign.
# ❌ Bad: Must modify class for new shapesclass AreaCalculator: def calculate(self, shape): if shape.type == "circle": return 3.14 * shape.radius ** 2 elif shape.type == "rectangle": return shape.width * shape.height # Must add more elif for each shape!# ✅ Good: Extend without modifyingfrom abc import ABC, abstractmethodclass Shape(ABC): @abstractmethod def area(self): passclass Circle(Shape): def __init__(self, radius): self.radius = radius def area(self): return 3.14 * self.radius ** 2class Rectangle(Shape): def __init__(self, width, height): self.width = width self.height = height def area(self): return self.width * self.height# New shapes just implement Shape interfaceclass Triangle(Shape): def __init__(self, base, height): self.base = base self.height = height def area(self): return 0.5 * self.base * self.height
Subtypes must be substitutable for their base types.
Named after Barbara Liskov (Turing Award winner), this principle says: if your code works with a Bird object, it must also work correctly with any subclass of Bird (Eagle, Sparrow, etc.) without the code knowing or caring which subclass it got. If substituting a subclass breaks something, your inheritance hierarchy is lying about the “is-a” relationship. This is the most commonly violated SOLID principle in practice.
# ❌ Bad: Square violates Rectangle's contract.# Client code expects: "I set width to 5 and height to 10, so area = 50."# But Square silently overrides height when you set width -- area = 25!# The subclass breaks the behavioral contract of the parent class.class Rectangle: def set_width(self, width): self.width = width def set_height(self, height): self.height = heightclass Square(Rectangle): def set_width(self, width): self.width = width self.height = width # Violates expectation! Caller did not ask for this. def set_height(self, height): self.width = height # Same problem -- silent side effect. self.height = height# ✅ Good: Separate abstractionsclass Shape(ABC): @abstractmethod def area(self): passclass Rectangle(Shape): def __init__(self, width, height): self.width = width self.height = height def area(self): return self.width * self.heightclass Square(Shape): def __init__(self, side): self.side = side def area(self): return self.side ** 2
Clients should not depend on interfaces they don’t use.
Think of a universal remote control with 80 buttons — you only use 5 of them, and the rest are confusing clutter. ISP says: give each client a small, focused remote with only the buttons it needs. In code, this means splitting large “god interfaces” into smaller, purpose-specific ones so that implementing classes are not forced to provide dummy implementations for methods they cannot meaningfully support.
This is the most architecturally impactful SOLID principle. Instead of high-level business logic directly calling low-level implementation details (database queries, API calls), both should depend on an abstraction (interface) defined by the high-level module. The “inversion” is that the low-level module conforms to an interface that the high-level module defines — not the other way around. This is what makes your codebase testable and swappable.
# ❌ Bad: High-level depends on low-level -- UserService is welded to MySQL.# Cannot test without a real MySQL instance. Cannot switch to PostgreSQL# without rewriting UserService. Cannot use a different database in staging.class MySQLDatabase: def save(self, data): print("Saving to MySQL...")class UserService: def __init__(self): self.db = MySQLDatabase() # Hardcoded! This is the coupling problem. def create_user(self, user): self.db.save(user)# ✅ Good: Both depend on abstraction -- the "inversion" is that MySQLDatabase# now conforms to an interface defined by the needs of UserService, not the# other way around. UserService defines WHAT it needs; the adapter provides HOW.class Database(ABC): @abstractmethod def save(self, data): passclass MySQLDatabase(Database): def save(self, data): print("Saving to MySQL...")class PostgreSQLDatabase(Database): def save(self, data): print("Saving to PostgreSQL...")class UserService: def __init__(self, db: Database): # Inject the abstraction, not a concrete class self.db = db def create_user(self, user): self.db.save(user)# Usage -- swap implementations with zero changes to UserService.# In tests, inject a FakeDatabase that stores data in a dict.mysql_service = UserService(MySQLDatabase())postgres_service = UserService(PostgreSQLDatabase())test_service = UserService(FakeDatabase()) # Fast, in-memory tests
DRY is about knowledge duplication, not code duplication. Two pieces of code that look identical but represent different business concepts should NOT be merged — they will diverge as requirements evolve. The test: if a business rule changes, should both pieces of code change? If yes, they are DRY violations. If no, the similarity is coincidental and merging them creates harmful coupling.
# ❌ Bad: Repeated KNOWLEDGE -- the bonus formula appears in two places.# If the bonus percentage changes from 10% to 12%, you must find and# update both functions. Miss one and you have an inconsistent system.def calculate_employee_bonus(employee): base_salary = employee.salary years = employee.years_of_service bonus = base_salary * 0.1 * years return bonusdef calculate_manager_bonus(manager): base_salary = manager.salary years = manager.years_of_service bonus = base_salary * 0.1 * years # Same formula! Same business rule! bonus += 5000 # Manager extra return bonus# ✅ Good: Single source of truth for the base bonus formula.# Change the formula in one place, all callers get the update.def calculate_base_bonus(person): return person.salary * 0.1 * person.years_of_servicedef calculate_employee_bonus(employee): return calculate_base_bonus(employee)def calculate_manager_bonus(manager): return calculate_base_bonus(manager) + 5000
The simplest solution that meets the requirements is usually the best one. Over-engineering is a form of technical debt — it adds complexity that must be maintained, understood, and debugged by future developers (including your future self). Ask: “Could a new team member understand this in 5 minutes?” If not, simplify.
# ❌ Over-engineered -- a Factory pattern for reversing a string!?# This is 8 lines of code to do what 1 line does. Every abstraction# layer is a tax on readability. Pay that tax only when it buys you# flexibility you actually need.class StringReverserFactory: def create_reverser(self): return StringReverser()class StringReverser: def reverse(self, s): return ''.join(reversed(list(s)))# ✅ Simple -- one function, one line, zero indirection.# The next developer who reads this code wastes zero mental cycles.def reverse_string(s): return s[::-1]
Inheritance creates a rigid “is-a” hierarchy that is brittle to change. When you add a new type that does not fit the hierarchy (a flying fish? a penguin that swims but does not fly?), the entire tree breaks. Composition lets you mix and match capabilities like LEGO blocks — snap on the pieces you need. The Gang of Four book (1994) identified this as one of the most important design insights, and decades of industry experience have only strengthened the recommendation.
Use when exactly one instance must exist globally — database connection pools, configuration managers, or logger instances. Be cautious: Singletons are essentially global state, which makes testing harder and hides dependencies. In modern applications, dependency injection often replaces Singletons for better testability.
class DatabaseConnection: _instance = None def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) cls._instance.connection = cls._create_connection() return cls._instance @staticmethod def _create_connection(): return "Connected to DB"# Both point to the exact same object in memory -- there is only one# connection pool no matter how many times you call the constructor.db1 = DatabaseConnection()db2 = DatabaseConnection()assert db1 is db2 # True -- same object
Factory - Create objects without specifying exact class
The Factory pattern centralizes object creation decisions, so the rest of your code works with interfaces and never needs to know which concrete class it received. This is valuable when the creation logic is complex, involves configuration, or when the set of types may grow over time (e.g., adding push notifications should not require changing every caller).
from abc import ABC, abstractmethodclass Notification(ABC): @abstractmethod def send(self, message): passclass EmailNotification(Notification): def send(self, message): print(f"Email: {message}")class SMSNotification(Notification): def send(self, message): print(f"SMS: {message}")class NotificationFactory: """Encapsulates the creation decision. Adding PushNotification only requires changing this factory -- no caller code changes.""" @staticmethod def create(notification_type: str) -> Notification: if notification_type == "email": return EmailNotification() elif notification_type == "sms": return SMSNotification() raise ValueError(f"Unknown type: {notification_type}")# Caller code works with the Notification interface -- it does not# know or care whether it got Email, SMS, or any future type.notification = NotificationFactory.create("email")notification.send("Hello!")
The Strategy pattern lets you swap algorithms at runtime without changing the code that uses them. Think of it like choosing a navigation app’s routing mode — “fastest,” “shortest,” “avoid tolls” — the app works the same way regardless of which strategy is active. This is one of the most commonly used patterns in production code, especially for payment processing, sorting, compression, and validation.
from abc import ABC, abstractmethodclass PaymentStrategy(ABC): @abstractmethod def pay(self, amount): passclass CreditCardPayment(PaymentStrategy): def pay(self, amount): print(f"Paid ${amount} with credit card")class PayPalPayment(PaymentStrategy): def pay(self, amount): print(f"Paid ${amount} with PayPal")class CryptoPayment(PaymentStrategy): def pay(self, amount): print(f"Paid ${amount} with crypto")class ShoppingCart: """Cart does not care HOW payment happens -- it delegates to the strategy. Adding ApplePay requires zero changes to ShoppingCart.""" def __init__(self, payment_strategy: PaymentStrategy): self.payment_strategy = payment_strategy def checkout(self, amount): self.payment_strategy.pay(amount)# Swap payment methods at runtime based on user choice -- no if/elif chains.cart = ShoppingCart(CreditCardPayment())cart.checkout(100)cart = ShoppingCart(CryptoPayment())cart.checkout(100)
Observer - Notify multiple objects of state changes
class EventEmitter: def __init__(self): self._listeners = {} def on(self, event, callback): if event not in self._listeners: self._listeners[event] = [] self._listeners[event].append(callback) def emit(self, event, data=None): if event in self._listeners: for callback in self._listeners[event]: callback(data)# Usageemitter = EventEmitter()def on_user_created(user): print(f"Send welcome email to {user['email']}")def on_user_created_log(user): print(f"Log: User {user['name']} created")emitter.on("user_created", on_user_created)emitter.on("user_created", on_user_created_log)emitter.emit("user_created", {"name": "John", "email": "john@example.com"})
# ❌ Deep nesting -- every level of indentation adds cognitive load.# By the time you reach the actual work, you have mentally tracked# four conditions. This is exhausting to read and easy to get wrong.def process_order(order): if order: if order.is_valid(): if order.has_items(): if order.customer.has_credit(): # finally do something (the reader is exhausted) pass# ✅ Guard clauses (early return) -- reject bad cases upfront, then# the remaining code runs at a single indentation level with zero# cognitive overhead. The "happy path" reads like a straight line.def process_order(order): if not order: return if not order.is_valid(): raise InvalidOrderError() if not order.has_items(): raise EmptyOrderError() if not order.customer.has_credit(): raise InsufficientCreditError() # Clean path -- if you reach here, all preconditions are satisfied. # No nesting, no ambiguity about what state the data is in. process(order)
Practical tip: If you find yourself deeper than 2-3 levels of nesting, treat it as a code smell. Either extract a helper function, use guard clauses, or rethink the control flow. Most senior engineers consider deep nesting a stronger signal of design problems than long functions.
Common Mistake: Over-applying principles can lead to over-engineering. Use judgment — simple problems need simple solutions. The goal is maintainability, not pattern purity. If you find yourself creating an AbstractFactoryProxyStrategyDecorator for a feature that sends one type of email, you have gone too far. A common heuristic: apply the “Rule of Three” — do not abstract until you have seen the same pattern in three different places.
Interview Tip: When discussing design principles, always mention trade-offs. SOLID principles add abstraction layers which increase indirection and can make code harder to follow. The art is knowing when the benefits (testability, extensibility, team scalability) outweigh the costs (more files, more indirection, steeper onboarding). The strongest interview answer sounds like: “I would apply the Strategy pattern here because we have three payment methods today and the product roadmap includes two more. If this were a one-time script, a simple if/else would be fine.”
A colleague submits a PR where a single class handles user validation, database persistence, email sending, and audit logging. They argue it is simpler to have everything in one place. How do you review this?
Strong Answer:
I would not reject the PR based on principle alone. The first question is: “How likely is this to change independently?” If this is a one-off admin script that will never change, a single class is fine — adding four abstractions for a script that runs once a month is over-engineering. But if this is core domain logic in a growing application, the Single Responsibility Principle applies directly.
My specific feedback: “Right now, this works. But imagine next month: the product team wants to switch from SendGrid to Mailgun for emails. With this design, the developer changing the email provider must also understand, touch, and potentially break the validation logic, the database queries, and the audit system in the same file. That is four reasons for this class to change, owned by potentially four different concerns.” I would frame it as risk management, not ideological purity.
I would suggest a concrete refactoring path: extract the email sending first (it is the most obviously independent concern), keep validation close to the entity (it is domain logic), and introduce a thin orchestrator that calls the pieces in sequence. The orchestrator is the “use case” layer in Clean Architecture — it knows WHAT to do but delegates HOW to specialized classes.
The test I would apply: “Can I write a unit test for the validation logic without setting up an SMTP server and a database connection?” If no, the class is doing too much. Testability is the practical manifestation of SRP — a well-separated class can be tested in isolation with simple mocks.
I would also point out the real production risk: a bug in the email-sending code (say, a timeout or exception) could now prevent the user record from being saved to the database and the audit log from being written, because they are all in the same try-except block. Separation of concerns is not just about code organization — it is about failure isolation.
Follow-up: The colleague says “YAGNI — we do not need this separation until we actually need it. Refactoring later is fine.” How do you respond?They have a valid point — YAGNI is a real principle and premature abstraction is a real cost. My response depends on context. If the application is an early-stage MVP with two engineers and uncertain product-market fit, I concede: ship it, move fast, accept the tech debt. But if this is a core service in a mature codebase with 10+ engineers, I push back. The cost of refactoring later is not constant — it grows with the number of callers, tests, and integrations touching this class. At that point, “we will refactor later” is the engineering equivalent of “we will pay off the credit card next month.” I would propose the Rule of Three as a compromise: leave it coupled for now, but if a second concern needs to change independently (say, they need to add Slack notifications alongside email), we split at that point. This respects YAGNI while setting a clear trigger for when we invest in separation.
Give me a real example where you would intentionally violate the DRY principle. Why would duplicating code be the right call?
Strong Answer:
The classic example is two microservices that both need a “calculate shipping cost” function. The junior instinct is to extract it into a shared library. But shared libraries between services create deployment coupling — updating the shipping calculation now requires releasing a new version of the library, having both services upgrade, coordinating their deployments, and testing both. You have recreated a distributed monolith through a shared dependency.
In this case, duplicating the calculation in each service is the right call. Each service can evolve its copy independently. If the order service needs a different shipping calculation for international orders while the invoicing service keeps the domestic formula, they diverge without conflict.
Another example: two different bounded contexts in DDD that both have a “User” concept. In the Billing context, a User has a payment method and billing address. In the Support context, a User has ticket history and escalation priority. These look similar but represent fundamentally different domain concepts. Merging them into one shared User model creates a god object that serves no single context well and couples unrelated domains.
The principle I use: DRY applies to knowledge duplication (the same business rule expressed in multiple places) not to code that happens to look similar. Two functions with identical code that represent different business decisions should remain separate — they are coincidentally identical today but will diverge tomorrow. The test: “If this logic changes, should BOTH copies change?” If yes, extract. If no, the similarity is accidental and merging creates coupling.
A concrete production example: at a fintech company, the tax calculation for invoices and the tax calculation for real-time checkout used the same formula but were owned by different teams with different deployment cadences. Merging them into a shared library meant the invoice team’s monthly release cycle blocked the checkout team’s daily deploys. Duplicating the 40-line function saved both teams weeks of coordination overhead per quarter.
Follow-up: How do you decide between a shared library, a shared service, and duplication when multiple teams need the same functionality?I evaluate along three axes: (1) Rate of change — if the logic changes frequently (weekly), a shared service with a versioned API is best because consumers are decoupled from internal changes. If it changes rarely (quarterly), a shared library with semantic versioning is fine. If it almost never changes, just duplicate it. (2) Operational coupling tolerance — a shared service means adding a network dependency and a failure mode. If the consuming services cannot tolerate the shared service being down, duplication is safer. (3) Consistency requirements — if all consumers MUST use exactly the same logic at all times (regulatory compliance, financial calculations), a shared service is the only option because library versions can drift. If approximate consistency is acceptable, a library or duplication works. In practice, I default to duplication for small amounts of logic (under 100 lines), a shared library for medium complexity within the same deployment pipeline, and a shared service only when the logic is substantial and has its own release lifecycle.
Compare the Strategy pattern and the Template Method pattern. When would you use each, and what are the pitfalls of choosing wrong?
Strong Answer:
Both patterns solve the same problem — varying behavior in an algorithm — but they use opposite mechanisms. Strategy uses composition: the algorithm is injected as a separate object, and you can swap it at runtime. Template Method uses inheritance: the algorithm skeleton is in a base class, and subclasses override specific steps.
I use Strategy when: (1) the varying behavior needs to be swapped at runtime (user selects a payment method at checkout), (2) multiple independent dimensions of variation exist (a report that varies by format AND by data source — combining these with inheritance creates an exponential class explosion), or (3) I want the algorithm to be independently testable.
I use Template Method when: (1) there is a fixed sequence of steps where only specific steps vary (ETL pipelines where Extract-Transform-Load is always the order, but each step’s implementation differs per data source), (2) the varying behavior is tightly coupled to the overall algorithm and it does not make sense to extract it, or (3) I want to enforce the algorithm structure and prevent subclasses from changing the step order.
The pitfall of choosing Strategy when Template Method is better: you end up passing 8 strategy objects into a constructor, each representing one step of a tightly coupled algorithm. The configuration becomes harder to understand than the inheritance hierarchy it replaced.
The pitfall of choosing Template Method when Strategy is better: you build a deep inheritance tree that becomes rigid. Adding a new variation requires a new subclass, and when two variations need to be combined, you reach for multiple inheritance or duplicated subclasses. This is the classic “fragile base class” problem — a change in the base class ripples unpredictably through all subclasses.
My real-world heuristic: if the variation is about WHAT to do (which algorithm), use Strategy. If the variation is about HOW to do a fixed sequence (which implementation of each step), use Template Method.
Follow-up: In a code review, you see someone using inheritance for code reuse rather than to model an is-a relationship. What is your concern and what do you recommend?This is the #1 misuse of inheritance. When you inherit from a class purely to reuse its methods, you create a coupling that says “this class IS A kind of that class,” which may not be true. The classic example: a Stack that extends ArrayList to reuse its internal storage. Now Stack has methods like add(index, element) and sort() that violate stack semantics. Any code receiving an ArrayList can receive your Stack and use it in ways that break the LIFO invariant — a Liskov Substitution violation. My recommendation: use composition instead. The Stack should CONTAIN a list as a private field and expose only push(), pop(), and peek(). This gives you the code reuse without the false type relationship and without exposing methods that violate your abstraction. The rule of thumb: inherit to establish a behavioral contract (polymorphism), compose to reuse implementation.
You are building a notification system that today supports email, SMS, and push notifications. The product roadmap shows Slack, WhatsApp, and in-app notifications coming in the next two quarters. How do you design this using SOLID principles?
Strong Answer:
This is a textbook case for the Open/Closed Principle combined with Strategy pattern. I would define a NotificationChannel interface with a single send(recipient, message) method. Each channel (Email, SMS, Push) implements this interface. Adding Slack or WhatsApp means creating a new class that implements the same interface — zero changes to existing code.
For the routing logic (which user gets which notification type), I would use the Dependency Inversion Principle: the NotificationService depends on the NotificationChannel abstraction, not on EmailSender or SmsSender directly. Channels are injected via a registry or factory.
For the Interface Segregation Principle: notifications have different capabilities — email supports HTML bodies and attachments, SMS has a 160-character limit, push notifications have a title and badge count. I would NOT create a fat interface with all possible fields. Instead, each channel’s send() method accepts a NotificationPayload that it adapts internally. The email channel extracts the HTML body; the SMS channel truncates to 160 characters. Each channel knows its own constraints.
For Single Responsibility: the NotificationService orchestrates (decides who gets what), but each channel class handles only its own delivery logic. The retry logic, rate limiting, and failure handling are in a decorator or middleware layer, not inside each channel implementation.
Real production considerations: notifications should be sent asynchronously via a message queue (Celery, SQS). The user should not wait 3 seconds for an email API call. I would also add a circuit breaker per channel — if the SMS provider is down, fail open (skip SMS, still send email and push) rather than failing the entire notification.
Follow-up: Six months in, the product team wants conditional notification logic: “Send email AND push for order confirmations, but ONLY push for shipping updates, and SMS only if the order is over $500.” How does your design handle this?This is where a notification preferences and routing rules engine comes in, and it is the point where many SOLID-designed systems reveal their limits. I would add a NotificationRouter that takes a notification event (type, context, recipient) and returns a list of channels to use. The routing rules could be stored as configuration (database or YAML), not code, so the product team can adjust them without a deployment. The router evaluates rules in order: “For event_type=order_confirmation, send via [email, push]. For event_type=shipping_update, send via [push]. For event_type=order_confirmation AND context.amount > 500, also send via [sms].” Each channel implementation remains unchanged — the router just decides which ones to invoke. This separates the “what to send where” decision from the “how to send via channel X” implementation. The trap to avoid: do not bake these rules into if/else chains in the NotificationService — that violates Open/Closed because every new rule requires a code change and redeployment.