Applying SOLID Principles in Software Development

Applying SOLID Principles in Software Development

Introduction

Modern web apps live and die by clarity, speed, and changeability. SOLID offers a practical checklist to keep your React/Next.js + Node/Strapi codebase simple to evolve: fewer regressions, easier features, and safer refactors. Below you’ll find a no‑fluff, web‑focused take with patterns, smells, and code you can paste today—all in plain JavaScript.

SOLID principles in software development

Quick Map (80/20)

  • SRP — One responsibility per unit. Smell: components/services that “do everything.”
  • OCP — Add features by adding code, not editing stable code. Smell: switch/case mountains.
  • LSP — Swappable implementations must behave the same. Smell: “works with A, breaks with B.”
  • ISP — Small, focused interfaces. Smell: “god services” with 10+ methods.
  • DIP — Code depends on abstractions, not concrete modules. Smell: import chains into low‑level libs everywhere.

1) SRP — Single Responsibility in React/Next.js

Problem: components that fetch, format, render, handle analytics, and call payments in one file.

Refactor recipe: split by role → data, logic, view.

// ✅ View-only
export function ProductCard({ product, onAdd }) {
  return (
    <div className="rounded-2xl border p-4">
      <h3>{product.title}</h3>
      <p>${product.price}</p>
      <button onClick={() => onAdd(product.id)}>Add to cart</button>
    </div>
  )
}
// ✅ Logic-only
export function useCartActions(cartRepo) {
  return {
    add: (id) => cartRepo.add(id),
    remove: (id) => cartRepo.remove(id),
  }
}
// ✅ Data-only (server action / API route / service)
export const cartRepo = {
  add: async (id) => fetch('/api/cart', { method: 'POST', body: JSON.stringify({ id }) }),
  remove: async (id) => fetch('/api/cart', { method: 'DELETE', body: JSON.stringify({ id }) }),
}

Payoff: smaller files, faster reviews, independent testing. If design changes, view changes only.


2) OCP — Add Payments Without Editing Checkout

Goal: support Stripe now, add MercadoPago/PayPal later without touching the checkout flow.

// payments/strategies/stripe.js
export const stripeStrategy = {
  pay: async ({ amount, currency }) => {
    // call Stripe SDK/endpoint
    return { ok: true, ref: 'stripe_tx_123' }
  }
}

// payments/strategies/dummy.js (for local dev/tests)
export const dummyStrategy = {
  pay: async ({ amount }) => ({ ok: true, ref: 'dev_tx_' + amount })
}
// payments/index.js — closed for modification, open for extension
import { stripeStrategy } from './strategies/stripe'
import { dummyStrategy } from './strategies/dummy'

const strategies = {
  stripe: stripeStrategy,
  dummy: dummyStrategy,
}

export function getPaymentStrategy(name) {
  return strategies[name] || dummyStrategy
}
// api/checkout.js — stays stable
import { getPaymentStrategy } from '@/payments'

export default async function handler(req, res) {
  const body = await parseBody(req)
  const strategy = getPaymentStrategy(process.env.NEXT_PUBLIC_PAYMENT || 'stripe')
  const result = await strategy.pay({ amount: body.total, currency: 'USD' })
  return res.status(result.ok ? 200 : 400).json(result)
}

Extend: to add a provider, create a new strategy file and register it—no edits to checkout.


3) LSP — Substitutable Caches/Repos That Don’t Break

Scenario: swap LocalStorage cart for server-side cart. Both must keep promises: same inputs → same outputs, same error semantics.

// contracts/cartStore.js — behavior contract (JSDoc over JS)
/**
 * @typedef {Object} CartStore
 * @property {(id: string) => Promise<void>} add
 * @property {(id: string) => Promise<void>} remove
 * @property {() => Promise<Array<{id: string, qty: number}>>} list
 */
