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.

The Four Pillars of OOP

These four concepts are the foundation of every well-designed object-oriented system. Think of them as the load-bearing walls of a building — you can rearrange the furniture (implementation details), but these structural elements must be solid or everything collapses. In interviews, demonstrating fluency with these pillars is table stakes; what separates candidates is knowing when and why to apply each one.

Encapsulation

Bundling data and methods that operate on that data

Abstraction

Hiding complex implementation details

Inheritance

Creating new classes from existing ones

Polymorphism

Same interface, different implementations

1. Encapsulation

Encapsulation protects object integrity by hiding internal state and requiring all interaction through methods. Think of it like an ATM machine: you interact through a well-defined interface (insert card, enter PIN, select amount), but the internal cash-counting mechanism, network communication, and security protocols are completely hidden. If the bank upgrades its internal systems, your interaction with the ATM does not change. That is encapsulation in action — controlled access gates that protect invariants and allow internal evolution without breaking external consumers.
class BankAccount:
    def __init__(self, account_number: str, initial_balance: float = 0):
        self._account_number = account_number  # Protected
        self.__balance = initial_balance        # Private
        self.__transactions = []
    
    @property
    def balance(self) -> float:
        """Read-only access to balance"""
        return self.__balance
    
    @property
    def account_number(self) -> str:
        return self._account_number
    
    def deposit(self, amount: float) -> bool:
        """Controlled way to modify balance"""
        if amount <= 0:
            raise ValueError("Deposit amount must be positive")
        
        self.__balance += amount
        self.__record_transaction("DEPOSIT", amount)
        return True
    
    def withdraw(self, amount: float) -> bool:
        """Business rules enforced here"""
        if amount <= 0:
            raise ValueError("Withdrawal amount must be positive")
        if amount > self.__balance:
            raise InsufficientFundsError("Insufficient funds")
        
        self.__balance -= amount
        self.__record_transaction("WITHDRAWAL", amount)
        return True
    
    def __record_transaction(self, type: str, amount: float):
        """Private method - internal use only"""
        self.__transactions.append({
            "type": type,
            "amount": amount,
            "timestamp": datetime.now(),
            "balance_after": self.__balance
        })
    
    def get_statement(self) -> List[dict]:
        """Controlled access to transaction history"""
        return self.__transactions.copy()  # Return copy, not reference

# Usage
account = BankAccount("123456", 1000)
account.deposit(500)    # ✅ Controlled access
account.withdraw(200)   # ✅ Business rules applied
print(account.balance)  # ✅ Read-only property

# These would fail:
# account.__balance = 1000000  # ❌ Can't access private
# account.balance = 1000000    # ❌ No setter defined

Benefits of Encapsulation

BenefitDescription
Data ProtectionPrevents invalid states
FlexibilityChange implementation without affecting users
ValidationEnforce business rules at access points
DebuggingAll modifications go through known paths

2. Abstraction

Abstraction hides complexity by showing only necessary details. Consider how you drive a car: you turn the steering wheel and the car turns. You don’t need to understand the rack-and-pinion mechanism, power steering fluid dynamics, or electronic stability control. The steering wheel is an abstraction over enormous mechanical complexity. In software, abstraction lets you define what something does (the contract) without dictating how it does it — enabling multiple implementations to coexist behind the same interface.
from abc import ABC, abstractmethod

# Abstract class - defines what, not how
class PaymentProcessor(ABC):
    @abstractmethod
    def process_payment(self, amount: float) -> PaymentResult:
        """Process a payment - implementation hidden"""
        pass
    
    @abstractmethod
    def refund(self, transaction_id: str) -> RefundResult:
        """Process a refund"""
        pass
    
    def validate_amount(self, amount: float) -> bool:
        """Concrete method - shared implementation"""
        return amount > 0

