Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

SOLID Principles

What is SOLID?

SOLID is an acronym for five design principles that help you write maintainable, flexible, and testable code. Coined by Robert C. Martin (Uncle Bob) in the early 2000s, these principles distill decades of software engineering experience into actionable rules. Think of SOLID as guardrails on a mountain road — they don’t tell you where to drive, but they keep you from going off a cliff. The real power of SOLID is not in rigid adherence, but in recognizing when a violation is causing pain and knowing exactly which principle to apply as the remedy.
LetterPrincipleOne-Liner
SSingle ResponsibilityOne class, one reason to change
OOpen/ClosedOpen for extension, closed for modification
LLiskov SubstitutionSubtypes must be substitutable for base types
IInterface SegregationMany specific interfaces > one general interface
DDependency InversionDepend on abstractions, not concretions

🚨 Code Smell Recognition

Before diving into principles, learn to recognize when code violates SOLID:
# 🚨 GOD CLASS - does everything
class UserManager:
    def create_user(self): pass
    def authenticate(self): pass
    def send_email(self): pass      # ❌ Email is not user management
    def generate_report(self): pass  # ❌ Reporting is separate concern
    def backup_data(self): pass      # ❌ Backup is infrastructure

# Signs of SRP violation:
# - Class has many unrelated methods
# - Class name includes "Manager", "Handler", "Processor", "Utils"
# - You need to change the class for different reasons

Case Study: E-commerce Order System

We’ll refactor a poorly designed order system step by step.

❌ Initial Bad Design (Violates ALL SOLID Principles)

class Order:
    def __init__(self, items, customer_email):
        self.items = items
        self.customer_email = customer_email
        self.status = "pending"
    
    def calculate_total(self):
        total = sum(item.price * item.quantity for item in self.items)
        # Apply discount based on items
        if total > 100:
            total *= 0.9
        return total
    
    def process_payment(self, card_number, cvv):
        # Direct payment processing
        print(f"Processing payment of ${self.calculate_total()}")
        # Credit card API call here
        return True
    
    def update_inventory(self):
        for item in self.items:
            # Direct database call
            print(f"Reducing stock for {item.name}")
    
    def send_confirmation_email(self):
        # Direct email sending
        print(f"Sending email to {self.customer_email}")
    
    def generate_invoice_pdf(self):
        # PDF generation logic
        print("Generating PDF invoice")
    
    def save_to_database(self):
        # Database save logic
        print("Saving order to database")
Problems:
  • One class does everything (violates SRP)
  • Hard to test (depends on external services)
  • Can’t change payment/email without modifying Order
  • Tightly coupled to implementations

S - Single Responsibility Principle

Each class should have only one reason to change.
The keyword here is “reason to change,” not “one thing to do.” A PricingService might have multiple methods (calculate discount, apply tax, convert currency), but they all change for the same reason: pricing rules change. If your class changes when the email template changes and when the database schema changes, those are two different reasons — and a sign you should split it. In practice, ask yourself: “If I hand this class to a single team to own, would they have conflicts with other teams?” If yes, SRP is being violated.

✅ Refactored: Separate Responsibilities

# Order only handles order data
class Order:
    def __init__(self, items: List[OrderItem], customer: Customer):
        self.id = uuid.uuid4()
        self.items = items
        self.customer = customer
        self.status = OrderStatus.PENDING
        self.created_at = datetime.now()
    
    def calculate_total(self) -> Decimal:
        return sum(item.subtotal for item in self.items)

# Separate class for pricing/discounts
class PricingService:
    def __init__(self, discount_rules: List[DiscountRule]):
        self.discount_rules = discount_rules
    
    def calculate_final_price(self, order: Order) -> Decimal:
        total = order.calculate_total()
        for rule in self.discount_rules:
            total = rule.apply(total, order)
        return total

# Separate class for payment
class PaymentProcessor:
    def process(self, order: Order, payment_method: PaymentMethod) -> PaymentResult:
        pass

# Separate class for notifications
class NotificationService:
    def send_order_confirmation(self, order: Order) -> None:
        pass

# Separate class for inventory
class InventoryService:
    def reserve_items(self, items: List[OrderItem]) -> bool:
        pass
    
    def release_items(self, items: List[OrderItem]) -> None:
        pass