// impl/localCartStore.js
export const localCartStore = /** @type {import('./cartStore').CartStore} */ ({
  add: async (id) => {
    const cart = JSON.parse(localStorage.getItem('cart') || '[]')
    cart.push({ id, qty: 1 })
    localStorage.setItem('cart', JSON.stringify(cart))
  },
  remove: async (id) => {
    const cart = JSON.parse(localStorage.getItem('cart') || '[]')
    localStorage.setItem('cart', JSON.stringify(cart.filter(i => i.id !== id)))
  },
  list: async () => JSON.parse(localStorage.getItem('cart') || '[]'),
})
// impl/serverCartStore.js
export const serverCartStore = {
  add: (id) => fetch('/api/cart', { method: 'POST', body: JSON.stringify({ id }) }),
  remove: (id) => fetch('/api/cart', { method: 'DELETE', body: JSON.stringify({ id }) }),
  list: () => fetch('/api/cart').then(r => r.json()),
}

Test: both pass the same suite. If one violates expected shape/behavior → breaks tests → not LSP.


4) ISP — Split “God Interfaces” Into Focused Ones

Smell: a MediaService with upload, delete, resize, signUrl, list, optimize, transcode… clients only need two.

// Instead of 1 mega interface, split per use-case
export const uploadOnly = ({ uploader }) => (file) => uploader.upload(file)
export const deleteOnly = ({ remover }) => (id) => remover.remove(id)
// Usage: pages that only upload aren’t forced to import/remove logic
import { uploadOnly } from '@/media/contracts'
import { strapiUploader } from '@/media/impl/strapiUploader'

const upload = uploadOnly({ uploader: strapiUploader })
await upload(file)

Benefit: smaller bundles, fewer accidental imports, cheaper refactors.


5) DIP — Depend on Abstractions, Inject Details

Problem: components import concrete libs (import Stripe from 'stripe') everywhere → hard to test, hard to swap.

Fix: pass dependencies from the edge (composition root: page, API route, server action).

// domain/orders.js — pure and testable
export function createOrder({ repo, payments }) {
  return {
    async checkout(cart) {
      const total = await repo.total(cart)
      const tx = await payments.pay({ amount: total, currency: 'USD' })
      await repo.commit(cart, tx.ref)
      return { ok: true, ref: tx.ref }
    }
  }
}
// app/api/checkout/route.js — inject details here
import { createOrder } from '@/domain/orders'
import { ordersRepo } from '@/infra/ordersRepo'
import { getPaymentStrategy } from '@/payments'

export async function POST(req) {
  const body = await req.json()
  const payments = getPaymentStrategy(process.env.NEXT_PUBLIC_PAYMENT || 'stripe')
  const order = createOrder({ repo: ordersRepo, payments })
  const result = await order.checkout(body.cart)
  return Response.json(result, { status: result.ok ? 200 : 400 })
}

Payoff: domain is framework‑free; you can test createOrder with fakes in milliseconds.


Pragmatic Checklists

Component SRP checklist

  • [ ] Does this file fetch data and render UI? Split hook/service vs component.
  • [ ] Does it handle analytics/business rules? Move to a useXxx hook or domain function.
  • [ ] Can I delete it without breaking unrelated features? If no → it does too much.

OCP extension checklist

  • [ ] New payment/shipping/logging = new strategy module.
  • [ ] Register strategy in a single map/factory.
  • [ ] No edits to API routes/pages that orchestrate the flow.

DIP adoption checklist

  • [ ] Domain functions receive repo, payments, logger as parameters.
  • [ ] Edge modules (pages/API) assemble dependencies.
  • [ ] Tests pass fakes instead of real SDKs.

A 90‑Minute Refactor Plan

  1. Pick 1 feature (checkout, media upload, email).
  2. Draw boundaries (domain vs edge).
  3. Extract interfaces (JSDoc typedefs) and add a fake implementation.
  4. Inject dependencies into domain functions (DIP).
  5. Split interfaces used by each page (ISP).
  6. Replace conditionals with strategy maps (OCP).
  7. Write 3 tests for happy path, invalid input, provider failure.
  8. Measure: changed files ↓, bundle size ↓, test time ↓.

Conclusion

SOLID is not theory for large OOP systems—it’s a survival kit for web teams shipping weekly. Use SRP to cut files, OCP to add providers safely, LSP to swap infra without surprises, ISP to slim public surfaces, and DIP to make core logic framework‑agnostic and testable. Start small, pick one feature, and you’ll feel the compound interest in your next release.