SOLID Principles in Backend Design

LESSON

Backend and API Architecture

008 30 min intermediate

SOLID Principles in Backend Design

The core idea: SOLID is most useful in backend design as a diagnostic for change pressure, trading short-term directness for boundaries that keep policy, variants, contracts, and infrastructure from collapsing into one fragile module.

Core Insight

Imagine the EnrollmentService has survived several rounds of product growth. It started by enrolling a learner in a course. Then it added coupons, enterprise discounts, instructor overrides, payment retries, audit records, waitlists, notifications, and a special path for imported corporate learners.

No single change looked unreasonable. Each one was just another condition, another collaborator, or another side effect. But after a few months, the service is hard to edit safely. A pricing change touches enrollment eligibility. A new notification rule risks breaking payments. A test fake passes because it ignores a failure the real payment adapter can raise. A repository boundary exists, but the service still imports ORM-specific exceptions.

SOLID is often taught as five letters to memorize. In backend work, it is better read as five recurring design questions: What reasons to change are colliding here? Can new behavior be added without rewriting a brittle center? Can substitutes keep the promises consumers rely on? Is this contract forcing consumers to know too much? Is policy depending directly on volatile infrastructure?

The misconception is that SOLID demands more classes. It does not. Sometimes the SOLID move is to delete an abstraction that hides nothing. The value is diagnostic: it helps you notice when the code shape no longer matches how the backend changes.

The Pressure: One Service Starts Carrying Every Change

Here is the shape that usually appears before anyone talks about principles:

class EnrollmentService:
    def enroll(self, learner_id: str, course_id: str, request):
        course = self.db.query_course(course_id)

        if request.user.role == "instructor":
            allowed = True
        elif request.user.enterprise_account_id:
            allowed = self.enterprise_rules.can_enroll(request.user, course)
        else:
            allowed = course.is_open and course.has_available_seat

        if not allowed:
            raise EnrollmentRejected("not_allowed")

        if request.coupon_code:
            price = self.apply_coupon(course.price, request.coupon_code)
        elif request.user.enterprise_account_id:
            price = self.apply_enterprise_discount(course.price, request.user)
        else:
            price = course.price

        self.stripe.charge(learner_id, price)
        enrollment = self.db.insert_enrollment(learner_id, course_id)
        self.email.send_confirmation(learner_id, course_id)
        self.audit.write("learner_enrolled", enrollment.id)
        return enrollment

This code has several different reasons to change:

That is the real design smell. The problem is not that the class is "too long" in some abstract way. The problem is that unrelated changes are forced through the same piece of code, so each product request increases the chance of accidental damage.

SOLID gives names to the pressure. The names are useful only if they help you reshape the code around the way it actually changes.

Reading the Five Principles as Design Questions

The Single Responsibility Principle asks:

What reason to change owns this code?

It is not saying every class should do one tiny thing. It is saying that unrelated reasons to change should not be fused. Enrollment workflow, discount calculation, payment provider integration, and email rendering may all happen during one use case, but they do not change for the same reason.

The Open/Closed Principle asks:

Can the next variant be added without editing the most fragile center?

This matters when variants keep arriving. If every new discount or notification path means editing one growing conditional block, the backend becomes harder to reason about. A better design often moves the varying behavior behind a policy, strategy, adapter, or configuration boundary.

The Liskov Substitution Principle asks:

Can a substitute implementation preserve the behavior the consumer relies on?

In backend systems, this is less about inheritance trivia and more about adapters and fakes. A fake payment gateway that always succeeds is not a trustworthy substitute if the real gateway can fail after a retryable timeout, reject a card, or return an idempotency conflict.

The Interface Segregation Principle asks:

Is this consumer depending only on the capability it actually needs?

If EnrollmentService depends on a giant PlatformServices object with payments, email, reports, user creation, cache invalidation, and analytics, it becomes coupled to changes it does not care about. Smaller capability contracts make dependencies easier to understand and test.

The Dependency Inversion Principle asks:

Is core policy pointing toward stable capabilities, or toward volatile details?

Enrollment policy should know it can charge payment, reserve a seat, create enrollment, and emit an event. It should not need to import a concrete payment SDK, a web request class, an ORM session, or a queue client directly.

The trade-off is structure versus immediacy. Direct code is faster while the behavior is simple. Once variants, tests, and teams multiply, explicit boundaries can reduce blast radius.

Worked Example: Discounts Without a Growing Conditional

Suppose discount rules keep changing. First there are coupons. Then enterprise discounts. Then instructor grants. Then promotional campaigns. The naive approach keeps editing the central enrollment method:

if coupon_code:
    price = apply_coupon(price, coupon_code)
elif enterprise_account:
    price = apply_enterprise_discount(price, enterprise_account)
elif campaign_id:
    price = apply_campaign(price, campaign_id)
else:
    price = course.price