# Separate class for persistence
class OrderRepository:
    def save(self, order: Order) -> None:
        pass
    
    def find_by_id(self, order_id: UUID) -> Optional[Order]:
        pass

O - Open/Closed Principle

Open for extension, closed for modification.
This principle is the reason design patterns exist. The goal: when a new requirement arrives (a new discount type, a new payment method, a new notification channel), you should be able to add it by writing new code, not by editing existing, tested code. The mechanism is almost always the same — define an abstraction (interface or abstract class), then let new implementations plug in. When you see a growing if/elif chain based on type, that is your cue: OCP is being violated, and a Strategy or Factory pattern is the fix.

✅ Extensible Discount System

from abc import ABC, abstractmethod

class DiscountRule(ABC):
    @abstractmethod
    def apply(self, total: Decimal, order: Order) -> Decimal:
        pass
    
    @abstractmethod
    def is_applicable(self, order: Order) -> bool:
        pass

class BulkDiscountRule(DiscountRule):
    """10% off for orders over $100"""
    def is_applicable(self, order: Order) -> bool:
        return order.calculate_total() > 100
    
    def apply(self, total: Decimal, order: Order) -> Decimal:
        if self.is_applicable(order):
            return total * Decimal("0.9")
        return total

class FirstOrderDiscountRule(DiscountRule):
    """15% off for first-time customers"""
    def __init__(self, order_repository: OrderRepository):
        self.order_repository = order_repository
    
    def is_applicable(self, order: Order) -> bool:
        past_orders = self.order_repository.find_by_customer(order.customer.id)
        return len(past_orders) == 0
    
    def apply(self, total: Decimal, order: Order) -> Decimal:
        if self.is_applicable(order):
            return total * Decimal("0.85")
        return total

class HolidayDiscountRule(DiscountRule):
    """20% off during holidays"""
    def __init__(self, holiday_calendar: HolidayCalendar):
        self.holiday_calendar = holiday_calendar
    
    def is_applicable(self, order: Order) -> bool:
        return self.holiday_calendar.is_holiday(order.created_at)
    
    def apply(self, total: Decimal, order: Order) -> Decimal:
        if self.is_applicable(order):
            return total * Decimal("0.8")
        return total

# Adding new discount = new class, no modification to existing code!
class LoyaltyDiscountRule(DiscountRule):
    """5% off for loyalty members"""
    def is_applicable(self, order: Order) -> bool:
        return order.customer.is_loyalty_member
    
    def apply(self, total: Decimal, order: Order) -> Decimal:
        if self.is_applicable(order):
            return total * Decimal("0.95")
        return total

L - Liskov Substitution Principle

Subtypes must be substitutable for their base types.
Named after Barbara Liskov, this principle answers a simple question: can you replace a parent object with a child object and have everything still work correctly? If not, your inheritance hierarchy is lying about its contracts. The classic violation is the Penguin-extends-Bird problem (penguins cannot fly), but the real-world violations are subtler: a ReadOnlyDatabase that inherits from Database but throws on write(), or a Square that inherits from Rectangle but silently changes both dimensions when you set one. The fix is almost always to redesign the hierarchy or use composition.

❌ Violation Example

class Bird:
    def fly(self):
        return "Flying"

class Penguin(Bird):
    def fly(self):
        raise Exception("Penguins can't fly!")  # Violates LSP!

✅ Correct Hierarchy

class Bird(ABC):
    @abstractmethod
    def move(self):
        pass

class FlyingBird(Bird):
    def move(self):
        return self.fly()
    
    def fly(self):
        return "Flying through the air"

class SwimmingBird(Bird):
    def move(self):
        return self.swim()
    
    def swim(self):
        return "Swimming in water"

class Sparrow(FlyingBird):
    pass

class Penguin(SwimmingBird):
    pass

# Now any Bird can be used interchangeably
def make_bird_move(bird: Bird):
    print(bird.move())  # Works for all birds!

✅ Payment Example

class PaymentMethod(ABC):
    @abstractmethod
    def pay(self, amount: Decimal) -> PaymentResult:
        pass
    
    @abstractmethod
    def supports_refund(self) -> bool:
        pass

class CreditCardPayment(PaymentMethod):
    def pay(self, amount: Decimal) -> PaymentResult:
        # Process credit card
        return PaymentResult(success=True)
    
    def supports_refund(self) -> bool:
        return True
    
    def refund(self, amount: Decimal) -> RefundResult:
        return RefundResult(success=True)