# Concrete implementation - the "how"
class StripePaymentProcessor(PaymentProcessor):
    def __init__(self, api_key: str):
        self.__api_key = api_key
        self.__client = StripeClient(api_key)
    
    def process_payment(self, amount: float) -> PaymentResult:
        # Complex Stripe-specific logic hidden
        if not self.validate_amount(amount):
            return PaymentResult(success=False, error="Invalid amount")
        
        try:
            charge = self.__client.charges.create(
                amount=int(amount * 100),
                currency="usd"
            )
            return PaymentResult(
                success=True,
                transaction_id=charge.id
            )
        except StripeError as e:
            return PaymentResult(success=False, error=str(e))
    
    def refund(self, transaction_id: str) -> RefundResult:
        # Stripe-specific refund logic
        pass

class PayPalPaymentProcessor(PaymentProcessor):
    def __init__(self, client_id: str, secret: str):
        self.__client = PayPalClient(client_id, secret)
    
    def process_payment(self, amount: float) -> PaymentResult:
        # PayPal-specific logic - different from Stripe
        pass
    
    def refund(self, transaction_id: str) -> RefundResult:
        pass

# Client code doesn't know which processor is used
class CheckoutService:
    def __init__(self, payment_processor: PaymentProcessor):
        self.processor = payment_processor
    
    def checkout(self, cart: Cart) -> OrderResult:
        # Works with ANY payment processor
        result = self.processor.process_payment(cart.total)
        return OrderResult(success=result.success)

3. Inheritance

Inheritance creates “is-a” relationships, allowing code reuse and specialization. Think of it like biological taxonomy: a Golden Retriever is a Dog, which is a Mammal, which is an Animal. Each level adds specificity while inheriting general traits. However, inheritance is the most overused and misused OOP pillar. The rule of thumb: use inheritance only when there is a genuine “is-a” relationship and the parent class is stable. If the parent changes frequently, you will end up with fragile hierarchies. When in doubt, prefer composition — it gives you flexibility without the tight coupling.
# Base class
class Vehicle:
    def __init__(self, brand: str, model: str, year: int):
        self.brand = brand
        self.model = model
        self.year = year
        self._mileage = 0
    
    def drive(self, distance: float):
        self._mileage += distance
        print(f"Drove {distance} miles")
    
    def get_info(self) -> str:
        return f"{self.year} {self.brand} {self.model}"

# Derived classes
class Car(Vehicle):
    def __init__(self, brand: str, model: str, year: int, num_doors: int):
        super().__init__(brand, model, year)
        self.num_doors = num_doors
    
    def honk(self):
        print("Beep beep!")

class Motorcycle(Vehicle):
    def __init__(self, brand: str, model: str, year: int, engine_cc: int):
        super().__init__(brand, model, year)
        self.engine_cc = engine_cc
    
    def wheelie(self):
        print("Doing a wheelie!")

class ElectricCar(Car):
    def __init__(self, brand: str, model: str, year: int, 
                 num_doors: int, battery_kwh: float):
        super().__init__(brand, model, year, num_doors)
        self.battery_kwh = battery_kwh
        self._charge_level = 100
    
    def drive(self, distance: float):
        # Override parent method
        energy_used = distance * 0.25  # kWh per mile
        if energy_used > self._charge_level:
            print("Not enough charge!")
            return
        
        self._charge_level -= energy_used
        super().drive(distance)  # Call parent implementation
    
    def charge(self):
        self._charge_level = 100
        print("Fully charged!")

# Usage
tesla = ElectricCar("Tesla", "Model 3", 2024, 4, 75)
tesla.drive(100)     # Uses overridden method
tesla.honk()         # Inherited from Car
print(tesla.get_info())  # Inherited from Vehicle

When to Use Inheritance

Use InheritanceUse Composition
True “is-a” relationship”Has-a” relationship
Shared behavior across typesFlexible behavior mixing
Stable base classFrequently changing behavior
Limited hierarchy depthComplex variations

4. Polymorphism