This is an Open/Closed pressure point because the next variant reopens the same branch. It is also a Single Responsibility pressure point because pricing policy is changing the enrollment workflow.

A better shape can make pricing a capability:

class EnrollmentService:
    def __init__(self, pricing, payments, enrollments):
        self.pricing = pricing
        self.payments = payments
        self.enrollments = enrollments

    def enroll(self, learner_id: str, course_id: str, context: EnrollmentContext):
        price = self.pricing.price_for(course_id, learner_id, context)
        self.payments.charge(learner_id, price)
        return self.enrollments.create(learner_id, course_id)

Now the workflow remains visible: price, charge, create enrollment. Pricing can evolve behind pricing.price_for(...). That does not mean the pricing subsystem is automatically simple. It means the enrollment workflow no longer owns every pricing variant.

This is the practical SOLID move: pull apart behavior that changes for different reasons, but only where the pressure is real. For a product with one fixed price forever, a pricing abstraction may be noise. For a product where pricing changes weekly, it is a stabilizing boundary.

Contracts Must Preserve Real Behavior

The Liskov and Interface Segregation principles become concrete when you test or swap collaborators.

Imagine this payment contract:

class Payments:
    def charge(self, learner_id: str, amount_cents: int) -> ChargeResult:
        ...

A real adapter may return success, rejection, retryable timeout, duplicate idempotency key, or provider unavailable. A test fake that only returns success is convenient, but it teaches the service that failure does not exist. The type matches, but the behavior contract is too weak.

A better fake preserves the cases the service must handle:

class RecordingPayments:
    def __init__(self, result: ChargeResult):
        self.result = result
        self.calls = []

    def charge(self, learner_id: str, amount_cents: int) -> ChargeResult:
        self.calls.append((learner_id, amount_cents))
        return self.result

This fake is small, but it can represent success, rejection, or retryable failure. The service can be tested against the same behavioral categories it faces in production.

Interface size matters too. If enrollment only needs charge, it should not depend on a broad payment administration API with refunds, invoices, customer search, tax reporting, and account onboarding. A broad interface creates accidental coupling. A tiny but meaningful capability contract keeps the dependency honest.

The trade-off is contract precision versus noise. Too broad and consumers become coupled to unrelated changes. Too fragmented and the codebase becomes a pile of one-method abstractions. The right boundary represents a capability the use case actually depends on.

Dependency Direction Keeps Policy Above Details

The previous lessons already introduced the practical tools: layered architecture, dependency injection, repositories, and units of work. SOLID gives those tools a sharper reason.

Dependency Inversion says that high-level policy should not point directly at low-level details. In this track, "policy" means code that expresses product behavior: enrollment eligibility, pricing workflow, validation meaning, error semantics, and persistence intent. "Details" are things like HTTP request objects, concrete ORM sessions, payment SDK classes, queue client APIs, and database-specific exceptions.

A useful direction is:

application policy
      |
      v
small capability contract
      ^
      |
infrastructure adapter

This matches the earlier repository lesson. EnrollmentService can depend on Enrollments or a unit of work capability. A PostgresEnrollments adapter can depend on SQLAlchemy, PostgreSQL, locks, and constraints. The core service should not need to know those details unless the detail is truly part of the policy decision.

This is not a rule against concrete code. The concrete adapter is necessary. The rule is about which direction the dependency points. Policy should be stable enough to survive a new web framework, a new queue, a new payment adapter, or a better persistence implementation.

Failure Modes and Design Limits

The first failure mode is SOLID theater: creating interfaces, factories, strategies, and base classes before any real change pressure exists. That produces indirection without leverage.

The second failure mode is treating Single Responsibility as "one method per class." Responsibility is about reasons to change, not line count. A small class can still mix unrelated reasons to change.

The third failure mode is testing against substitutes that do not preserve production behavior. A fake that never fails can make failure-handling code look correct while leaving the important cases untested.

The fourth failure mode is using Dependency Inversion as "interfaces everywhere." A meaningful contract describes a capability that policy needs. A meaningless contract only renames a concrete class.

The limit is that SOLID does not choose your architecture for you. It cannot tell you whether a monolith, modular monolith, or microservice boundary is correct. It helps you inspect local design pressure once you understand how the system changes.

Connections

The previous lesson on persistence boundaries showed one concrete use of Dependency Inversion: services ask repositories or units of work for domain-relevant persistence capabilities while database details live in adapters.

The next lesson on validation at backend boundaries applies the same diagnostic habit to data quality. A validation rule should live where the code has the knowledge to reject it, rather than in whichever layer happens to be convenient.

SOLID also prepares the later contract-testing lesson. Substitution is only trustworthy when contracts describe real behavior and tests exercise the promises consumers rely on.

Resources

Key Takeaways

PREVIOUS Repositories, Units of Work, and Persistence Boundaries NEXT Validation at Backend Boundaries