class CashOnDelivery(PaymentMethod):
    def pay(self, amount: Decimal) -> PaymentResult:
        # Mark as COD
        return PaymentResult(success=True, is_cod=True)
    
    def supports_refund(self) -> bool:
        return False  # No refund for COD until delivered

# Client code works with any PaymentMethod
def process_order(order: Order, payment: PaymentMethod):
    result = payment.pay(order.total)
    if result.success:
        order.mark_as_paid()

I - Interface Segregation Principle

Clients should not depend on interfaces they don’t use.
If you have ever seen a class that implements an interface but stubs out half the methods with pass or raise NotImplementedError, you have witnessed ISP being violated. The fix is to split the fat interface into smaller, role-specific ones. A Printer interface should not force implementors to also support scanning, faxing, and stapling. This principle works hand-in-hand with SRP: just as classes should have a single responsibility, interfaces should have a single purpose. The practical benefit is decoupling — when you depend on a narrow interface, changes to unrelated capabilities cannot break your code.

❌ Fat Interface

class Worker(ABC):
    @abstractmethod
    def work(self):
        pass
    
    @abstractmethod
    def eat(self):
        pass
    
    @abstractmethod
    def sleep(self):
        pass
    
    @abstractmethod
    def attend_meeting(self):
        pass
    
    @abstractmethod
    def write_report(self):
        pass

✅ Segregated Interfaces

class Workable(ABC):
    @abstractmethod
    def work(self):
        pass

class Feedable(ABC):
    @abstractmethod
    def eat(self):
        pass

class Sleepable(ABC):
    @abstractmethod
    def sleep(self):
        pass

class Manageable(ABC):
    @abstractmethod
    def attend_meeting(self):
        pass
    
    @abstractmethod
    def write_report(self):
        pass

# Human employee implements all
class Employee(Workable, Feedable, Sleepable, Manageable):
    def work(self):
        print("Working on tasks")
    
    def eat(self):
        print("Having lunch")
    
    def sleep(self):
        print("Sleeping at night")
    
    def attend_meeting(self):
        print("In meeting")
    
    def write_report(self):
        print("Writing report")

# Robot only implements what it can do
class Robot(Workable):
    def work(self):
        print("Processing tasks 24/7")

✅ E-commerce Example

# Instead of one huge OrderService interface
class OrderReader(ABC):
    @abstractmethod
    def find_by_id(self, order_id: UUID) -> Order:
        pass
    
    @abstractmethod
    def find_by_customer(self, customer_id: UUID) -> List[Order]:
        pass

class OrderWriter(ABC):
    @abstractmethod
    def save(self, order: Order) -> None:
        pass
    
    @abstractmethod
    def update(self, order: Order) -> None:
        pass

class OrderCanceller(ABC):
    @abstractmethod
    def cancel(self, order_id: UUID, reason: str) -> None:
        pass

# Services can depend on only what they need
class OrderDisplayService:
    def __init__(self, reader: OrderReader):  # Only needs read
        self.reader = reader

class CheckoutService:
    def __init__(self, writer: OrderWriter):  # Only needs write
        self.writer = writer

D - Dependency Inversion Principle

Depend on abstractions, not concretions.
This is the principle that makes everything testable. When your OrderService directly creates a PostgreSQLDatabase inside its constructor, you cannot test the order logic without a running database. But when it accepts a Database interface through its constructor (dependency injection), you can pass in a MockDatabase for tests, a PostgreSQL for production, and a SQLite for local development. The “inversion” is about who controls the dependency — instead of the class deciding what it uses, the caller decides what to inject. This seemingly small shift transforms tightly coupled monoliths into flexible, testable, swappable architectures.

❌ Tight Coupling

class OrderService:
    def __init__(self):
        self.db = PostgreSQLDatabase()  # Directly depends on implementation
        self.payment = StripePayment()
        self.email = SendGridEmail()

✅ Dependency Injection

# Abstractions
class Database(ABC):
    @abstractmethod
    def save(self, entity: Any) -> None:
        pass

class PaymentGateway(ABC):
    @abstractmethod
    def charge(self, amount: Decimal) -> PaymentResult:
        pass