Polymorphism allows objects of different types to be treated uniformly. Imagine a universal remote control: pressing “play” does different things depending on whether it is controlling a DVD player, a streaming box, or a Bluetooth speaker, but you just press one button. In code, this means you can write a function that accepts a Shape and calls area() without knowing whether it is a Circle, Rectangle, or Triangle. This is the foundation of extensible design — new types can be added without modifying existing logic, which directly supports the Open/Closed Principle (the “O” in SOLID).

Method Overriding (Runtime Polymorphism)

class Shape(ABC):
    @abstractmethod
    def area(self) -> float:
        pass
    
    @abstractmethod
    def perimeter(self) -> float:
        pass

class Rectangle(Shape):
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height
    
    def area(self) -> float:
        return self.width * self.height
    
    def perimeter(self) -> float:
        return 2 * (self.width + self.height)

class Circle(Shape):
    def __init__(self, radius: float):
        self.radius = radius
    
    def area(self) -> float:
        return math.pi * self.radius ** 2
    
    def perimeter(self) -> float:
        return 2 * math.pi * self.radius

class Triangle(Shape):
    def __init__(self, a: float, b: float, c: float):
        self.a, self.b, self.c = a, b, c
    
    def area(self) -> float:
        # Heron's formula
        s = self.perimeter() / 2
        return math.sqrt(s * (s-self.a) * (s-self.b) * (s-self.c))
    
    def perimeter(self) -> float:
        return self.a + self.b + self.c

# Polymorphic function - works with ANY shape
def print_shape_info(shape: Shape):
    print(f"Area: {shape.area():.2f}")
    print(f"Perimeter: {shape.perimeter():.2f}")

def total_area(shapes: List[Shape]) -> float:
    return sum(shape.area() for shape in shapes)

# Usage
shapes = [
    Rectangle(10, 5),
    Circle(7),
    Triangle(3, 4, 5)
]

for shape in shapes:
    print_shape_info(shape)  # Same function, different behavior

print(f"Total area: {total_area(shapes)}")

Method Overloading (Compile-time Polymorphism)

Python doesn’t have true overloading, but you can achieve similar results:
from typing import overload, Union

class Calculator:
    # Using default arguments
    def add(self, a: float, b: float, c: float = 0) -> float:
        return a + b + c
    
    # Using *args
    def multiply(self, *args: float) -> float:
        result = 1
        for num in args:
            result *= num
        return result
    
    # Using Union types
    def double(self, value: Union[int, str, list]) -> Union[int, str, list]:
        if isinstance(value, int):
            return value * 2
        elif isinstance(value, str):
            return value + value
        elif isinstance(value, list):
            return value + value

calc = Calculator()
print(calc.add(1, 2))       # 3
print(calc.add(1, 2, 3))    # 6
print(calc.multiply(2, 3, 4))  # 24
print(calc.double(5))       # 10
print(calc.double("hi"))    # "hihi"

Composition vs Inheritance

This is one of the most important design decisions you will face in LLD interviews. The Gang of Four said it best: “Favor composition over inheritance.” Inheritance creates a rigid hierarchy — once you commit, changing the parent class ripples through every child. Composition is like assembling capabilities from interchangeable parts, giving you runtime flexibility. Use inheritance when the relationship is stable and truly “is-a”; use composition when you need to mix and match behaviors.
# Inheritance approach - rigid
class FlyingBird(Bird):
    def fly(self):
        print("Flying")

class SwimmingBird(Bird):
    def swim(self):
        print("Swimming")

# What about a duck that can fly AND swim? Multiple inheritance gets messy.

# Composition approach - flexible
class FlyBehavior(ABC):
    @abstractmethod
    def fly(self):
        pass

class SwimBehavior(ABC):
    @abstractmethod
    def swim(self):
        pass

class CanFly(FlyBehavior):
    def fly(self):
        print("Flying high!")

