SOLID Principles in Practice

Apply all five SOLID principles to a small checkout design, see where they help, and learn when extra interfaces or indirection make code harder to change.

published: reading time: 12 min read author: GeekWorkBench
Quick Summary

This guide uses a checkout workflow to show how SOLID principles shape boundaries between pricing, payments, storage, and receipts. It explains what each principle helps with, where added interfaces create noise, and how contracts can fail under real provider behavior. Use the examples and review questions to decide which seams protect a likely change and which abstractions add work without value.

SOLID Principles in Practice

Introduction

SOLID is a set of five object-oriented design heuristics: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. They help when a real change keeps landing in the wrong place, or when one component knows too much about the details around it. They are not a scorecard. A class can be small and direct without having its own interface, factory, and adapter.

We’ll use a checkout flow as a running example. It calculates a total, charges a payment method, stores the order, and sends a receipt. That is enough behavior to show all five principles, including where the abstractions stop paying for themselves.

Start with the change pressures

Suppose a single Checkout class calculates promotions, calls a card provider, writes SQL, and formats email. A promotion rule change can break persistence code because both live in one place. A second payment provider requires edits to that class. A test that only wants to check the total now needs database and payment setup.

Before splitting the class, name the likely changes: pricing rules, payment provider behavior, persistence technology, and receipt delivery. A responsibility is not simply “one method” or “one thing.” It is a set of behavior that tends to change for the same reason. If pricing policy and email delivery are changed by different requirements, they probably do not belong in the same module.

A small design with explicit seams

The following TypeScript sketch keeps the checkout workflow in one place and expresses its collaborators as small contracts. Production implementations might wrap a payment SDK, database, and email provider; unit tests can supply fakes.

interface Order {
  id: string;
  subtotalCents: number;
}

interface DiscountPolicy {
  discountCents(order: Order): number;
}

interface PaymentGateway {
  charge(orderId: string, amountCents: number): Promise<void>;
}

interface OrderStore {
  save(order: Order): Promise<void>;
}

interface ReceiptSender {
  send(orderId: string, totalCents: number): Promise<void>;
}

class CheckoutService {
  constructor(
    private readonly discounts: DiscountPolicy,
    private readonly payments: PaymentGateway,
    private readonly orders: OrderStore,
    private readonly receipts: ReceiptSender,
  ) {}

  async checkout(order: Order): Promise<number> {
    const discount = this.discounts.discountCents(order);
    if (discount < 0 || discount > order.subtotalCents) {
      throw new Error("Discount is outside the order subtotal");
    }

    const total = order.subtotalCents - discount;
    await this.payments.charge(order.id, total);
    await this.orders.save(order);
    await this.receipts.send(order.id, total);
    return total;
  }
}

The service coordinates a use case. Its dependencies make the extension points visible at construction time, and the discount range check protects a business invariant at the boundary. The code is intentionally small: it does not introduce a generic plugin registry or a framework container.

flowchart LR
  Order[Order] --> Checkout[Checkout service]
  Policy[Discount policy] --> Checkout
  Checkout --> Payment[Payment gateway]
  Checkout --> Store[Order store]
  Checkout --> Receipt[Receipt sender]
  Provider[Provider implementations] -. implement .-> Payment

The dashed relationship indicates that a concrete provider implements the contract the checkout service calls. The service can stay unaware of a particular payment SDK or mail API.

The five principles in this design

Single Responsibility Principle

A module should have one reason to change, where “reason” means a stakeholder or requirement that affects its behavior. CheckoutService has the workflow responsibility: compute the amount, charge it, save the order, then request a receipt. Pricing policy, provider communication, storage, and message formatting can change independently behind their own components.

This does not mean every method needs a class. If a tax calculation is a stable, short function used only by this workflow, a function may be the clearest home for it. Split along change boundaries that exist, not along every noun in the domain.

Open/Closed Principle

A component should allow a likely variation to be added without repeatedly editing its stable core. If a seasonal promotion is a real extension point, implement another DiscountPolicy and select it in application wiring. CheckoutService continues using the same contract.