class EmailService(ABC):
    @abstractmethod
    def send(self, to: str, subject: str, body: str) -> None:
        pass

# Implementations
class PostgreSQLDatabase(Database):
    def save(self, entity: Any) -> None:
        print("Saving to PostgreSQL")

class StripePaymentGateway(PaymentGateway):
    def charge(self, amount: Decimal) -> PaymentResult:
        print("Charging via Stripe")
        return PaymentResult(success=True)

# Service depends on abstractions
class OrderService:
    def __init__(
        self,
        database: Database,
        payment: PaymentGateway,
        email: EmailService
    ):
        self.database = database
        self.payment = payment
        self.email = email
    
    def place_order(self, order: Order) -> None:
        self.payment.charge(order.total)
        self.database.save(order)
        self.email.send(
            order.customer.email,
            "Order Confirmed",
            f"Your order {order.id} is confirmed"
        )

# Easy to swap implementations
service = OrderService(
    database=PostgreSQLDatabase(),
    payment=StripePaymentGateway(),
    email=SendGridEmailService()
)

# Easy to test with mocks
test_service = OrderService(
    database=MockDatabase(),
    payment=MockPayment(),
    email=MockEmail()
)

SOLID Summary

PrincipleOne-linerBenefit
Single ResponsibilityOne reason to changeEasier maintenance
Open/ClosedExtend without modifyingSafer changes
Liskov SubstitutionSubtypes are interchangeableReliable polymorphism
Interface SegregationSmall, focused interfacesLess coupling
Dependency InversionDepend on abstractionsFlexible, testable

🎯 Quick Decision Guide

When designing classes, ask yourself:
1

Single Responsibility Check

Question: Does this class have only one reason to change?Red Flag: Class name contains “Manager”, “Handler”, “Utils”, “Helper”Fix: Extract each responsibility into its own class
2

Open/Closed Check

Question: Can I add new behavior without modifying existing code?Red Flag: Long if-elif chains based on typeFix: Use polymorphism, Strategy pattern, or plugin architecture
3

Liskov Substitution Check

Question: Can I use any subtype wherever the parent is expected?Red Flag: Type checking before method calls, “not implemented” exceptionsFix: Redesign hierarchy or use composition instead
4

Interface Segregation Check

Question: Does every implementer need ALL methods in the interface?Red Flag: Empty stub implementations, methods throwing “not supported”Fix: Split into smaller, focused interfaces
5

Dependency Inversion Check

Question: Does this class depend on abstractions, not concretions?Red Flag: Using new or direct instantiation of dependenciesFix: Inject dependencies through constructor/parameters

💡 Interview Tips

During LLD interviews, explicitly mention when you’re applying SOLID:
  • “I’m separating payment processing into its own class for Single Responsibility”
  • “Using an interface here so we can add new payment methods without modifying existing code - that’s Open/Closed”
  • “I’ll inject the database as a dependency so it’s easy to test”
SOLID adds complexity. Don’t apply it to:
  • Simple scripts or one-off utilities
  • Classes with only 1-2 methods
  • Early prototypes (refactor later)
Apply SOLID when you expect:
  • Multiple developers
  • Long-term maintenance
  • Frequent changes/extensions
SOLID code is easy to test:
# With DIP, testing is trivial:
def test_order_service():
    mock_db = MockDatabase()
    mock_email = MockEmailService()
    service = OrderService(mock_db, mock_email)
    
    service.place_order(test_order)
    
    assert mock_db.save_called
    assert mock_email.send_called
Remember: SOLID principles are guidelines, not rules. Apply them where they add value. Over-engineering is also a problem! The goal is clean, maintainable code - not perfect adherence to principles.

Interview Questions

Strong Answer:
  • One class per method is too granular — that is not what SRP means. SRP says “one reason to change,” not “one method.” A PricingService with calculate_discount(), apply_tax(), and convert_currency() has three methods but one reason to change: pricing rules evolve. Splitting those into three classes would create unnecessary indirection with no design benefit.
  • The right question to ask is: “Who would request a change to this code?” If the finance team changes tax rules, that affects PricingService. If the marketing team changes discount logic, that also affects PricingService. If those two teams are the same stakeholder (or always change the rules together), one class is fine. If they are different teams with different release cadences, split them.
  • Robert Martin’s original formulation is actually “a class should have only one reason to change,” which he later refined to “a module should be responsible to one, and only one, actor.” The actor framing is more practical than counting methods.
  • In the e-commerce case, the right split is roughly: Order (data and order-level calculations), PricingService (discounts and pricing rules), PaymentProcessor (payment gateway interaction), NotificationService (email/SMS), InventoryService (stock management), OrderRepository (persistence). That is 6 classes, but the split is by responsibility domain, not by method count.