class CanSwim(SwimBehavior):
    def swim(self):
        print("Swimming gracefully!")

class Duck:
    def __init__(self):
        self.fly_behavior = CanFly()
        self.swim_behavior = CanSwim()
    
    def fly(self):
        self.fly_behavior.fly()
    
    def swim(self):
        self.swim_behavior.swim()

# Can even change behavior at runtime!
duck = Duck()
duck.fly()   # Flying high!
duck.fly_behavior = CantFly()  # Duck got injured
duck.fly()   # Can't fly anymore
Interview Tip: Be ready to explain trade-offs between inheritance and composition. “Favor composition over inheritance” is a common principle, but inheritance has its place for true “is-a” relationships.

Interview Deep-Dive

Strong Answer:
  • Encapsulation is about hiding data and controlling access. It operates at the implementation level. You make fields private and expose controlled methods (getters, setters, business methods) to protect invariants. The BankAccount class hides its balance field and only allows changes through deposit() and withdraw(), which enforce business rules.
  • Abstraction is about hiding complexity and defining contracts. It operates at the design level. You define what a PaymentProcessor does (process payment, issue refund) without specifying how any particular processor implements it. Abstraction uses interfaces and abstract classes to create a wall between “what” and “how.”
  • The practical difference: encapsulation prevents someone from setting account.balance = -1000 directly. Abstraction prevents someone from needing to know whether the payment goes through Stripe, PayPal, or Square.
  • A good mental model: encapsulation is the lock on your front door (controls who can change your stuff). Abstraction is the thermostat (you set a desired temperature without understanding the HVAC system behind it).
Follow-up: Can you have one without the other?Yes. You can have encapsulation without abstraction — a concrete class with private fields and public methods, but no interface or abstract class above it. You can also have abstraction without encapsulation — an interface that defines a contract, but the implementing class exposes all its internal state publicly. In practice, well-designed systems use both together: abstraction at the boundary between components, and encapsulation within each component.
Strong Answer:
  • The notification channel is a behavior that varies independently of the notification itself. A single notification might need to be sent via email AND SMS AND push simultaneously. With inheritance, you would need EmailSMSNotification, EmailPushNotification, SMSPushNotification — a combinatorial explosion of subclasses.
  • With composition, the Notification class holds a list of NotificationChannel objects (each implementing a send() method). At runtime, you can mix and match channels freely: add Slack, remove SMS, combine email with push. No new subclasses required.
  • This is the Strategy pattern applied to a collection. The Notification delegates sending to its channel strategies, and the set of channels is configured at runtime rather than baked into the class hierarchy.
  • The rule of thumb I follow: if the behavior varies independently and might be combined, use composition. If the relationship is a genuine identity (“a Dog is-a Animal” and will never need to also be a Vehicle), inheritance is fine.
Follow-up: When would you choose inheritance over composition in a real system?When there is a stable “is-a” hierarchy where subtypes genuinely specialize the parent and the base class is unlikely to change. The Shape hierarchy (Circle, Rectangle, Triangle all are-a Shape) is a classic example. Each subtype adds specific attributes (radius for Circle, width and height for Rectangle) and overrides area()/perimeter() with its own formula. The hierarchy is stable because the definition of “what a shape is” does not change. I would still use composition for any cross-cutting concerns like rendering strategy or serialization format.
Strong Answer:
  • This directly demonstrates the Open/Closed Principle. The Duck class is closed for modification (you do not edit the Duck class to change its flying behavior) but open for extension (you inject a different FlyBehavior implementation at runtime).
  • The design pattern is Strategy. The Duck holds a reference to a FlyBehavior interface, and the concrete behavior (CanFly or CantFly) is injected and can be swapped. The key is that the Duck delegates the “how to fly” decision to a separate object rather than encoding it in its own class hierarchy.
  • It also supports the Dependency Inversion Principle: Duck depends on the FlyBehavior abstraction, not on a concrete CanFly or CantFly class.
  • In a real-world scenario, this pattern appears everywhere: a payment processor whose payment method can be swapped, a report generator whose output format can be changed, or a game character whose weapon can be switched mid-game.
