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.
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.
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.
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.
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.
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.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.YAGNI - You Aren’t Gonna Need It
Don’t add functionality until you actually need it.
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.
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
Design Patterns
Creational Patterns
Singleton - One instance only
Singleton - One instance only
Factory - Create objects without specifying exact class
Factory - Create objects without specifying exact class
Builder - Construct complex objects step by step
Builder - Construct complex objects step by step
Structural Patterns
Adapter - Make incompatible interfaces work together
Adapter - Make incompatible interfaces work together
Decorator - Add behavior without modifying class
Decorator - Add behavior without modifying class
Facade - Simplified interface to complex subsystem
Facade - Simplified interface to complex subsystem
Behavioral Patterns
Strategy - Interchangeable algorithms
Strategy - Interchangeable algorithms
Observer - Notify multiple objects of state changes
Observer - Notify multiple objects of state changes
Command - Encapsulate requests as objects
Command - Encapsulate requests as objects
Clean Code Practices
Meaningful Names
- Use intention-revealing names
- Avoid abbreviations (use
customerAddressnotcustAddr) - 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
Principles Cheat Sheet
Interview Deep-Dive
A colleague submits a PR where a single class handles user validation, database persistence, email sending, and audit logging. They argue it is simpler to have everything in one place. How do you review this?
A colleague submits a PR where a single class handles user validation, database persistence, email sending, and audit logging. They argue it is simpler to have everything in one place. How do you review this?
- 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.
Give me a real example where you would intentionally violate the DRY principle. Why would duplicating code be the right call?
Give me a real example where you would intentionally violate the DRY principle. Why would duplicating code be the right call?
- 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.
Compare the Strategy pattern and the Template Method pattern. When would you use each, and what are the pitfalls of choosing wrong?
Compare the Strategy pattern and the Template Method pattern. When would you use each, and what are the pitfalls of choosing wrong?
- 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.
add(index, element) and sort() that violate stack semantics. Any code receiving an ArrayList can receive your Stack and use it in ways that break the LIFO invariant — a Liskov Substitution violation. My recommendation: use composition instead. The Stack should CONTAIN a list as a private field and expose only push(), pop(), and peek(). This gives you the code reuse without the false type relationship and without exposing methods that violate your abstraction. The rule of thumb: inherit to establish a behavioral contract (polymorphism), compose to reuse implementation.You are building a notification system that today supports email, SMS, and push notifications. The product roadmap shows Slack, WhatsApp, and in-app notifications coming in the next two quarters. How do you design this using SOLID principles?
You are building a notification system that today supports email, SMS, and push notifications. The product roadmap shows Slack, WhatsApp, and in-app notifications coming in the next two quarters. How do you design this using SOLID principles?
- This is a textbook case for the Open/Closed Principle combined with Strategy pattern. I would define a
NotificationChannelinterface with a singlesend(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
NotificationServicedepends on theNotificationChannelabstraction, not onEmailSenderorSmsSenderdirectly. 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 aNotificationPayloadthat 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
NotificationServiceorchestrates (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.
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.