Red flag answer: “Every method should be its own class” or “SRP means a class should only have one method.” Both indicate a mechanical reading of SRP without understanding the underlying principle of cohesion.Follow-ups:
  1. You have a UserService with create_user(), update_profile(), change_password(), and delete_user(). A colleague says this violates SRP because it has four methods. How do you respond?
  2. At what point does splitting classes for SRP become over-engineering? Can you give a concrete example where you chose NOT to split despite a mild SRP tension?
Strong Answer:
  • This is where the real-world stress-tests OCP. The current PricingService iterates through all rules sequentially, applying each one. That model breaks under both new requirements because the interaction between rules is now a concern, and that logic lives in PricingService, not in individual rules.
  • For “best discount only”: the PricingService needs to evaluate all applicable rules, collect the resulting prices, and pick the minimum. Each DiscountRule still computes independently (OCP preserved for individual rules), but the composition strategy changes. I would extract the composition logic into a DiscountStrategy interface with implementations like StackAllDiscounts, BestDiscountOnly, and PriorityStackDiscounts.
  • For “VIP always applies on top”: you need rule priority and layering. I would add a priority() method to DiscountRule and a is_stackable() boolean. The composition strategy sorts by priority, applies non-stackable rules to find the best base discount, then applies stackable rules on top. The DiscountRule interface grows by one or two methods, but existing implementations only need minor additions.
  • The key insight: OCP is preserved at the rule level (new discount types do not modify existing rules), but the composition mechanism needs modification. This is expected — the principle says “closed for modification” at the right abstraction level, not at every level.
Red flag answer: “OCP means you never modify existing code” (absolutist and impractical) or “Just add if-statements in PricingService to handle the stacking rules” (defeats the entire OCP architecture).Follow-ups:
  1. If you have 20 discount rules with complex priority and stacking logic, how do you test that the composition strategy is correct? What testing approach gives you the most confidence?
  2. The business now says “show the customer which discount was applied and why.” How does this affect your DiscountRule interface?
Strong Answer:
  • Yes, Square violates LSP, and this is one of the most famous examples in software design history. The violation is subtle because Square IS-A Rectangle in mathematics, but not in code. The issue is behavioral contracts.
  • Consider code that uses a Rectangle: r.setWidth(5); r.setHeight(10); assert r.area() == 50. This is a reasonable postcondition for Rectangle. But if r is actually a Square, setHeight(10) also sets width to 10, so r.area() == 100. The assertion fails. Square cannot be substituted for Rectangle without breaking existing code’s assumptions.
  • The root cause is that Rectangle has an implicit contract: width and height are independently mutable. Square violates this contract by coupling them. The type signature is preserved, but the behavioral contract is broken.
  • Solutions: Make both classes immutable — without setters, the behavioral contract issue disappears. Or do not use inheritance at all — Square is not a subtype of Rectangle in code, it is a special case. Use a factory method Rectangle.create_square(side) that returns a Rectangle with equal dimensions.
Red flag answer: “Square IS a Rectangle, so it should extend Rectangle” (confuses mathematical taxonomy with software contracts) or “Just don’t write code that assumes width and height are independent” (shifts the burden to every consumer).Follow-ups:
  1. If immutability solves the Square/Rectangle problem, does that mean LSP is only relevant for mutable objects? Can you think of an LSP violation with immutable types?
  2. Java’s Collections.unmodifiableList() returns a List that throws UnsupportedOperationException on add(). Is this an LSP violation?
Strong Answer:
  • This is the tension between ISP and practical polymorphism. You need a shared type for the scheduler, but you do not want a fat interface that forces robots to implement eat() and sleep().
  • The solution is a minimal shared interface defined by the consumer. Create a Schedulable interface with get_availability() and assign_task(). Both Employee and Robot implement Schedulable. The scheduler works with List[Schedulable] and does not know or care whether the entity eats or sleeps.
  • This means an Employee implements Workable, Feedable, Sleepable, Manageable, AND Schedulable. A Robot implements Workable and Schedulable. Each subsystem sees exactly the interface it needs. The cafeteria system sees only Feedable entities. The scheduler sees only Schedulable entities.
  • This pattern is called “Role Interfaces” — each interface represents a role the object plays in a specific context, rather than a complete description of the object. It is ISP applied correctly: the consumer defines what it needs, not the producer.