Follow-up: What happens if someone forgets to set the fly_behavior before calling fly()? How would you guard against that?This is a real production concern. I would address it in one of two ways. First, require the behavior in the constructor so Duck cannot be created without a FlyBehavior — this is the safest approach because it makes invalid states unrepresentable. Second, if the behavior truly must be optional, use a NullObject pattern: provide a default NoOpFlyBehavior that does nothing rather than crashing. Never let a None slip through. In Python specifically, I would use a type hint with no Optional and enforce it in init with a runtime check or use a dataclass with a required field.
Strong Answer:
  • Runtime polymorphism (method overriding) is when the actual method called is determined at runtime based on the object’s type. If you have a list of Shape objects and call area() on each, Python looks up the actual class (Circle, Rectangle, Triangle) at runtime and dispatches to the correct implementation. This is the foundation of extensible design.
  • Compile-time polymorphism (method overloading) is when the method to call is determined at compile time based on the parameter types or count. Languages like Java and C++ support this natively: you can define add(int, int) and add(double, double) as separate methods.
  • Python does not have true overloading because it is dynamically typed. When you define two methods with the same name in a class, the second definition simply overwrites the first. Python resolves method calls at runtime using the method name only, not the parameter signature.
  • In Python, you achieve similar results through default arguments, *args, **kwargs, Union types, or the functools.singledispatch decorator. The @overload decorator from typing is only for static type checkers — it does not create actual overloaded methods at runtime.
Follow-up: Why does this matter for LLD interviews?It matters because when you are designing class hierarchies in Python, you rely almost entirely on runtime polymorphism through abstract methods and method overriding. You cannot lean on overloading to create multiple constructors or method signatures the way you would in Java. This means your Python designs tend to use more pattern-based approaches: Builder for complex construction, Strategy for algorithm variation, and duck typing for informal polymorphism. Understanding this shapes how you present your design in the interview.

Interview Questions

Strong Answer:
  • They are technically correct. Python’s double-underscore name mangling only renames __balance to _BankAccount__balance. Anyone who knows this convention can access it directly: account._BankAccount__balance = 1000000. It is not enforced at the runtime level the way Java’s private is enforced by the JVM.
  • But calling it “security theater” misses the point. Encapsulation is not about security — it is about intent and maintainability. The double underscore is a strong signal to other developers: “This is an internal implementation detail. If you access it directly, you own the breakage when I refactor.” It is a contract, not a lock.
  • In production at scale, what matters is that all modifications go through controlled paths (deposit, withdraw) where business rules and logging are enforced. If someone bypasses that via name mangling, they have violated the contract and your audit trail breaks. This is caught by code review and linting rules (pylint flags direct access to mangled names), not by language enforcement.
  • The real-world analogy: a “Do Not Enter” sign on a door will not stop a burglar, but it will stop your coworkers from accidentally walking into the server room. That is what encapsulation does in Python — it prevents accidental misuse, not malicious abuse.
Red flag answer: “Python’s underscore convention is just as good as Java’s private keyword” or “It doesn’t matter because Python is not a real OOP language.” Both show a shallow understanding of the trade-off between convention-based and enforcement-based access control.Follow-ups:
  1. If you were building a financial system in Python where data integrity is critical, what additional mechanisms beyond name mangling would you use to protect invariants? (Testing for: knowledge of descriptors, __slots__, frozen dataclasses, runtime validation libraries like Pydantic, or even the argument for choosing a statically-typed language.)
  2. The get_statement() method returns self.__transactions.copy(). Why does it return a copy instead of the list itself, and what bug would occur if it returned the reference directly?
