กลับไปที่บทความ
Software Design Clean Code OOP Architecture TypeScript Java Python C#

SOLID Principles Explained for Working Developers

พลากร วรมงคล
15 เมษายน 2569 14 นาที

“Five principles that separate code you can still touch in six months from code that becomes a minefield. Each principle explained in plain English with before/after code in TypeScript, Java, Python, and C#.”

Why SOLID Still Matters

SOLID is a set of five object-oriented design principles coined by Robert C. Martin in the early 2000s. They predate React, TypeScript, serverless, and AI copilots — and they are still the most useful mental model we have for writing code that doesn’t rot.

The principles aren’t rules. They’re forces to balance. Apply them blindly and you get over-engineered factories that abstract a button click through seven layers. Ignore them and you get the 2,000-line “god class” that nobody wants to touch. The goal is intuition, not compliance.

Each letter stands for one principle:

  • S — Single Responsibility Principle
  • O — Open/Closed Principle
  • L — Liskov Substitution Principle
  • I — Interface Segregation Principle
  • D — Dependency Inversion Principle

Every code example below is shown in TypeScript, Java, Python, and C# so you can read it in whatever language is closest to your day job. The principles apply equally across all of them.

S — Single Responsibility Principle

A class should have one, and only one, reason to change.

The common misreading: “a class should only do one thing.” That’s too vague. The real framing is about reasons to change — which really means who is asking for the change.

If two different teams or stakeholders can request changes to the same class, that class has two responsibilities, and those responsibilities will eventually pull in different directions.

The pain case

class Invoice {
  constructor(
    public items: LineItem[],
    public customer: Customer,
  ) {}

  calculateTotal(): number {
    return this.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
  }

  renderPDF(): Buffer {
    const doc = new PDFDocument();
    doc.fontSize(16).text(`Invoice for ${this.customer.name}`);
    doc.text(`Total: $${this.calculateTotal()}`);
    return doc.end();
  }

  sendEmail(): Promise<void> {
    return mailer.send({
      to: this.customer.email,
      subject: "Your invoice",
      attachment: this.renderPDF(),
    });
  }

  saveToDatabase(): Promise<void> {
    return db.invoices.insert({
      customer_id: this.customer.id,
      total: this.calculateTotal(),
      items: JSON.stringify(this.items),
    });
  }
}
public class Invoice {
    private final List<LineItem> items;
    private final Customer customer;

    public Invoice(List<LineItem> items, Customer customer) {
        this.items = items;
        this.customer = customer;
    }