Red flag answer: “Just make Robot implement the full Worker interface with stub methods” (defeats ISP entirely) or “Use isinstance checks in the scheduler” (fragile, violates OCP because adding a new entity type requires modifying the scheduler).Follow-ups:
  1. With 5 narrow interfaces, how do you handle the complexity of a class implementing all 5? Does this lead to “header interface hell” where every class has a long list of implements?
  2. In Go, interfaces are satisfied implicitly (structural typing). How does that change the ISP trade-off compared to Python or Java where you explicitly declare implementation?
Strong Answer:
  • In a small app, you wire it manually in main(): db = PostgreSQLDatabase(); service = OrderService(db). But at 20+ services with cross-cutting dependencies, manual wiring becomes a maintenance nightmare.
  • In Python, the common approaches are: (1) A composition root — a single place (usually main.py or app_factory()) that creates all dependencies and wires them. This is explicit and debuggable but verbose. (2) A DI container like dependency-injector that automates wiring based on type hints. You register bindings (Database -> PostgreSQLDatabase) and the container resolves the graph. (3) Framework-level DI like FastAPI’s Depends().
  • The anti-pattern to avoid is the Service Locator, where dependencies are looked up from a global registry: ServiceLocator.get(Database). This hides dependencies, makes testing harder, and turns compile-time errors into runtime errors. DI makes dependencies explicit in the constructor; Service Locator buries them inside method bodies.
  • At scale (Uber, Stripe), the composition root approach is common. A service starts, reads config, constructs concrete implementations in the right order, and injects them. The wiring code is boring — and boring is a feature. Boring code is predictable code.
Red flag answer: “Just use global variables” or “Import the concrete class directly wherever you need it.” Both destroy testability and couple everything to specific implementations.Follow-ups:
  1. What is the difference between Dependency Injection and Dependency Inversion? They sound similar but are separate concepts.
  2. You need PostgreSQL in production, SQLite in development, and MockDB in tests. How do you configure this without if/elif chains?
Strong Answer:
  • They have a valid point, and this is one of the most important judgment calls in engineering. SOLID and YAGNI are in genuine tension. Applying SOLID to a hackathon project is over-engineering. Applying it to core order processing that 50 engineers maintain for 5 years is essential.
  • The heuristic I use: “How many people will touch this code, and how long will it live?” If it is one person for a week, the monolithic class is fine. If it is a team for years, the SOLID refactoring pays for itself within the first few feature additions. The 150 lines of well-separated code is cheaper to maintain than 30 lines of tangled code once you add the 5th payment method.
  • The practical compromise is “just-in-time SOLID”: start with the simpler design and refactor when you feel the first pain. When adding a second payment method requires modifying the Order class, that is the signal to extract PaymentProcessor. Martin Fowler calls this the Rule of Three: first time just do it, second time wince, third time refactor.
  • Over-engineering is as real a problem as under-engineering. The skill is knowing which one you are closer to, and that requires experience and context about the codebase’s trajectory.
Red flag answer: “SOLID should always be applied from the start” (ignores cost and context) or “YAGNI means never refactor” (ignores compounding technical debt).Follow-ups:
  1. You are a tech lead on a new project. How do you communicate to your team when to apply SOLID strictly versus when to be pragmatic?
  2. Can you give a real example where premature SOLID application caused more harm than the problem it was trying to prevent?