Strong Answer:
  • This is a textbook case where inheritance alone fails and you need Interface Segregation. If you add draw() to the Shape abstract class, HeadlessShape must implement it and either stub it out (ISP violation) or throw NotImplementedError (LSP violation). Both are design smells.
  • The clean solution is to separate the concerns: keep Shape as a pure geometric contract (area(), perimeter()), and create a separate Drawable interface with a draw() method. Classes like Circle can implement both Shape and Drawable, while HeadlessCircle implements only Shape.
  • Even better, use composition: create a ShapeRenderer that takes a Shape and a rendering strategy. The shape knows its geometry; the renderer knows how to draw. This way, the same Circle instance can be drawn on a canvas, exported to SVG, or used in a pure calculation context — without the Circle class changing at all.
  • This is the Strategy pattern combined with ISP, and it mirrors how real frameworks work. Matplotlib separates Figure (data) from Backend (rendering). React separates component logic from the renderer (ReactDOM vs React Native).
Red flag answer: “Just add draw() to Shape and make HeadlessShape raise NotImplementedError” or “Use a boolean flag is_drawable to decide whether to call draw().” Both approaches couple unrelated concerns and violate LSP or OCP.Follow-ups:
  1. If you went with composition and a ShapeRenderer, how would you handle a shape that needs different rendering on screen versus PDF export without creating a combinatorial explosion of classes?
  2. Python supports multiple inheritance and mixins. Would you use a DrawableMixin here instead of composition, and what are the trade-offs?
Strong Answer:
  • This is the fragile base class problem. When PluginHybridCar is inserted between Car and ElectricCar, the super() call in ElectricCar.drive() no longer goes to Car.drive() — it goes to PluginHybridCar.drive(), which may have its own fuel-consumption logic that conflicts with the electric charge calculation. The MRO (Method Resolution Order) has silently changed, and the behavior breaks without any code in ElectricCar being modified.
  • In Python specifically, super() follows the C3 linearization algorithm for MRO. With deep hierarchies, the order of method calls becomes non-obvious. You can inspect it with ElectricCar.__mro__, but requiring developers to check MRO before making changes is a sign the hierarchy is too fragile.
  • Prevention strategies: First, keep hierarchies shallow — two levels is fine, three is a warning sign, four is almost certainly wrong. Second, favor composition: instead of ElectricCar extending Car, have a Vehicle class that holds a Drivetrain interface (ElectricDrivetrain, HybridDrivetrain, CombustionDrivetrain). Third, if you must use inheritance, make the parent methods final (in Java) or document them with a clear “do not override” contract and enforce it with tests.
  • The Gang of Four warned about this in 1994: “Favor composition over inheritance.” The ElectricCar example is simple enough to work, but the moment you add PluginHybrid, the hierarchy needs to be flattened into a composition-based design.
Red flag answer: “Just make sure the MRO is correct” or “That is why you should always use super() everywhere.” Both answers treat the symptom (wrong dispatch order) rather than the disease (fragile hierarchy depth).Follow-ups:
  1. How does Python’s MRO (C3 linearization) differ from C++‘s multiple inheritance resolution, and why does Python’s approach prevent the diamond problem?
  2. If you were refactoring this Vehicle hierarchy from inheritance to composition mid-project with 50 existing subclasses, what would your migration strategy look like?
Strong Answer:
  • The biggest risk is type safety. Nothing prevents someone from writing duck.fly_behavior = "hello" or duck.fly_behavior = 42. When duck.fly() is called later, it will crash with an AttributeError at runtime, potentially deep in a call stack where the root cause is hard to trace. The bug is introduced on one line but manifests on a completely different line, possibly in a different module.
  • A second risk is the open assignment window. Between creating the Duck and assigning a valid fly_behavior, the object is in an invalid state. If any code calls duck.fly() during that window, you get a crash. This is the “temporal coupling” antipattern.
  • To make it safer in Python, I would use a property with a setter that validates the type: @fly_behavior.setter that checks isinstance(value, FlyBehavior) and raises TypeError if it fails. I would also require the behavior in __init__ so a Duck cannot be created without one.
  • For maximum safety, I would use a Protocol from typing instead of an ABC, which enables structural subtyping (duck typing, appropriately enough). Any object with a fly() method matches the protocol, but static type checkers like mypy will catch mismatches before runtime.
  • In production at Uber or Stripe, you would see this pattern wrapped in a set_behavior() method that logs the change, validates the input, and possibly emits a metric. Direct attribute assignment is fine for tutorials but dangerous in production where auditability matters.