This is not a ban on modifying code. A new legal pricing rule may require changing the policy that owns pricing. OCP is useful when the variation is known and recurring; building an abstraction for hypothetical future promotions can cost more than changing a small conditional once.

Liskov Substitution Principle

An implementation must honor the contract callers rely on. Every DiscountPolicy needs to return a discount in the range the checkout service accepts. A special “no discount” implementation returning a negative value, or one that throws for ordinary orders, is not substitutable even if the language accepts it as the interface type.

The same applies to payment implementations. If charge resolves only after authorization, every gateway implementation must preserve that meaning. An adapter that returns before the charge outcome is known changes the contract and can cause the workflow to save an order as paid when it is not. Write contract tests for behavior that matters across implementations; matching method signatures alone is insufficient.

Interface Segregation Principle

Clients should depend only on operations they use. Payment, storage, and receipt delivery are separate contracts here. A PaymentGateway does not need saveOrder or sendReceipt. A single CheckoutDependencies interface containing every application operation would force each collaborator or test double to provide irrelevant methods.

Small interfaces are not automatically better. If only one implementation exists and no independent consumer benefits from a seam, a concrete class can be simpler. Split contracts when their clients or change patterns differ.

Dependency Inversion Principle

High-level policy should not depend directly on low-level implementation details. CheckoutService depends on domain-level contracts; infrastructure classes implement those contracts. Runtime calls still go from checkout to the provider, but source-level dependencies can point from provider code toward the contract owned by the application.

Dependency injection is one way to supply the implementation. It is not the principle itself: the important part is that policy does not import a vendor SDK or database driver. The object graph can be assembled in a small composition root at startup. See the project’s dependency injection guide for how wiring and object lifetimes fit together.

When to Use

Use SOLID as a set of review questions when a change causes unrelated code edits, when tests need costly infrastructure for a narrow behavior, or when replacing one implementation keeps changing callers. A useful first step is to trace one change request through the code: which files must move, and why?

It is especially practical at boundaries that often vary, such as payment providers, persistence, external messaging, and policies with several real business variants. These are places where a small contract can limit how far a change travels.

When NOT to Use

Do not split every class because it has more than one method. Do not create an interface just to mock a stable utility, or add a strategy hierarchy for a discount that will never vary. A direct function call is often easier to navigate than an interface plus a factory plus one implementation.

Avoid applying OCP to every possible future request. Until a variation is likely enough to matter, a local edit may be cheaper. If an abstraction makes the common path harder to read, adds more files than behavior, or requires a developer to jump across layers to understand one operation, reconsider it.

Production Failure Scenarios

Failure How the design can fail Response
Payment times out after the provider accepted the charge A retry may charge the customer twice if charge is not idempotent. Send a stable idempotency key and reconcile the provider result before retrying.
Payment succeeds but order persistence fails The customer is charged without a durable order record. Use a recoverable workflow: persist intent, record provider references, and reconcile incomplete states. A class boundary alone does not make two systems atomic.
Receipt provider is unavailable Checkout may report failure after payment and order persistence completed. Decide whether receipt delivery is part of checkout success. Often it belongs in a retryable outbox or job.
A replacement gateway violates the contract One implementation returns before authorization or reports ambiguous errors. Run shared contract tests against each adapter and document timeout/error semantics.

SOLID reduces some forms of coupling; it does not solve distributed transactions, retries, duplicate delivery, or inconsistent provider behavior. Those need explicit workflow and operational decisions.

Trade-Off Table

Principle What it can improve Cost or risk when pushed too far
SRP Changes with different owners stay separate. A class for every tiny operation obscures the flow.
OCP New variants can fit behind a stable contract. Speculative extension points make simple rules harder to follow.
LSP Implementations behave consistently for callers. Deep inheritance can make contracts brittle; composition may fit better.
ISP A client sees only the operations it needs. Too many tiny interfaces make navigation and wiring noisy.
DIP Business policy is less tied to infrastructure. Abstracting every stable dependency adds indirection without useful substitution.