Strong Answer:
  • Strongest synergy: OCP and DIP. You cannot truly achieve OCP without DIP. If your high-level module depends on a concrete class, “extending” means modifying that class. DIP provides the abstraction layer that OCP extends through. In the e-commerce example, the DiscountRule abstraction (DIP) enables adding new discount types without modifying PricingService (OCP).
  • Most likely to conflict: SRP and ISP. When you aggressively apply ISP, you create many tiny interfaces. Classes implementing them become narrowly focused, sometimes holding almost no logic — just glue code. Conversely, applying SRP can create classes implementing 5 narrow interfaces, raising the question: is a class with 5 interface contracts really “single responsibility”?
  • Another tension: SRP and pragmatism. Having 40 single-method classes is worse than 10 four-method classes if all four methods change for the same reason. SRP at the service level matters more than SRP at the method level.
  • The resolution is context-dependent. In a microservice with 3 endpoints, SRP at the class level matters less than SRP at the service level. In a monolith with 500 classes, ISP becomes critical. SOLID principles are not equally weighted in every situation.
Red flag answer: “All 5 principles always work together perfectly” (suggests theoretical knowledge without practical experience) or “I just apply them all equally” (no judgment about context).Follow-ups:
  1. LSP and OCP both deal with subtyping and extension. Can you give an example where satisfying one forces you to violate the other?
  2. If you could only teach a junior developer TWO of the five SOLID principles, which two would give them the most leverage?
Strong Answer:
  • Both sides have merit. Unit tests with mocks verify that OrderService’s logic is correct in isolation: does it call charge() before save()? Does it handle payment failure? These run in milliseconds and catch logic bugs. Integration tests with real dependencies verify that pieces actually fit together: does the SQL work against PostgreSQL? Does Stripe accept the payload format?
  • The danger the QA engineer points to is real: over-mocking. If your mock always returns success=True and Stripe rejects 5% of charges due to expired cards, your tests give false confidence. The rule I follow: mock at the boundary of YOUR code. Mock external APIs (Stripe, SendGrid) because you do not control them, but use real implementations for your own classes.
  • The Testing Pyramid resolves this: fast unit tests with mocks on every commit (30 seconds), integration tests against sandboxed services nightly, and contract tests (Pact) to verify mocks match real API behavior. Mocks and integration tests are not alternatives — they are different layers of the same safety net.
  • At Shopify and Stripe, the pattern is: unit test the business logic with mocks, integration test the boundaries with real dependencies, contract test the mock fidelity. Each layer catches different classes of bugs.
Red flag answer: “Always mock everything for speed” or “Never mock, only integration tests.” Both are absolutist positions that ignore the cost-benefit trade-off at each testing layer.Follow-ups:
  1. Your MockDatabase always returns success. In production, PostgreSQL throws a unique constraint violation on duplicate order IDs. How do you catch this without a full PostgreSQL in tests?
  2. What are contract tests (like Pact), and how do they solve the “mocks drift from reality” problem?

Interview Deep-Dive

Strong Answer:
  • I would pick Single Responsibility (S) and Dependency Inversion (D) as the two highest-impact principles.
  • SRP is first because the original Order class has five responsibilities: data management, pricing, payment processing, inventory updates, email sending, and PDF generation. Extracting each into its own class immediately makes the codebase navigable, testable, and assignable to different team members. This single refactoring eliminates the God class anti-pattern.
  • DIP is second because once you have separated the responsibilities, you need those services to depend on abstractions rather than concrete implementations. Injecting a PaymentGateway interface rather than hardcoding StripePayment means you can test the OrderService without a live Stripe connection, swap to PayPal for a different market, or use a MockPayment in CI/CD pipelines.
  • OCP, LSP, and ISP would naturally follow once S and D are in place. With abstractions injected (DIP), adding new payment methods becomes a matter of creating new classes (OCP). With focused interfaces from the extraction (SRP), you avoid fat interfaces (ISP). And with proper abstractions, subtypes stay substitutable (LSP).
  • In my experience, SRP and DIP together give you roughly eighty percent of the maintainability benefit of SOLID.