Red flag answer: “Python is dynamically typed so there is nothing you can do about it” or “Just add a comment saying not to change it.” The first is defeatist; the second is wishful thinking.Follow-ups:
  1. What is the difference between Python’s ABC (abstract base class) and Protocol for defining the FlyBehavior contract, and when would you choose one over the other?
  2. If the Duck has 5 different swappable behaviors (fly, swim, quack, eat, sleep), how do you prevent the constructor from becoming a 5-parameter monster?
Strong Answer:
  • This is a subtle but critical violation of the Liskov Substitution Principle — not at the type level, but at the behavioral contract level. The implicit contract of area() is that it returns quickly. FractalShape honors the method signature but violates the performance expectation. Any code that iterates over a List[Shape] and calls area() assumes roughly uniform cost.
  • The immediate fix is to separate the contract. Add a CachedShape wrapper or require that area() always returns a precomputed value. The expensive computation happens in a compute_area() method that is called explicitly during initialization or in a background process, and area() simply returns the cached result.
  • A more robust design uses the concept of “cost-aware interfaces.” You can add an is_expensive() method to Shape, or better yet, separate Shape into Shape (fast, precomputed) and ComputedShape (may be slow, needs explicit invocation). The total_area() function only accepts Shape, forcing callers to precompute before passing in.
  • In production, this maps to real problems. At Netflix, a “simple” method like getRecommendations() might be backed by a quick cache lookup for most users but a 2-second ML inference for cold-start users. The solution is always the same: make performance characteristics explicit in the interface, never hide latency behind a uniform abstraction.
Red flag answer: “Just make area() async” (does not solve the fundamental contract problem) or “Add a timeout” (treats the symptom, not the design issue).Follow-ups:
  1. How would you design the Shape interface differently if you knew upfront that some implementations would be computationally expensive? Would you use async, lazy evaluation, or something else?
  2. This scenario reveals that LSP is about more than just types — it is about behavioral contracts. Can you give another example where a subclass honors the type signature but violates the behavioral contract?
Strong Answer:
  • The core distinction is shared implementation. An abstract class (ABC in Python) is appropriate when you have a family of related types that share some concrete behavior. The abstract class provides the shared implementation and forces subclasses to implement the parts that vary. An interface (Protocol in Python) is appropriate when you want to define a capability contract without any shared implementation, and especially when unrelated types might share that capability.
  • Concrete example for ABC: PaymentProcessor. All payment processors need to validate the amount (shared logic), but each processes payment differently (Stripe vs PayPal vs Square). The ABC has a concrete validate_amount() method and an abstract process_payment() method. This avoids duplicating the validation logic across every subclass.
  • Concrete example for Protocol: Serializable. A User, an Order, and a LogEntry are completely unrelated types, but they all need a to_json() method. An ABC would force them into a fake inheritance hierarchy. A Protocol says “any object with a to_json() method satisfies this contract,” enabling structural subtyping without coupling.
  • In Python specifically, Protocol has the additional advantage of working with third-party classes you do not control. If a library’s class happens to have a to_json() method, it satisfies your Serializable protocol without the library needing to explicitly inherit from your ABC. ABCs require explicit registration or inheritance.
  • The rule I follow: if I am modeling a family of related things, use ABC. If I am modeling a capability that crosses family boundaries, use Protocol.