    public BigDecimal calculateTotal() {
        return items.stream()
            .map(i -> i.getPrice().multiply(BigDecimal.valueOf(i.getQuantity())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }

    public byte[] renderPDF() {
        PDFDocument doc = new PDFDocument();
        doc.text("Invoice for " + customer.getName(), 16);
        doc.text("Total: $" + calculateTotal());
        return doc.toBytes();
    }

    public void sendEmail() {
        Mailer.send(
            customer.getEmail(),
            "Your invoice",
            renderPDF()
        );
    }

    public void saveToDatabase() {
        Database.invoices().insert(
            customer.getId(),
            calculateTotal(),
            new Gson().toJson(items)
        );
    }
}
class Invoice:
    def __init__(self, items: list[LineItem], customer: Customer):
        self.items = items
        self.customer = customer

    def calculate_total(self) -> Decimal:
        return sum((i.price * i.quantity for i in self.items), Decimal(0))

    def render_pdf(self) -> bytes:
        doc = PDFDocument()
        doc.text(f"Invoice for {self.customer.name}", size=16)
        doc.text(f"Total: ${self.calculate_total()}")
        return doc.to_bytes()

    def send_email(self) -> None:
        mailer.send(
            to=self.customer.email,
            subject="Your invoice",
            attachment=self.render_pdf(),
        )

    def save_to_database(self) -> None:
        db.invoices.insert(
            customer_id=self.customer.id,
            total=self.calculate_total(),
            items=json.dumps([i.__dict__ for i in self.items]),
        )
public class Invoice
{
    public List<LineItem> Items { get; }
    public Customer Customer { get; }

    public Invoice(List<LineItem> items, Customer customer)
    {
        Items = items;
        Customer = customer;
    }

    public decimal CalculateTotal() =>
        Items.Sum(i => i.Price * i.Quantity);

    public byte[] RenderPDF()
    {
        var doc = new PDFDocument();
        doc.Text($"Invoice for {Customer.Name}", 16);
        doc.Text($"Total: ${CalculateTotal()}");
        return doc.ToBytes();
    }

    public Task SendEmailAsync() =>
        Mailer.SendAsync(
            Customer.Email,
            "Your invoice",
            RenderPDF()
        );

    public Task SaveToDatabaseAsync() =>
        Db.Invoices.InsertAsync(new {
            CustomerId = Customer.Id,
            Total = CalculateTotal(),
            Items = JsonSerializer.Serialize(Items),
        });
}

This class has four reasons to change:

  • Finance changes tax rules → calculateTotal
  • Marketing wants a new PDF layout → renderPDF
  • Ops switches email providers → sendEmail
  • The DBA renames a column → saveToDatabase

Four different stakeholders, four different release cadences, one class. Every change risks breaking the other three.

The better shape

class Invoice {
  constructor(
    public items: LineItem[],
    public customer: Customer,
  ) {}

  calculateTotal(): number {
    return this.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
  }
}

class InvoicePdfRenderer {
  render(invoice: Invoice): Buffer { /* ... */ }
}

class InvoiceMailer {
  constructor(private renderer: InvoicePdfRenderer, private mailer: Mailer) {}
  send(invoice: Invoice): Promise<void> { /* ... */ }
}

class InvoiceRepository {
  save(invoice: Invoice): Promise<void> { /* ... */ }
}
public class Invoice {
    private final List<LineItem> items;
    private final Customer customer;

    public Invoice(List<LineItem> items, Customer customer) {
        this.items = items;
        this.customer = customer;
    }

    public BigDecimal calculateTotal() {
        return items.stream()
            .map(i -> i.getPrice().multiply(BigDecimal.valueOf(i.getQuantity())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

public class InvoicePdfRenderer {
    public byte[] render(Invoice invoice) { /* ... */ }
}

public class InvoiceMailer {
    private final InvoicePdfRenderer renderer;
    private final Mailer mailer;
    public void send(Invoice invoice) { /* ... */ }
}

public class InvoiceRepository {
    public void save(Invoice invoice) { /* ... */ }
}
class Invoice:
    def __init__(self, items: list[LineItem], customer: Customer):
        self.items = items
        self.customer = customer

    def calculate_total(self) -> Decimal:
        return sum((i.price * i.quantity for i in self.items), Decimal(0))


class InvoicePdfRenderer:
    def render(self, invoice: Invoice) -> bytes: ...


class InvoiceMailer:
    def __init__(self, renderer: InvoicePdfRenderer, mailer: Mailer):
        self.renderer = renderer
        self.mailer = mailer

    def send(self, invoice: Invoice) -> None: ...


class InvoiceRepository:
    def save(self, invoice: Invoice) -> None: ...
public class Invoice
{
    public List<LineItem> Items { get; }
    public Customer Customer { get; }

    public Invoice(List<LineItem> items, Customer customer)
    {
        Items = items;
        Customer = customer;
    }

    public decimal CalculateTotal() =>
        Items.Sum(i => i.Price * i.Quantity);
}

public class InvoicePdfRenderer
{
    public byte[] Render(Invoice invoice) { /* ... */ }
}

public class InvoiceMailer
{
    private readonly InvoicePdfRenderer _renderer;
    private readonly IMailer _mailer;
    public Task SendAsync(Invoice invoice) { /* ... */ }
}

public class InvoiceRepository
{
    public Task SaveAsync(Invoice invoice) { /* ... */ }
}

Each class has one reason to change. The Invoice is now a pure domain object — finance logic lives with finance, presentation with presentation, persistence with persistence.

When to ignore it

For a 20-line admin script that saves a record and emails you, one class is fine. SRP earns its keep when code has multiple readers and a long lifespan.

O — Open/Closed Principle

Software entities should be open for extension, but closed for modification.

Translation: when requirements change, you should be able to add new behavior by writing new code, not by editing old code. Editing old code risks breaking things that already work.

The pain case

class PaymentProcessor {
  charge(method: "card" | "paypal", amount: number) {
    if (method === "card") {
      return stripe.charge(amount);
    } else if (method === "paypal") {
      return paypal.charge(amount);
    }
  }
}
public class PaymentProcessor {
    public Receipt charge(String method, BigDecimal amount) {
        if ("card".equals(method)) {
            return Stripe.charge(amount);
        } else if ("paypal".equals(method)) {
            return PayPal.charge(amount);
        }
        throw new IllegalArgumentException("Unknown method: " + method);
    }
}
class PaymentProcessor:
    def charge(self, method: str, amount: Decimal) -> Receipt:
        if method == "card":
            return stripe.charge(amount)
        elif method == "paypal":
            return paypal.charge(amount)
        raise ValueError(f"Unknown method: {method}")
public class PaymentProcessor
{
    public Receipt Charge(string method, decimal amount)
    {
        if (method == "card")
            return Stripe.Charge(amount);
        if (method == "paypal")
            return PayPal.Charge(amount);
        throw new ArgumentException($"Unknown method: {method}");
    }
}

Adding Apple Pay means editing this method. Adding crypto means editing it again. Every addition risks breaking card and PayPal flows. Every addition also requires re-running every test for every payment method.

The better shape

interface PaymentMethod {
  charge(amount: number): Promise<Receipt>;
}

class StripePayment implements PaymentMethod {
  charge(amount: number) { return stripe.charge(amount); }
}

class PayPalPayment implements PaymentMethod {
  charge(amount: number) { return paypal.charge(amount); }
}

class ApplePayPayment implements PaymentMethod {
  charge(amount: number) { return applePay.charge(amount); }
}

class PaymentProcessor {
  constructor(private method: PaymentMethod) {}
  process(amount: number) { return this.method.charge(amount); }
}
public interface PaymentMethod {
    Receipt charge(BigDecimal amount);
}

public class StripePayment implements PaymentMethod {
    public Receipt charge(BigDecimal amount) { return Stripe.charge(amount); }
}

public class PayPalPayment implements PaymentMethod {
    public Receipt charge(BigDecimal amount) { return PayPal.charge(amount); }
}

public class ApplePayPayment implements PaymentMethod {
    public Receipt charge(BigDecimal amount) { return ApplePay.charge(amount); }
}

public class PaymentProcessor {
    private final PaymentMethod method;
    public PaymentProcessor(PaymentMethod method) { this.method = method; }
    public Receipt process(BigDecimal amount) { return method.charge(amount); }
}
from typing import Protocol

class PaymentMethod(Protocol):
    def charge(self, amount: Decimal) -> Receipt: ...


class StripePayment:
    def charge(self, amount: Decimal) -> Receipt:
        return stripe.charge(amount)


class PayPalPayment:
    def charge(self, amount: Decimal) -> Receipt:
        return paypal.charge(amount)


class ApplePayPayment:
    def charge(self, amount: Decimal) -> Receipt:
        return apple_pay.charge(amount)


class PaymentProcessor:
    def __init__(self, method: PaymentMethod):
        self.method = method

    def process(self, amount: Decimal) -> Receipt:
        return self.method.charge(amount)
public interface IPaymentMethod
{
    Task<Receipt> ChargeAsync(decimal amount);
}

public class StripePayment : IPaymentMethod
{
    public Task<Receipt> ChargeAsync(decimal amount) => Stripe.ChargeAsync(amount);
}

public class PayPalPayment : IPaymentMethod
{
    public Task<Receipt> ChargeAsync(decimal amount) => PayPal.ChargeAsync(amount);
}

public class ApplePayPayment : IPaymentMethod
{
    public Task<Receipt> ChargeAsync(decimal amount) => ApplePay.ChargeAsync(amount);
}

public class PaymentProcessor
{
    private readonly IPaymentMethod _method;
    public PaymentProcessor(IPaymentMethod method) { _method = method; }
    public Task<Receipt> ProcessAsync(decimal amount) => _method.ChargeAsync(amount);
}

Adding crypto is now a new file. The existing classes don’t change. Their tests don’t re-run unless genuinely affected. The PaymentProcessor is closed for modification but open for extension via new PaymentMethod implementations.

The trap

Don’t preemptively apply OCP to every if/else. If a feature has only two variants and you’re 90% sure there will never be a third, the inline if is fine. Refactor to OCP when you feel the third variant coming — not before.

L — Liskov Substitution Principle

Subtypes must be substitutable for their base types without altering the correctness of the program.

Formal definition, plain meaning: if your code works with Bird, it should also work with any Bird subclass. A caller shouldn’t need to know which subclass it received.

The pain case — the classic rectangle/square trap

class Rectangle {
  constructor(public width: number, public height: number) {}
  setWidth(w: number) { this.width = w; }
  setHeight(h: number) { this.height = h; }
  area() { return this.width * this.height; }
}

class Square extends Rectangle {
  setWidth(w: number) { this.width = w; this.height = w; }
  setHeight(h: number) { this.width = h; this.height = h; }
}

function increaseWidth(r: Rectangle) {
  r.setWidth(r.width + 10);
  assert(r.area() === r.width * r.height); // passes
}

increaseWidth(new Rectangle(5, 10)); // fine
increaseWidth(new Square(5, 5));     // surprise: height changed too
public class Rectangle {
    protected int width, height;
    public Rectangle(int w, int h) { this.width = w; this.height = h; }
    public void setWidth(int w) { this.width = w; }
    public void setHeight(int h) { this.height = h; }
    public int area() { return width * height; }
}

public class Square extends Rectangle {
    public Square(int side) { super(side, side); }
    @Override public void setWidth(int w) { this.width = w; this.height = w; }
    @Override public void setHeight(int h) { this.width = h; this.height = h; }
}

void increaseWidth(Rectangle r) {
    r.setWidth(r.width + 10);
    assert r.area() == r.width * r.height; // passes
}

increaseWidth(new Rectangle(5, 10)); // fine
increaseWidth(new Square(5));        // surprise: height changed too
class Rectangle:
    def __init__(self, width: int, height: int):
        self.width = width
        self.height = height

    def set_width(self, w: int) -> None:
        self.width = w

    def set_height(self, h: int) -> None:
        self.height = h

    def area(self) -> int:
        return self.width * self.height


class Square(Rectangle):
    def set_width(self, w: int) -> None:
        self.width = w
        self.height = w

    def set_height(self, h: int) -> None:
        self.width = h
        self.height = h


def increase_width(r: Rectangle) -> None:
    r.set_width(r.width + 10)
    assert r.area() == r.width * r.height  # passes

increase_width(Rectangle(5, 10))  # fine
increase_width(Square(5, 5))      # surprise: height changed too
public class Rectangle
{
    public int Width { get; protected set; }
    public int Height { get; protected set; }
    public Rectangle(int w, int h) { Width = w; Height = h; }
    public virtual void SetWidth(int w) => Width = w;
    public virtual void SetHeight(int h) => Height = h;
    public int Area() => Width * Height;
}

public class Square : Rectangle
{
    public Square(int side) : base(side, side) {}
    public override void SetWidth(int w) { Width = w; Height = w; }
    public override void SetHeight(int h) { Width = h; Height = h; }
}

void IncreaseWidth(Rectangle r)
{
    r.SetWidth(r.Width + 10);
    Debug.Assert(r.Area() == r.Width * r.Height); // passes
}

IncreaseWidth(new Rectangle(5, 10)); // fine
IncreaseWidth(new Square(5));        // surprise: height changed too

Mathematically, a square is a rectangle. In code, substituting a Square for a Rectangle breaks caller assumptions. The inheritance hierarchy is wrong.

The fix

If a subtype strengthens preconditions, weakens postconditions, or throws on methods the parent supports, you have an LSP violation. The remedy is usually composition over inheritance or a differently-shaped type hierarchy.

// Instead of Square extends Rectangle:
interface Shape { area(): number; }
class Rectangle implements Shape {
  constructor(public width: number, public height: number) {}
  area() { return this.width * this.height; }
}
class Square implements Shape {
  constructor(public side: number) {}
  area() { return this.side * this.side; }
}
public interface Shape {
    int area();
}

public class Rectangle implements Shape {
    private final int width, height;
    public Rectangle(int w, int h) { this.width = w; this.height = h; }
    public int area() { return width * height; }
}

public class Square implements Shape {
    private final int side;
    public Square(int side) { this.side = side; }
    public int area() { return side * side; }
}
from typing import Protocol

class Shape(Protocol):
    def area(self) -> int: ...


class Rectangle:
    def __init__(self, width: int, height: int):
        self.width = width
        self.height = height

    def area(self) -> int:
        return self.width * self.height


class Square:
    def __init__(self, side: int):
        self.side = side

    def area(self) -> int:
        return self.side * self.side
public interface IShape
{
    int Area();
}

public class Rectangle : IShape
{
    public int Width { get; }
    public int Height { get; }
    public Rectangle(int w, int h) { Width = w; Height = h; }
    public int Area() => Width * Height;
}

public class Square : IShape
{
    public int Side { get; }
    public Square(int side) { Side = side; }
    public int Area() => Side * Side;
}

Now neither shape “is-a” the other; they share a contract. Callers that genuinely need area polymorphism depend on Shape, and callers that need rectangle-specific operations depend on Rectangle directly.

I — Interface Segregation Principle

Clients should not be forced to depend on interfaces they do not use.

The symptom: a class implementing an interface has to stub out methods it doesn’t need, throwing NotSupported or doing nothing. That’s a sign the interface is too fat.

The pain case

interface Worker {
  work(): void;
  eat(): void;
  sleep(): void;
  reportProgress(): string;
}

class Human implements Worker {
  work() { /* ... */ }
  eat() { /* ... */ }
  sleep() { /* ... */ }
  reportProgress() { return "85%"; }
}

class Robot implements Worker {
  work() { /* ... */ }
  eat() { throw new Error("Robots don't eat"); }    // forced stub
  sleep() { throw new Error("Robots don't sleep"); } // forced stub
  reportProgress() { return "85%"; }
}
public interface Worker {
    void work();
    void eat();
    void sleep();
    String reportProgress();
}

public class Human implements Worker {
    public void work() { /* ... */ }
    public void eat() { /* ... */ }
    public void sleep() { /* ... */ }
    public String reportProgress() { return "85%"; }
}

public class Robot implements Worker {
    public void work() { /* ... */ }
    public void eat() { throw new UnsupportedOperationException("Robots don't eat"); }
    public void sleep() { throw new UnsupportedOperationException("Robots don't sleep"); }
    public String reportProgress() { return "85%"; }
}
from typing import Protocol

class Worker(Protocol):
    def work(self) -> None: ...
    def eat(self) -> None: ...
    def sleep(self) -> None: ...
    def report_progress(self) -> str: ...


class Human:
    def work(self) -> None: ...
    def eat(self) -> None: ...
    def sleep(self) -> None: ...
    def report_progress(self) -> str: return "85%"


class Robot:
    def work(self) -> None: ...
    def eat(self) -> None:
        raise NotImplementedError("Robots don't eat")     # forced stub
    def sleep(self) -> None:
        raise NotImplementedError("Robots don't sleep")   # forced stub
    def report_progress(self) -> str: return "85%"
public interface IWorker
{
    void Work();
    void Eat();
    void Sleep();
    string ReportProgress();
}

public class Human : IWorker
{
    public void Work() { /* ... */ }
    public void Eat() { /* ... */ }
    public void Sleep() { /* ... */ }
    public string ReportProgress() => "85%";
}

public class Robot : IWorker
{
    public void Work() { /* ... */ }
    public void Eat() => throw new NotSupportedException("Robots don't eat");
    public void Sleep() => throw new NotSupportedException("Robots don't sleep");
    public string ReportProgress() => "85%";
}

The Worker interface is too wide. Every class that plays the “worker” role is forced to know about eating and sleeping, even if it has nothing to do with them.

The better shape

interface Workable   { work(): void; }
interface Feedable   { eat(): void; }
interface Restable   { sleep(): void; }
interface Reportable { reportProgress(): string; }

class Human implements Workable, Feedable, Restable, Reportable {
  work() { /* ... */ }
  eat() { /* ... */ }
  sleep() { /* ... */ }
  reportProgress() { return "85%"; }
}

class Robot implements Workable, Reportable {
  work() { /* ... */ }
  reportProgress() { return "85%"; }
}
public interface Workable   { void work(); }
public interface Feedable   { void eat(); }
public interface Restable   { void sleep(); }
public interface Reportable { String reportProgress(); }

public class Human implements Workable, Feedable, Restable, Reportable {
    public void work() { /* ... */ }
    public void eat() { /* ... */ }
    public void sleep() { /* ... */ }
    public String reportProgress() { return "85%"; }
}

public class Robot implements Workable, Reportable {
    public void work() { /* ... */ }
    public String reportProgress() { return "85%"; }
}
from typing import Protocol

class Workable(Protocol):
    def work(self) -> None: ...

class Feedable(Protocol):
    def eat(self) -> None: ...

class Restable(Protocol):
    def sleep(self) -> None: ...

class Reportable(Protocol):
    def report_progress(self) -> str: ...


class Human:
    def work(self) -> None: ...
    def eat(self) -> None: ...
    def sleep(self) -> None: ...
    def report_progress(self) -> str: return "85%"


class Robot:
    def work(self) -> None: ...
    def report_progress(self) -> str: return "85%"
public interface IWorkable   { void Work(); }
public interface IFeedable   { void Eat(); }
public interface IRestable   { void Sleep(); }
public interface IReportable { string ReportProgress(); }

public class Human : IWorkable, IFeedable, IRestable, IReportable
{
    public void Work() { /* ... */ }
    public void Eat() { /* ... */ }
    public void Sleep() { /* ... */ }
    public string ReportProgress() => "85%";
}

public class Robot : IWorkable, IReportable
{
    public void Work() { /* ... */ }
    public string ReportProgress() => "85%";
}

Now a function that only needs reportProgress depends on Reportable — not on a bloated Worker. It accepts both humans and robots, and if you later add a Dog implements Feedable, the progress-reporting code is completely unaffected.

Practical tip

When designing an interface, ask: “Who are all the callers, and do they all use every method?” If any caller uses only a subset, split the interface along those usage lines.

D — Dependency Inversion Principle

High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.

Reframe: depend on interfaces, not concrete implementations. The business logic of your app shouldn’t know whether it’s storing data in PostgreSQL or S3, sending email via SendGrid or SMTP, or calling OpenAI or Claude.

The pain case

class OrderService {
  async placeOrder(order: Order) {
    const db = new PostgresClient();           // concrete dependency
    const mailer = new SendGridClient();       // concrete dependency
    const logger = new CloudWatchLogger();     // concrete dependency

    await db.insert("orders", order);
    await mailer.send(order.customer.email, "Order placed");
    logger.info(`Order ${order.id} placed`);
  }
}
public class OrderService {
    public void placeOrder(Order order) {
        PostgresClient db = new PostgresClient();         // concrete dependency
        SendGridClient mailer = new SendGridClient();     // concrete dependency
        CloudWatchLogger logger = new CloudWatchLogger(); // concrete dependency

        db.insert("orders", order);
        mailer.send(order.getCustomer().getEmail(), "Order placed");
        logger.info("Order " + order.getId() + " placed");
    }
}
class OrderService:
    def place_order(self, order: Order) -> None:
        db = PostgresClient()           # concrete dependency
        mailer = SendGridClient()       # concrete dependency
        logger = CloudWatchLogger()     # concrete dependency

        db.insert("orders", order)
        mailer.send(order.customer.email, "Order placed")
        logger.info(f"Order {order.id} placed")
public class OrderService
{
    public async Task PlaceOrderAsync(Order order)
    {
        var db = new PostgresClient();           // concrete dependency
        var mailer = new SendGridClient();       // concrete dependency
        var logger = new CloudWatchLogger();     // concrete dependency

        await db.InsertAsync("orders", order);
        await mailer.SendAsync(order.Customer.Email, "Order placed");
        logger.Info($"Order {order.Id} placed");
    }
}

You cannot unit test this without a real Postgres, real SendGrid, and real CloudWatch. Every test is an integration test. Switching to another mail provider means editing OrderService. Running it in a different environment means editing OrderService.

The better shape

interface Database { insert(table: string, row: unknown): Promise<void>; }
interface Mailer   { send(to: string, subject: string): Promise<void>; }
interface Logger   { info(msg: string): void; }

class OrderService {
  constructor(
    private db: Database,
    private mailer: Mailer,
    private logger: Logger,
  ) {}

  async placeOrder(order: Order) {
    await this.db.insert("orders", order);
    await this.mailer.send(order.customer.email, "Order placed");
    this.logger.info(`Order ${order.id} placed`);
  }
}

// Wiring happens once, at the edge of the application:
const service = new OrderService(
  new PostgresClient(),
  new SendGridClient(),
  new CloudWatchLogger(),
);
public interface Database { void insert(String table, Object row); }
public interface Mailer   { void send(String to, String subject); }
public interface Logger   { void info(String msg); }

public class OrderService {
    private final Database db;
    private final Mailer mailer;
    private final Logger logger;

    public OrderService(Database db, Mailer mailer, Logger logger) {
        this.db = db;
        this.mailer = mailer;
        this.logger = logger;
    }

    public void placeOrder(Order order) {
        db.insert("orders", order);
        mailer.send(order.getCustomer().getEmail(), "Order placed");
        logger.info("Order " + order.getId() + " placed");
    }
}

// Wiring happens once, at the edge of the application:
OrderService service = new OrderService(
    new PostgresClient(),
    new SendGridClient(),
    new CloudWatchLogger()
);
from typing import Protocol

class Database(Protocol):
    def insert(self, table: str, row: object) -> None: ...

class Mailer(Protocol):
    def send(self, to: str, subject: str) -> None: ...

class Logger(Protocol):
    def info(self, msg: str) -> None: ...


class OrderService:
    def __init__(self, db: Database, mailer: Mailer, logger: Logger):
        self.db = db
        self.mailer = mailer
        self.logger = logger

    def place_order(self, order: Order) -> None:
        self.db.insert("orders", order)
        self.mailer.send(order.customer.email, "Order placed")
        self.logger.info(f"Order {order.id} placed")


# Wiring happens once, at the edge of the application:
service = OrderService(
    PostgresClient(),
    SendGridClient(),
    CloudWatchLogger(),
)
public interface IDatabase { Task InsertAsync(string table, object row); }
public interface IMailer   { Task SendAsync(string to, string subject); }
public interface ILogger   { void Info(string msg); }

public class OrderService
{
    private readonly IDatabase _db;
    private readonly IMailer _mailer;
    private readonly ILogger _logger;

    public OrderService(IDatabase db, IMailer mailer, ILogger logger)
    {
        _db = db;
        _mailer = mailer;
        _logger = logger;
    }

    public async Task PlaceOrderAsync(Order order)
    {
        await _db.InsertAsync("orders", order);
        await _mailer.SendAsync(order.Customer.Email, "Order placed");
        _logger.Info($"Order {order.Id} placed");
    }
}

// Wiring happens once, at the edge of the application:
var service = new OrderService(
    new PostgresClient(),
    new SendGridClient(),
    new CloudWatchLogger()
);

The OrderService now depends on three small interfaces. In unit tests you pass in-memory fakes. In staging you pass staging implementations. In production you pass the real ones. The business logic never changes when infrastructure does.

The framework / web-handler version

DI doesn’t have to mean constructors and classes. In a web handler, the same principle applies via factory functions or framework DI containers — the handler depends on a PaymentProvider abstraction, and the route file composes the real provider:

// Don't:
export async function POST(req: Request) {
  const stripe = new Stripe(process.env.STRIPE_KEY!);
  // ...business logic mixed with Stripe setup...
}

// Do:
export function createCheckoutHandler(payments: PaymentProvider) {
  return async (req: Request) => {
    // business logic in terms of `payments`
  };
}
// Don't:
@RestController
public class CheckoutController {
    @PostMapping("/checkout")
    public Receipt checkout(@RequestBody Order order) {
        Stripe stripe = new Stripe(System.getenv("STRIPE_KEY")); // hard-wired
        return stripe.charge(order.getAmount());
    }
}

// Do:
@RestController
public class CheckoutController {
    private final PaymentProvider payments;
    public CheckoutController(PaymentProvider payments) { this.payments = payments; }

    @PostMapping("/checkout")
    public Receipt checkout(@RequestBody Order order) {
        return payments.charge(order.getAmount());
    }
}
# Don't:
@router.post("/checkout")
async def checkout(order: Order):
    stripe = Stripe(os.environ["STRIPE_KEY"])  # hard-wired
    return stripe.charge(order.amount)


# Do:
def make_checkout_handler(payments: PaymentProvider):
    async def checkout(order: Order):
        return payments.charge(order.amount)
    return checkout
// Don't:
[ApiController]
public class CheckoutController : ControllerBase
{
    [HttpPost("/checkout")]
    public Task<Receipt> Checkout([FromBody] Order order)
    {
        var stripe = new Stripe(Environment.GetEnvironmentVariable("STRIPE_KEY"));
        return stripe.ChargeAsync(order.Amount);
    }
}

// Do:
[ApiController]
public class CheckoutController : ControllerBase
{
    private readonly IPaymentProvider _payments;
    public CheckoutController(IPaymentProvider payments) { _payments = payments; }

    [HttpPost("/checkout")]
    public Task<Receipt> Checkout([FromBody] Order order) =>
        _payments.ChargeAsync(order.Amount);
}

Same idea: the handler depends on a PaymentProvider abstraction. Wiring composes the real Stripe provider in production, a fake in tests.

Putting It Together

Applying SOLID doesn’t mean every feature needs five interfaces, three dependency injections, and a factory. It means:

  1. Separate concerns so each class has one reason to change.
  2. Add new behavior by writing new code, not editing working code.
  3. Don’t create subclasses that surprise callers of the base class.
  4. Keep interfaces narrow — one role, one interface.
  5. Depend on abstractions at module boundaries; wire up concrete implementations at the edges.

These are heuristics. Good engineers apply them where they pay off — typically in business logic, in code that will live for years, and at boundaries between subsystems — and skip them in throwaway scripts and one-time tools.

A Quick Self-Check

When you finish a change, ask yourself:

  • SRP — If requirements change in this area, will I need to touch this file for only one type of reason?
  • OCP — If I need to add a new variant tomorrow, can I do it without editing this file?
  • LSP — If I substitute any subclass for its base, will callers still behave correctly?
  • ISP — Does every implementer of this interface actually use every method?
  • DIP — Does my business logic know which specific database, mailer, or cloud provider I’m using?

If most answers are yes, your design is healthy. If most are no, you’ve identified the seams to refactor first.

Further Reading

  • Clean Architecture — Robert C. Martin (2017). The canonical book-length treatment.
  • Working Effectively with Legacy Code — Michael Feathers (2004). How to apply these principles to code that doesn’t want to be changed.
  • Domain-Driven Design — Eric Evans (2003). A richer framework that subsumes SOLID when applied to complex business domains.

Code is read far more often than it’s written. SOLID is one of the cheapest tools we have for making that reading pleasant.

Comments powered by Giscus are not yet configured. Set PUBLIC_GISCUS_REPO_ID and PUBLIC_GISCUS_CATEGORY_ID in apps/web/.env to enable.

PV

เขียนโดย พลากร วรมงคล

Software Engineer Specialist ประสบการณ์กว่า 20 ปี เขียนเกี่ยวกับ Architecture, Performance และการสร้างระบบ Production

เพิ่มเติมเกี่ยวกับผม

บทความที่เกี่ยวข้อง