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.

Overview

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.

SOLID Principles

S - Single Responsibility Principle

A class should have only one reason to change.
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 = email

class UserRepository:
    """Knows HOW to persist users. One reason to change: storage concerns."""
    def save(self, user):
        pass

class EmailService:
    """Knows HOW to send emails. One reason to change: communication concerns."""
    def send(self, user, message):
        pass

O - Open/Closed Principle

Open for extension, closed for modification.
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 shapes
class 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 modifying
from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        pass

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius
    
    def area(self):
        return 3.14 * self.radius ** 2

class 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 interface
class Triangle(Shape):
    def __init__(self, base, height):
        self.base = base
        self.height = height
    
    def area(self):
        return 0.5 * self.base * self.height

L - Liskov Substitution Principle

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 = height

class 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 abstractions
class Shape(ABC):
    @abstractmethod
    def area(self):
        pass

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height
    
    def area(self):
        return self.width * self.height

class Square(Shape):
    def __init__(self, side):
        self.side = side
    
    def area(self):
        return self.side ** 2

I - Interface Segregation Principle

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.
# ❌ Bad: Fat interface
class Worker(ABC):
    @abstractmethod
    def work(self):
        pass
    
    @abstractmethod
    def eat(self):
        pass
    
    @abstractmethod
    def sleep(self):
        pass

class Robot(Worker):
    def work(self):
        print("Working...")
    
    def eat(self):
        pass  # Robots don't eat!
    
    def sleep(self):
        pass  # Robots don't sleep!

# ✅ Good: Segregated interfaces
class Workable(ABC):
    @abstractmethod
    def work(self):
        pass

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

class Human(Workable, Eatable):
    def work(self):
        print("Working...")
    
    def eat(self):
        print("Eating...")

class Robot(Workable):
    def work(self):
        print("Working...")

D - Dependency Inversion Principle

Depend on abstractions, not concretions.
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):
        pass

class 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

Other Important Principles

DRY - Don’t Repeat Yourself

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 bonus

def 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_service

def calculate_employee_bonus(employee):
    return calculate_base_bonus(employee)

def calculate_manager_bonus(manager):
    return calculate_base_bonus(manager) + 5000

KISS - Keep It Simple, Stupid

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]

YAGNI - You Aren’t Gonna Need It

Don’t add functionality until you actually need it.
# ❌ Bad: Building for hypothetical future
class User:
    def __init__(self, name):
        self.name = name
        self.middle_name = None
        self.suffix = None
        self.nickname = None
        self.avatar_url = None
        self.theme_preference = None
        self.notification_settings = {}
        # 20 more fields "just in case"

# ✅ Good: Start minimal, add when needed
class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email

Composition Over Inheritance

Favor object composition over class inheritance.
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.
# ❌ Bad: Deep inheritance hierarchy
class Animal:
    def move(self): pass

class Bird(Animal):
    def fly(self): pass

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

# ✅ Good: Composition with behaviors
class FlyBehavior:
    def fly(self):
        print("Flying!")

class WalkBehavior:
    def walk(self):
        print("Walking!")

class Bird:
    def __init__(self, fly_behavior=None, walk_behavior=None):
        self.fly_behavior = fly_behavior
        self.walk_behavior = walk_behavior

# Penguin walks but doesn't fly
penguin = Bird(walk_behavior=WalkBehavior())

# Eagle flies and walks
eagle = Bird(fly_behavior=FlyBehavior(), walk_behavior=WalkBehavior())

Law of Demeter (Principle of Least Knowledge)

A method should only call methods on:
  • Its own object
  • Objects passed as parameters
  • Objects it creates
  • Its direct component objects
# ❌ Bad: Train wreck - reaching through objects
def get_city(order):
    return order.get_customer().get_address().get_city()

# ✅ Good: Ask, don't reach
class Order:
    def get_shipping_city(self):
        return self.customer.get_shipping_city()

class Customer:
    def get_shipping_city(self):
        return self.address.city

def get_city(order):
    return order.get_shipping_city()

Design Patterns

Creational Patterns

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
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, abstractmethod

class Notification(ABC):
    @abstractmethod
    def send(self, message): pass

class 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!")
class QueryBuilder:
    def __init__(self):
        self._select = "*"
        self._from = ""
        self._where = []
        self._order_by = None
        self._limit = None
    
    def select(self, columns):
        self._select = columns
        return self
    
    def from_table(self, table):
        self._from = table
        return self
    
    def where(self, condition):
        self._where.append(condition)
        return self
    
    def order_by(self, column):
        self._order_by = column
        return self
    
    def limit(self, n):
        self._limit = n
        return self
    
    def build(self):
        query = f"SELECT {self._select} FROM {self._from}"
        if self._where:
            query += " WHERE " + " AND ".join(self._where)
        if self._order_by:
            query += f" ORDER BY {self._order_by}"
        if self._limit:
            query += f" LIMIT {self._limit}"
        return query

# Fluent interface
query = (QueryBuilder()
    .select("name, email")
    .from_table("users")
    .where("status = 'active'")
    .where("age > 18")
    .order_by("created_at DESC")
    .limit(10)
    .build())