Follow-up: What is the risk of over-applying SRP? When have you seen it go wrong?The risk is “class explosion” — breaking a coherent responsibility into too many tiny classes. I once saw a codebase where someone split a UserService into UserCreator, UserUpdater, UserDeleter, UserFinder, UserValidator, UserSerializer — six classes for basic CRUD. The cognitive overhead of navigating six files for one entity was worse than the original class. The test: if all the methods change for the same reason (user data schema changes), they belong in one class. SRP says “one reason to change,” not “one method per class.”
Strong Answer:
  • The current design passes a list of DiscountRules to the PricingService and applies them sequentially. To handle priority and mutual exclusivity, I would add two concepts: a priority field on each rule, and a discount group for mutual exclusivity.
  • Each DiscountRule gets a priority (integer, lower is higher priority) and an optional group name. The PricingService sorts rules by priority, then within each group, applies only the best (highest-saving) rule and skips the rest in that group. Rules without a group are applied independently.
  • This follows OCP because adding a new discount type still means creating a new DiscountRule class. The prioritization and exclusivity logic lives in the PricingService, which iterates over any list of rules without knowing their specific types.
  • For example: “HolidayDiscount” and “LoyaltyDiscount” might be in different groups (both apply), but “FirstOrderDiscount” and “ReferralDiscount” might be in the same group “new_customer” (only the better one applies).
  • An alternative is the Chain of Responsibility pattern, where each rule decides whether to apply itself and whether to pass control to the next rule. This gives even more flexibility for complex discount logic.
Follow-up: How would you test this priority and exclusivity logic?I would write unit tests for the PricingService with mock DiscountRules. Test cases: two rules in the same group where rule A gives 10% off and rule B gives 20% off — verify only 20% is applied. Two rules in different groups — verify both apply. A rule with is_applicable returning False — verify it is skipped. Rules with identical priority — verify deterministic ordering (tie-break by insertion order or rule name). Since the PricingService depends on the DiscountRule abstraction (DIP), I can inject simple test implementations without touching any real pricing logic.
Strong Answer:
  • In a real codebase with 200 subclasses, a complete rewrite is not feasible. I would use a phased approach.
  • Phase 1: Introduce the new interfaces (FlyingBird, SwimmingBird) alongside the existing Bird class. The existing Bird class implements all the new interfaces to maintain backward compatibility. No existing code breaks.
  • Phase 2: Gradually migrate consumers. When a function currently takes Bird but only calls fly(), change its parameter type to FlyingBird. This is safe because Bird implements FlyingBird, so all existing callers still work. But now the type system prevents passing a Penguin to a function that expects flying behavior.
  • Phase 3: Migrate subclasses one at a time. Change Penguin from extending Bird to extending SwimmingBird. If some code still passes Penguin where Bird is expected, the type checker flags it.
  • Phase 4: Once all subclasses are migrated, deprecate the catch-all Bird class and remove the problematic fly() method from the base.
  • This is the “Strangler Fig” pattern applied to a class hierarchy. Each phase is independently deployable and testable. The key is never breaking existing callers during the migration.
Follow-up: How do you convince your team to invest time in this migration?I quantify the cost of the current violation. Every time a developer adds a new subclass, they have to implement methods they cannot support (raising NotImplementedError), write defensive isinstance checks in consumer code, and maintain documentation about which subclasses support which methods. I track how many bugs or incidents were caused by someone passing a Penguin to code that expected flying behavior. If I can show two production incidents in the last quarter caused by LSP violations, the migration pays for itself.
Strong Answer:
  • Constructor injection is the default and best choice for required dependencies. The object cannot be created in an invalid state because all dependencies are provided at construction time. It makes dependencies explicit — you can look at the constructor signature and immediately see what the class needs. It also works naturally with immutability: once set, the dependencies do not change.
  • Setter injection is for optional dependencies or dependencies that might change after construction. For example, a Logger that can be swapped at runtime, or a CacheProvider that might be configured after the service is created. The downside is that the object can exist in a partially configured state — you must handle the case where the setter was never called.
  • Method injection is for dependencies that are only needed for a single operation, not for the lifetime of the object. For example, passing an AuditLogger to a specific processPayment() call because only that method needs auditing. This keeps the class lightweight and avoids storing dependencies that are rarely used.
  • In practice, I use constructor injection for 90% of cases, method injection for 8%, and setter injection for 2%. If I find myself using setter injection frequently, it usually means my class has too many optional behaviors and should be split.
Follow-up: How does this relate to DI containers and frameworks?DI containers (like Spring in Java, or dependency-injector in Python) automate the wiring. Instead of manually constructing OrderService(PostgreSQLDatabase(), StripePayment(), SendGridEmail()), the container reads configuration, resolves the dependency graph, and creates everything for you. The benefit is that adding a new dependency to OrderService does not require updating every call site. The trade-off is implicit magic — a developer reading the code cannot immediately see where the dependencies come from without understanding the container’s configuration. I prefer explicit constructor injection in smaller systems and containers in larger systems with dozens of services.