Observability Checklist

  • Record checkout outcomes with a correlation or order ID, without logging payment credentials or full personal data.
  • Track payment authorization, persistence, and receipt delivery as separate outcomes; one “checkout failed” counter hides partial success.
  • Measure retries and idempotency conflicts by provider and operation.
  • Alert on old pending orders and repeated reconciliation failures.
  • Include adapter and contract version in diagnostic context when implementations can differ.
  • Keep domain events or structured logs clear about whether a charge was authorized, captured, or merely requested.

Security and Compliance Notes

Keep card data out of the domain model and logs. Prefer provider-hosted tokenization or another approved payment boundary, and pass opaque tokens rather than raw card details through the checkout workflow. Apply least privilege to payment and storage credentials, rotate them through the platform’s secret manager, and avoid exposing provider errors directly to customers.

Interfaces do not create a security boundary by themselves. Validate authorization and amount rules in trusted application code, protect webhook verification, and restrict access to order records. Confirm applicable payment and privacy requirements with the organization’s compliance owner; requirements depend on the data and deployment context.

Common Pitfalls and Anti-Patterns

  • One interface per class: An interface is useful when it protects a meaningful boundary or contract, not as a required twin for every class.
  • “One reason” interpreted as one method: SRP is about cohesive change, not method count.
  • Abstraction for imagined change: The design pays its complexity cost now; add an extension seam when evidence says it is useful.
  • Inheritance as the default reuse tool: A subclass may inherit assumptions it cannot honor. Prefer a collaborator when behavior varies independently.
  • Confusing dependency injection with DIP: A container can inject concrete dependencies and still leave policy coupled to infrastructure.
  • Treating unit tests as proof of substitutability: Fakes can accidentally accept behavior that the real adapter rejects. Keep adapter and contract tests too.
  • Assuming interfaces guarantee reliability: They do not make remote calls atomic, fast, secure, or available.

Quick Recap Checklist

  • Can I name the responsibility and the kind of change that should affect this component?
  • Is there a real variation point that justifies extension instead of a direct edit?
  • Does every implementation honor the caller-visible contract, including error and timing behavior?
  • Does each client depend only on the operations it uses?
  • Does business policy depend on contracts rather than vendor or infrastructure details?
  • Has the abstraction reduced a concrete change or test cost enough to justify its extra moving parts?

Interview Questions

1. What does the Single Responsibility Principle mean in practice?

A component should own behavior that changes for one coherent reason, often associated with one stakeholder or policy. It does not mean one method per class. In the checkout example, pricing and receipt delivery have different change pressures, so they should not be coupled in one module.

2. How is Dependency Inversion different from Dependency Injection?

Dependency Inversion is a design principle about source dependencies: high-level policy relies on abstractions rather than low-level details. Dependency Injection is a way to provide an object with its collaborators. Injection can support inversion, but it does not guarantee it.

3. How can you tell if an abstraction is over-engineering?

Look at the cost it removes and the cost it adds. If an interface has one stable implementation, no meaningful test or deployment seam, and makes a common operation harder to trace, a direct dependency may be clearer. Revisit the choice when a real change pattern appears.

Further Reading

Conclusion

SOLID helps you spot where change is leaking across responsibilities, implementations, and callers. In the checkout example, a small set of contracts separates policy from payment, storage, and receipt details while preserving a readable workflow. The design is useful because each seam protects a plausible change; adding more seams without evidence would make it worse.

Category

Related Posts

Creational Patterns: Control Object Creation

Compare five creational patterns, see a compact TypeScript example, and choose the simplest fit for product families, construction, copying, or shared state.

#object-oriented-design #design-patterns #creational-patterns

Design Review: Trade-Offs and Maintainability

Review object designs for change, coupling, cohesion, tests, performance, operations, and security using a worked example and the practical checklist.

#object-oriented-design #design-review #maintainability

GRASP: Assigning Object Responsibilities

Learn all nine GRASP patterns through a Java order workflow, with code, trade-offs, failure cases, and design review questions for intermediate developers.

#object-oriented-design #grasp #responsibility-assignment