SOLID, DRY, and KISS
SOLID, DRY, and KISS are foundational principles that guide how code should be structured and evolve over time. They are not checklists or patterns to blindly follow. Used correctly, they reduce coupling, control complexity, and make change safer. Used incorrectly, they can over-engineer simple systems. Understanding why they exist matters more than memorizing their definitions.
SOLID
SOLID is a set of five principles that promote maintainable and extensible design.
Single Responsibility Principle (SRP)
A unit of code should have only one responsibility and, therefore, only one reason to change. This responsibility may span multiple methods or files, but it should represent a single, cohesive concern at a consistent level of abstraction. By keeping responsibilities focused, SRP reduces the impact of changes, making code easier to understand, maintain, and evolve.
Poor
class UserService {
authenticate() {}
sendWelcomeEmail() {}
logLogin() {}
}Better
class AuthService {
authenticate() {}
}
class EmailService {
sendWelcome() {}
}Open/Closed Principle (OCP)
Code should be open for extension but closed for modification. New functionality should be introduced by extending existing components rather than altering proven, working code. This minimizes the need for rewrites, reduces regression risk, and makes systems easier to evolve over time.
Poor
if (type === "A") { ... }
else if (type === "B") { ... }Better
interface Handler {
handle(): void;
}
handlers[type].handle();Liskov Substitution Principle (LSP)
Subtypes should be substitutable for their base types without altering the correctness of the program. A derived type must honor the behavior and expectations established by its parent, avoiding unexpected exceptions, inconsistent behavior, or broken assumptions. This ensures polymorphism remains reliable and safe throughout the system.
Poor
class ReadOnlyFile extends File {
write() {
throw new Error("Not supported");
}
}Better
If it cannot behave like a File, it should not extend it.Interface Segregation Principle (ISP)
Clients should not be forced to depend on methods they do not use. Interfaces should be small, focused, and tailored to the needs of their consumers rather than exposing unrelated functionality. This reduces unnecessary coupling, improves flexibility, and makes systems easier to maintain and evolve.
Poor
interface Machine {
print();
scan();
fax();
}Better
interface Printer {
print();
}
interface Scanner {
scan();
}Dependency Inversion Principle (DIP)
High-level modules, which contain business rules and application logic, should not depend directly on low-level modules, such as databases, file systems, APIs, or framework-specific implementations. Instead, both should depend on abstractions that define the required behavior. This reduces coupling and allows infrastructure components to change without impacting core business logic, making the system more flexible, testable, and maintainable.
Poor
class PaymentService {
constructor(private db: MySQLClient) {}
}Better
interface PaymentRepository {
save(payment: Payment): void;
}
class PaymentService {
constructor(private repo: PaymentRepository) {}
}DRY (Don't Repeat Yourself)
DRY (Don't Repeat Yourself) is about maintaining a single source of truth, not eliminating every instance of duplication. Duplication becomes a problem when the same business rule is implemented in multiple places, requiring repeated updates and creating opportunities for behavior to diverge. However, introducing abstractions too early can add unnecessary complexity. It's often better to tolerate some duplication until it clearly represents a shared concept rather than a temporary coincidence. The goal is to centralize knowledge, not merely reduce lines of code.
DRY Example
Violation of DRY
The same business rule (tax calculation) is repeated in multiple places:
public double calculateOrderTotal(double amount) {
return amount + (amount * 0.18);
}
public double calculateInvoiceTotal(double amount) {
return amount + (amount * 0.18);
}If the tax rate changes from 18% to 20%, both methods must be updated. Missing one update can introduce inconsistent behavior.
Applying DRY
Extract the shared business rule into a single source of truth:
public class TaxCalculator {
private static final double TAX_RATE = 0.18;
public static double applyTax(double amount) {
return amount + (amount * TAX_RATE);
}
}
public double calculateOrderTotal(double amount) {
return TaxCalculator.applyTax(amount);
}
public double calculateInvoiceTotal(double amount) {
return TaxCalculator.applyTax(amount);
}Why this is DRY: The tax calculation logic exists in one place. When the business rule changes, only the TaxCalculator needs to be updated, reducing maintenance effort and preventing inconsistencies.
KISS (Keep It Simple)
KISS (Keep It Simple, Stupid) emphasizes solving problems with the simplest solution that meets current requirements. Unnecessary complexity often arises from over-abstraction, over-engineering, or designing for hypothetical future needs. Simple code is easier to understand, test, debug, and maintain, making it more resilient to change over time. The goal is not to oversimplify, but to avoid complexity that provides no immediate value.
KISS Example
Over-engineered (not KISS)
This version introduces unnecessary abstraction for a simple rule:
interface Calculator {
double calculate(double amount);
}
class TaxStrategy implements Calculator {
public double calculate(double amount) {
return amount * 0.18;
}
}
class TaxContext {
private Calculator calculator;
public TaxContext(Calculator calculator) {
this.calculator = calculator;
}
public double execute(double amount) {
return calculator.calculate(amount);
}
}For a fixed tax rate, this design adds complexity without real benefit.
KISS version (simple and direct)
public double applyTax(double amount) {
return amount + (amount * 0.18);
}Why this is KISS
- No unnecessary interfaces or classes
- Easy to read and debug
- Directly solves the current requirement
- Avoids designing for hypothetical future extensions
KISS favors the simplest solution that works today, not the most flexible design imaginable.
Naming and Readability
Naming and readability are about making code self-explanatory so that intent is clear without needing extra context. Good names express purpose, not implementation details, and reduce the mental effort required to understand what the code is doing. Readable code favors clarity over cleverness, consistent conventions over ad-hoc style, and meaningful structure over unnecessary brevity. Together, they make systems easier to maintain, review, and extend because the code communicates its own behavior.
Variable naming
Poor
data, temp, value, flagBetter
invoiceSummary, retryCount, isEmailVerified, checkoutSessionWhy it matters: Concrete names remove guesswork and reduce the need to inspect surrounding code for meaning.
Function naming
Poor
process(), handle(), run(), update()Better
sendWelcomeEmail(), calculateRefundTotal(), markOrderAsRefunded()Why it matters: Verb-first names communicate behavior and intent immediately.
Comments vs naming
Poor
// check if the user can log in
if (u.a && !u.b) { ... }Better
if (user.isActive && !user.isBanned) { ... }Why it matters: When names explain the rule, comments can focus on non-obvious trade-offs instead of restating the code.
Structuring Code That Scales
Code structure determines how easily a system can be understood, changed, and trusted over time. Well-structured code reduces cognitive load, limits unintended side effects, and allows teams to move quickly without breaking existing behavior. Poor structure does the opposite, even if individual functions are clean.
Below are the core principles of effective code structure, each explained with concrete examples.
1. Structure Around the Problem Domain
Code should be organized around what the system does (its business capabilities), not the technical layers or frameworks used to build it.
Poor structure (organized by technical layers)
/controllers
/services
/repositories
/modelsThis approach scatters related functionality across folders, forcing developers to jump between multiple places to understand or change a single feature.
Better structure (organized by domain/feature)
/payments
├── payment.ts
├── refund.ts
├── payment_service.tsHere, everything related to payments is grouped together, making the feature easier to navigate, understand, and modify.
2. Clear Ownership and Responsibilities
Each piece of behavior and state change should have a single, well-defined owner, so responsibility is not scattered across multiple modules.
Poor design (unclear ownership)
// order_service.ts
order.status = "REFUNDED";
// payment_service.ts
order.status = "REFUNDED";Here, multiple services directly modify the same state, making it unclear who is responsible for enforcing rules around refunds. This can lead to inconsistent updates and hidden side effects.
Better design (clear ownership)
class Order {
refund() {
this.status = "REFUNDED";
}
}Now, the Order class owns its state transitions, and external services request behavior rather than directly mutating state.
3. Files Should Read Top to Bottom
A file should present its intent clearly at the top, so readers can understand its purpose without scanning unrelated details.
Poor structure (mixed intent and implementation)
function calculateTax() {}
export function refund() {}
function formatCurrency() {}
export function createInvoice() {}Here, public APIs and internal helpers are mixed together, making it harder to quickly understand what the file actually provides.
Better structure (intent first, details later)
export function createInvoice() {}
export function refund() {}
// internal helpers
function calculateTax() {}
function formatCurrency() {}Now the most important behavior is visible immediately, while implementation details are placed lower in the file.
Why this matters When a file reads top to bottom in order of importance, readers can grasp its purpose quickly. This reduces cognitive load and makes navigation easier, especially in large codebases where scanning speed matters.
4. File and Class Size Are Design Signals
Large classes or files often indicate that multiple responsibilities have been grouped together, making the code harder to maintain and reason about.
Poor design (multiple responsibilities in one class)
class UserService {
authenticate() {}
sendEmail() {}
generateReport() {}
exportCSV() {}
}This class mixes authentication, communication, and reporting logic, creating unclear ownership and increasing the risk of unintended side effects when changes are made.
Better design (focused responsibilities)
class AuthService {
authenticate() {}
}
class ReportService {
generate() {}
exportCSV() {}
}Each class now has a single, well-defined responsibility, making its purpose clearer and its behavior easier to isolate.
Why this matters Smaller, focused units of code are easier to test, reuse, and modify safely. They also reduce cognitive load by making it easier to understand what each part of the system is responsible for.
5. Control Dependency Direction
High-level business logic should depend on abstractions, not concrete infrastructure details.
Poor design (high-level depends on low-level implementation)
class PaymentService {
constructor(private db: MySQLClient) {}
}Here, the PaymentService is tightly coupled to a specific database technology. Changing the database would force changes in business logic.
Better design (depends on abstraction)
interface PaymentRepository {
save(payment: Payment): void;
}
class PaymentService {
constructor(private repo: PaymentRepository) {}
}Now the service depends on a contract rather than a concrete implementation, allowing the underlying storage to be swapped without affecting business logic.
Why this matters When dependency direction is controlled through abstractions, infrastructure changes (like switching databases or external services) do not ripple into core business logic. This keeps the system more flexible, testable, and stable over time.
6. Prefer Explicit Dependencies
Hidden or implicit dependencies make code harder to reason about and more fragile during change.
Poor design (hidden coupling through globals or shared state)
function chargeUser() {
gateway.charge(globalUser.card);
}Here, chargeUser depends on external global state. This makes behavior unclear and tightly couples the function to ambient context.
Better design (explicit dependencies)
function chargeUser(user: User, gateway: Gateway) {
gateway.charge(user.card);
}Now all required inputs are clearly provided, and the function’s behavior is fully defined by its parameters.
Why this matters Explicit dependencies make code easier to understand, test, and reuse. When inputs are visible, behavior becomes predictable, and the system avoids hidden coupling that can lead to unexpected side effects.
7. Avoid Generic Utility Dumping Grounds
Folders like utils or helpers often become catch-all locations with no clear ownership, mixing unrelated logic and losing domain meaning.
Poor design (generic utility dumping ground)
/utils
├── date.ts
├── string.ts
├── payment.tsThis structure groups unrelated concerns together. Over time, it becomes unclear where logic belongs or who “owns” it, making reuse and maintenance harder.
Better design (domain-oriented organization)
/payments
├── payment_formatter.ts
/orders
├── order_dates.tsHere, utilities live close to the domain they support, making their purpose and usage clearer.
Why this matters When helper code is placed within its domain context, it becomes easier to understand, safer to modify, and less likely to be misused elsewhere. It also encourages better ownership and prevents uncontrolled growth of generic utility buckets.
8. Make the Main Flow Obvious
The primary execution path should be easy to read, with edge cases handled early to avoid deep nesting.
Poor design (deep nesting hides intent)
if (user) {
if (user.active) {
if (!user.banned) {
process(user);
}
}
}The main action is buried inside multiple layers of conditions, making it harder to quickly understand the core behavior.
Better design (guard clauses and early exits)
if (!user || !user.active || user.banned) {
return;
}
process(user);Here, invalid cases are handled upfront, leaving the main flow clean and linear.
Why this matters Flat, linear code reduces cognitive load and makes logic easier to follow. By handling edge cases early, the primary intent of the function becomes obvious, lowering the chance of mistakes and improving readability.
9. Tests Reflect Structure Quality
Well-structured code naturally leads to simple, focused tests. If testing is difficult, it often signals that the design is too coupled or unclear.
Poor design (over-coupled, hard to test)
// requires mocking db, cache, logger, email service
test("refund", () => {
...
});Here, testing the refund behavior requires setting up multiple dependencies, making the test brittle and hard to maintain.
Better design (simple, behavior-focused test)
test("refund updates order status", () => {
const order = new Order();
order.refund();
expect(order.status).toBe("REFUNDED");
});The test focuses on observable behavior without needing external systems or heavy mocking.
Why this matters When code is well-structured, tests become straightforward expressions of behavior rather than complex setup exercises. This improves test clarity, reduces maintenance cost, and provides faster feedback when changes are made.
10. Design for Change
Systems should be structured so that new behavior can be added without rewriting existing logic.
Poor design (requires modifying existing conditionals)
if (type === "A") {
...
} else if (type === "B") {
...
}Every new type requires editing this block, increasing the risk of breaking existing behavior and creating long, fragile condition chains.
Better design (open for extension)
interface Handler {
handle(): void;
}
handlers[type].handle();Now each behavior is encapsulated in its own handler, and new types can be added without changing existing logic.
Why this matters When design supports extension over modification, systems evolve more safely. New features can be added in isolation, reducing regression risk and keeping core logic stable over time.
Common Code Smells and What to Do Instead
As a senior software engineer, I've seen systems degrade not because of poor algorithms or infrastructure choices, but due to accumulated maintainability debt. Code smells give us a shared language to detect these issues early, before they turn into production incidents, slow onboarding, or costly rewrites.
Below is a practical, experience-driven guide to common smells and the refactor strategies that scale.
1. Long Method
Signal: A single function handles validation, data loading, business rules, mutations, logging, error handling, and formatting. The narrative is lost in the noise.
Why it hurts: Violates Single Responsibility Principle. Hard to read, test, debug, and reuse. Changes in one concern risk breaking others.
Do instead: Extract small, focused functions and separate orchestration from implementation details.
Example
Before (monolithic)
def process_order(raw_order):
# 80+ lines of mixed responsibilities
passAfter (composed)
def process_order(raw_order):
order = validate_and_parse(raw_order)
validated = apply_business_rules(order)
persisted = save_order(validated)
notify_stakeholders(persisted)
return format_response(persisted)Each function should do one thing well and fit comfortably within a single screen.
2. Duplicated Business Rules
Signal: Pricing logic, validation rules, or permissions duplicated across services, controllers, and sometimes the frontend, with subtle variations.
Why it hurts: Inconsistent behavior, missed updates, and erosion of trust in system correctness.
Do instead: Centralize rules in a single source of truth: domain services, policies, or rich domain models.
PricingPolicy.calculate_total(cart, customer_context)
EligibilityService.is_eligible(user, campaign)Use value objects or specification patterns for complex logic.
3. Large Class (God Class)
Signal: One class handles authentication, persistence, formatting, notifications, caching, and reporting.
Why it hurts: Low cohesion, high coupling, difficult testing, and continuous complexity growth.
Do instead: Split by responsibility boundaries and favor composition over accumulation.
Extract:
- persistence → repository
- business logic → domain service
- orchestration → application service
- external systems → adapters
Keep classes small, focused, and purpose-driven.
4. Long Parameter List
Signal: Methods accept many primitives or loosely related parameters.
Why it hurts: Hard to read, easy to misuse, and fragile to change.
Do instead: Group related data into value objects or parameter objects.
class PaymentRequest:
def __init__(self, user, amount, currency, metadata):
...This improves readability, type safety, and future extensibility.
5. Nested Conditionals (Arrow Code)
Signal: Deeply nested if/else blocks that obscure the main flow.
Why it hurts: High cognitive load; understanding the happy path requires mental stack tracking.
Do instead: Use guard clauses, early returns, and polymorphism for behavioral variation.
if not is_valid(order): return error(...)
if not has_inventory(order): return error(...)
result = execute_core_logic(order)
return success(result)Keep the main path flat and obvious.
6. Generic Utilities / God Helpers
Signal: A growing utils/ or helpers/ module containing unrelated functions with unclear ownership.
Why it hurts: Poor discoverability, hidden dependencies, and duplicated domain logic.
Do instead: Place logic near the domain it belongs to.
orders/pricing.py
users/permissions.py
notifications/channels.pyOnly truly cross-cutting infrastructure concerns (logging, string/date utilities) belong in shared modules, and even those should remain minimal and intentional.
7. Error Handling: Swallowed or Inconsistent Errors
Signal: Errors are ignored, inconsistently handled, or converted into ambiguous return values like null or partial objects. Logging happens without context, and callers cannot reliably interpret failure.
Why it hurts: It hides real system failures, makes debugging unreliable, and forces every caller to guess what a “failed” result means. Over time, this erodes trust in the codebase.
Do instead: Make failure explicit and consistent. Define clear error contracts using domain errors, exceptions, or result types.
Poor design (silent failure)
function getUser(id: string) {
try {
return db.findUser(id);
} catch (e) {
console.log(e);
return null;
}
}Better design (explicit failure contract)
class NotFoundError extends Error {}
function getUser(id: string): User {
const user = db.findUser(id);
if (!user) {
throw new NotFoundError("User not found");
}
return user;
}Or with result types
function getUser(id: string): Result<User> {
const user = db.findUser(id);
return user ? ok(user) : err("USER_NOT_FOUND");
}Why this matters: Explicit error handling makes system behavior predictable and prevents hidden failures from spreading across layers. It forces every boundary to define what failure means.
8. Testing and Refactor Safety
Core idea: Refactoring is safe only when behavior is continuously verifiable. Structure changes should never rely on “it looks correct” but on tests that confirm correctness at every step.
This section is closely tied to Smell #9 (Tests Reflect Structure Quality): if code is hard to test, it is usually hard to refactor safely.
Refactoring safety pattern
Before changing structure, establish a safety net around behavior:
- Add or strengthen tests around critical flows
- Introduce stricter types or contracts where possible
- Add assertions at boundaries (inputs/outputs)
- Ensure at least one test covers success and one covers failure paths
Then refactor incrementally, validating behavior after each step.
Safe refactor flow
- Identify the smallest meaningful behavior unit
- Lock it with a test (or improve existing coverage)
- Refactor internals without changing external behavior
- Run tests after each small change
- Stop when structure improves and all tests remain green
Example (linked to Smell #9)
Before (hard to refactor safely)
test("refund", () => {
// heavy mocking: db, email, cache, logger
});After (behavior-focused)
test("refund updates order status", () => {
const order = new Order();
order.refund();
expect(order.status).toBe("REFUNDED");
});Now internal implementation can evolve freely without breaking tests, because tests validate behavior, not infrastructure.
Why this matters: Well-structured tests make refactoring low-risk and routine. Instead of fearing change, engineers can improve design continuously while staying confident that behavior remains intact.
Refactoring Safely
Good engineering practice doesn't stop at identifying problems. It ensures improvements can be made without introducing new defects. Safe refactoring is less about large rewrites and more about sequencing, constraints, and disciplined scope control.
- Start with behavior-preserving changes rather than rewriting code out of frustration or perceived disorder. The goal is stability first, structure second.
- Prioritize refactoring areas that are frequently modified or consistently difficult to understand. Cosmetic ugliness alone is not a sufficient reason to restructure code.
- Keep each change small enough that a reviewer can quickly understand intent, scope, and risk. Large, sweeping diffs increase uncertainty and slow down validation.
- Introduce at least one safety mechanism during the refactor: tests, type improvements, assertions, or clearer contracts. Refactoring without guardrails is just rework with hope.
- Stop when the code becomes easier to understand and modify. Refactoring is complete when change becomes safer and clearer, not when the design feels maximally elegant.
Practical Checklist Before You Merge Code
A good checklist turns principles into an actionable review tool. Use this during refactors, pull requests, or self-review to validate design improvements.
FAQs
A set of practical questions developers often ask when trying to turn best practices into everyday habits.
What are the most important coding best practices for junior developers?
Focus on fundamentals that directly improve readability and maintainability:
- Clear and intention-revealing naming
- Small, single-responsibility functions and classes
- Avoiding duplication of business logic
- Writing code that is easy to test
- Keeping dependencies explicit rather than hidden
Strong foundations matter more than advanced patterns early on.
Should I prioritize clean code or shipping quickly?
This is often framed as a trade-off, but experienced engineers treat it as a false dichotomy.
Good structure, naming, and separation of concerns actually increase delivery speed over time. They reduce debugging effort, simplify reviews, and lower the cost of change.
The real goal is sustainable velocity, not short-term speed achieved through technical debt.
When do design patterns actually help?
Design patterns are useful when they:
- Solve a recurring structural problem
- Reduce complexity in evolving systems
- Improve clarity between collaborating components
They are not useful when applied prematurely or used to “future-proof” simple logic. A pattern should reduce friction, not introduce ceremony.
What usually gets missed when engineers move too fast?
Speed often hides structural issues that accumulate over time, such as:
- Duplicated business rules across modules
- Hidden dependencies through global state or implicit context
- Overgrown classes or functions with mixed responsibilities
- Inconsistent naming that obscures intent
- Test complexity caused by tight coupling
The long-term cost is rarely in writing code. It’s in understanding and safely changing it later.