Structural Patterns

# Legacy XML service
class LegacyXMLParser:
    def parse_xml(self, xml_string):
        return {"data": "parsed from XML"}

# Modern code expects JSON
class JSONAdapter:
    def __init__(self, xml_parser):
        self.xml_parser = xml_parser
    
    def parse_json(self, json_string):
        # Convert JSON to XML, parse, return result
        xml_data = self._json_to_xml(json_string)
        return self.xml_parser.parse_xml(xml_data)
    
    def _json_to_xml(self, json_string):
        return "<xml>converted</xml>"

# Usage
legacy_parser = LegacyXMLParser()
adapter = JSONAdapter(legacy_parser)
result = adapter.parse_json('{"key": "value"}')
from functools import wraps
import time

def timing_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        print(f"{func.__name__} took {time.time() - start:.2f}s")
        return result
    return wrapper

def retry_decorator(max_attempts=3):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts - 1:
                        raise
                    print(f"Attempt {attempt + 1} failed, retrying...")
        return wrapper
    return decorator

@timing_decorator
@retry_decorator(max_attempts=3)
def fetch_data(url):
    # Simulate API call
    return {"data": "fetched"}
class VideoConverter:
    def convert(self, filename, format):
        print(f"Converting {filename} to {format}")

class AudioExtractor:
    def extract(self, video):
        print(f"Extracting audio from {video}")

class ThumbnailGenerator:
    def generate(self, video):
        print(f"Generating thumbnail for {video}")

class UploadService:
    def upload(self, file, platform):
        print(f"Uploading {file} to {platform}")

# Facade simplifies the complex workflow
class VideoPublishingFacade:
    def __init__(self):
        self.converter = VideoConverter()
        self.audio = AudioExtractor()
        self.thumbnail = ThumbnailGenerator()
        self.uploader = UploadService()
    
    def publish(self, video_file, platform):
        self.converter.convert(video_file, "mp4")
        self.audio.extract(video_file)
        self.thumbnail.generate(video_file)
        self.uploader.upload(video_file, platform)

# Simple usage
facade = VideoPublishingFacade()
facade.publish("my_video.mov", "youtube")

Behavioral Patterns

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, abstractmethod

class PaymentStrategy(ABC):
    @abstractmethod
    def pay(self, amount): pass

class 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)
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)

# Usage
emitter = 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"})
from abc import ABC, abstractmethod

class Command(ABC):
    @abstractmethod
    def execute(self): pass
    
    @abstractmethod
    def undo(self): pass

class AddTextCommand(Command):
    def __init__(self, document, text):
        self.document = document
        self.text = text
    
    def execute(self):
        self.document.content += self.text
    
    def undo(self):
        self.document.content = self.document.content[:-len(self.text)]

class Document:
    def __init__(self):
        self.content = ""
        self.history = []
    
    def execute(self, command):
        command.execute()
        self.history.append(command)
    
    def undo(self):
        if self.history:
            command = self.history.pop()
            command.undo()

# Usage with undo support
doc = Document()
doc.execute(AddTextCommand(doc, "Hello "))
doc.execute(AddTextCommand(doc, "World!"))
print(doc.content)  # "Hello World!"

doc.undo()
print(doc.content)  # "Hello "

Clean Code Practices

Meaningful Names

  • Use intention-revealing names
  • Avoid abbreviations (use customerAddress not custAddr)
  • Be consistent (don’t mix get, fetch, retrieve)
  • Use domain vocabulary

Small Functions

  • Do one thing well
  • Few parameters (≤3, use objects for more)
  • No side effects (pure functions)
  • Single level of abstraction

Comments

  • Code should be self-documenting
  • Comment WHY, not WHAT
  • Keep comments updated (stale comments are worse than none)
  • Use for legal, warnings, TODOs

Error Handling

  • Use exceptions, not error codes
  • Provide context in error messages
  • Don’t return null (use Optional/Maybe)
  • Fail fast

Code Smells to Avoid

SmellDescriptionFix
Long MethodFunction > 20 linesExtract smaller functions
God ClassClass does too muchSplit into focused classes
Feature EnvyMethod uses other class’s data moreMove method to that class
Primitive ObsessionUsing primitives instead of objectsCreate value objects
Magic NumbersUnexplained numeric literalsUse named constants
Duplicate CodeSame code in multiple placesExtract to shared function
Deep NestingMany levels of if/forExtract, early return, guard clauses
# ❌ 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.

Principles Cheat Sheet

PrincipleOne-linerWhen to Apply
SRPOne reason to changeClass has multiple unrelated methods
OCPExtend, don’t modifyAdding features requires changing existing code
LSPSubtypes substitutableSubclass can’t fulfill parent’s contract
ISPSmall, focused interfacesClass implements methods it doesn’t need
DIPDepend on abstractionsHigh-level depends on low-level directly
DRYDon’t repeat yourselfSame logic in multiple places
KISSKeep it simpleOver-engineered solution
YAGNIBuild what you need nowBuilding for hypothetical future
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.”

Interview Deep-Dive

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.
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.
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.
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.