Red flag answer: “They are the same thing, just use whichever” or “Always use abstract classes because they are more traditional.” Both miss the fundamental distinction between shared implementation and structural contracts.Follow-ups:
  1. Java has both abstract classes and interfaces (with default methods since Java 8). How does that change the decision compared to Python where we have ABC and Protocol?
  2. You mentioned third-party classes satisfying a Protocol. Can you walk me through a real scenario where this retroactive conformance saved you from writing adapter code?
Strong Answer:
  • This is a classic abuse of multiple inheritance. list and dict have conflicting semantics for fundamental operations: __contains__ checks values in list but checks keys in dict. __iter__ yields values in list but yields keys in dict. len() counts elements in list but counts key-value pairs in dict. The resulting ListDict has ambiguous behavior and will confuse every consumer.
  • Python’s MRO will “resolve” the conflict by picking one parent’s method over the other based on the order of inheritance (class ListDict(list, dict) vs class ListDict(dict, list)), but the resolution is arbitrary from the domain perspective. The developer will spend hours debugging why iteration behaves like a list in one case and like a dict in another.
  • The correct approach is composition: create a class that contains a list and a dict internally, and explicitly defines which operations delegate to which backing store. This makes the semantics unambiguous: get_by_key() uses the dict, get_by_index() uses the list.
  • An even better question to ask the junior is: “What problem are you actually solving?” Often, the need for a ListDict indicates a missing data structure. Python’s OrderedDict preserves insertion order while providing key-based lookup. collections.namedtuple or a dataclass might be what they really need. The design conversation should start with the use case, not the implementation mechanism.
Red flag answer: “Multiple inheritance is fine in Python, just use super() correctly” or “Just pick one parent to inherit from and copy methods from the other.” Both sidestep the fundamental design issue.Follow-ups:
  1. Python’s MRO uses C3 linearization. If you had class A(B, C) and both B and C define __len__, which one wins and why? How would you determine this without running the code?
  2. When IS multiple inheritance appropriate in Python? Can you give an example of a mixin that works well and explain why it does not have the same problems as ListDict?
Strong Answer:
  • Composition, without question, and this is one of those cases where the answer is clear-cut. A logger’s output destinations vary independently of its core behavior. You might want to log to console AND file simultaneously, or switch from file to remote server in production. Inheritance cannot model this: you would need ConsoleLogger, FileLogger, ConsoleAndFileLogger, ConsoleAndFileAndRemoteLogger — a combinatorial explosion.
  • The design: Logger holds a list of LogHandler objects, each implementing a handle(log_entry) method. ConsoleHandler, FileHandler, RemoteHandler are concrete implementations. Logger iterates through its handlers and delegates. This is the Observer pattern applied to logging, and it is exactly how Python’s built-in logging module works: you add handlers to a logger, each with its own formatter and filter.
  • The key insight is that Logger and LogHandler have different rates of change. Logger’s core logic (formatting, filtering, level checking) is stable. Handlers change frequently: new destinations are added, existing ones are reconfigured. SRP says things that change for different reasons should be separate classes.
  • Where inheritance IS appropriate: the handler hierarchy itself. FileHandler and RotatingFileHandler have a genuine is-a relationship. RotatingFileHandler is a FileHandler that adds rotation logic. The base class is stable, the specialization is genuine, and the hierarchy is shallow (two levels).
Red flag answer: “Use inheritance because Logger, ConsoleLogger, FileLogger is a natural hierarchy” or “It depends on the requirements.” The first misses the combinatorial problem; the second is an evasion.Follow-ups:
  1. Python’s logging module uses both inheritance (Handler -> StreamHandler -> FileHandler) and composition (Logger has Handlers). Why does it use both instead of committing to one approach?
  2. If the remote LogHandler makes network calls, how do you prevent a slow network from blocking your application’s main thread? What pattern would you use?