9 min read

Beyond SOLID: Modern Design Principles Every Senior Engineer Needs

SOLID principles provide a solid foundation for object-oriented design, but modern distributed and large-scale applications require more. Explore the essential engineering principles — from the Law of Demeter and Composition over Inheritance to Immutability, Circuit Breaking, and CQRS — that bridge the gap between clean code and resilient systems.

👉 Need a refresher on the five core pillars first? Check out our companion guide: SOLID Principles: Building Robust and Maintainable Software.


The Real-World Limits of SOLID

Every software engineer eventually goes through the “SOLID phase.” You learn Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. You refactor classes, introduce interfaces everywhere, and feel like you have conquered software architecture.

Then, your application enters production.

Suddenly, you discover that code can strictly adhere to every letter of SOLID and still suffer from catastrophic flaws:

  • Classes form long, fragile chains of calls that collapse when a nested property changes.
  • Inheritance hierarchies become rigid mazes where altering a base class breaks downstream behavior across dozens of subclasses.
  • Shared mutable state triggers sporadic concurrency bugs that disappear in test environments and surface only under heavy load.
  • A slow third-party API exhausts thread pools, causing cascading outages across your entire cluster.

SOLID principles are micro-level heuristics designed to organize responsibilities within classes. To build systems that scale across teams, services, and high traffic, you need principles that govern component boundaries, data ownership, fault isolation, and cognitive simplicity.


The Law of Demeter (Principle of Least Knowledge)

The Law of Demeter (LoD), or the Principle of Least Knowledge, establishes a simple rule for object communication: only talk to your immediate neighbors. An object should only invoke methods on:

  • Itself and its own fields.
  • Objects passed in as parameters.
  • Objects it instantiates directly.

The “Train Wreck” Anti-Pattern

The most common violation of LoD is the “train wreck” call chain, where one object reaches deep into another object's internal structure:

// Anti-pattern: The caller knows way too much about internal plumbing
customer.getAccount().getBillingProfile().getDefaultCard().charge(amount);

This single line couples the caller to four distinct classes: Customer, Account, BillingProfile, and CreditCard. If your team decides to move credit cards under a new PaymentMethod abstraction, this line — and hundreds like it across the codebase — immediately breaks.


flowchart LR
    Caller["OrderService"]
    Order["Order Entity"]
    Customer["Customer Entity"]
    Wallet["Wallet Component"]

    Caller -->|"1. Direct Call"| Order
    Order -->|"2. Encapsulated"| Customer
    Customer -->|"3. Private Access"| Wallet
    Caller -.->|"Avoid Train Wreck:\norder.customer.wallet.charge()"| Wallet

The Clean Solution

Instead of navigating through an object graph, ask the immediate owner to perform the action for you:

// Idiomatic Solution: Tell, Don't Ask
order.processPayment(amount);

The Order class internally coordinates with Customer and the billing provider. The caller no longer cares how billing data is arranged internally. When internal structures change, the caller remains untouched.


Composition Over Inheritance (Favor “Has-A” Over “Is-A”)

In classic object-oriented textbooks, inheritance is presented as the primary mechanism for code reuse. In real-world enterprise engineering, deep inheritance hierarchies are one of the fastest paths to technical debt — a trap known as the Fragile Base Class problem.

When a subclass extends a parent class, it becomes tightly bound to the parent's internal implementation details. Modifying a protected method or changing constructor parameters in a base class can silently break subclasses scattered across multiple packages.


flowchart TD
    subgraph INH["Inheritance: Rigid & Coupled ('is-a')"]
        Vehicle["Vehicle (Base Class)"]
        SportsCar["SportsCar"]
        DeliveryTruck["DeliveryTruck"]
        Vehicle -->|"extends"| SportsCar
        Vehicle -->|"extends"| DeliveryTruck
    end

    subgraph COMP["Composition: Modular & Swappable ('has-a')"]
        Car["Car (Composed)"]
        Engine["Engine Strategy"]
        Telemetry["Telemetry Tracker"]
        Storage["Cargo Capacity"]
        Car -->|"has-a"| Engine
        Car -->|"has-a"| Telemetry
        Car -->|"has-a"| Storage
    end

Real-World Code Comparison

Consider building an order processor that needs custom notification behavior. With inheritance, you are forced to create subclasses for every combination of notification channel and processing rule:

// Anti-Pattern: Subclass explosion
class OrderProcessor { ... }
class EmailOrderProcessor extends OrderProcessor { ... }
class SmsAndEmailOrderProcessor extends EmailOrderProcessor { ... }
// What happens when you need Slack, Webhooks, or WhatsApp?

With composition, you treat behaviors as interchangeable components passed into the constructor:

public class OrderProcessor {
    private final PaymentGateway paymentGateway;
    private final NotificationService notificationService;

    // Injected via constructor - flexible, testable, and swappable at runtime
    public OrderProcessor(PaymentGateway paymentGateway, NotificationService notificationService) {
        this.paymentGateway = paymentGateway;
        this.notificationService = notificationService;
    }

    public void completeOrder(Order order) {
        paymentGateway.charge(order.getTotal());
        notificationService.notifyCustomer(order);
    }
}

Now you can combine any PaymentGateway with any NotificationService without creating a single subclass. In unit tests, you can inject lightweight mocks or stubs in one line of code.


Separation of Concerns (SoC) & Layered Boundaries

Separation of Concerns asserts that software should be divided into distinct sections, where each section addresses a separate operational concern. In backend engineering, this is the driving principle behind Layered, Hexagonal, and Clean Architecture.

When concerns blur — such as when an HTTP controller parses raw JSON payloads, runs business validations, crafts direct SQL queries, and sends emails all in one method — the code becomes impossible to test in isolation and terrifying to refactor.


flowchart TD
    API["Presentation Layer\n(REST Controllers, DTOs, HTTP Validation)"]
    Domain["Domain Service Layer\n(Core Business Logic & Invariants)"]
    DataAccess["Data Access Layer\n(Repositories, ORM Mappings)"]
    DB[("Infrastructure Storage\n(PostgreSQL, Redis, S3)")]

    API -->|"1. Validated DTO"| Domain
    Domain -->|"2. Execute Business Logic"| DataAccess
    DataAccess -->|"3. Execute Query"| DB
    DB -.->|"4. Database Records"| DataAccess
    DataAccess -.->|"5. Domain Entities"| Domain
    Domain -.->|"6. Result / View Model"| API

The Rule of Thumb

The One-Reason-To-Change Test: If your database schema changes from PostgreSQL to DynamoDB, your domain business logic should not change by a single line. If your web framework changes from Spring Boot or Express to Fastify, your database queries should remain completely unaffected. That is Separation of Concerns in practice.


The Principle of Least Astonishment (POLA)

The Principle of Least Astonishment (also known as the Principle of Least Surprise) states that a component should behave in the way users — in this case, other software engineers — naturally expect it to behave. If an API has high surprise value, it will inevitably cause production incidents.

Common violations in everyday code include:

  • Getters with hidden side effects: A method named calculateDiscount() that silently increments an internal counter or makes an external network call.
  • Surprise mutators: Passing a list to a method named filterInactiveUsers(List<User> users) that mutates the passed list in-place rather than returning a new collection.
  • Non-standard naming: Naming a database write method findOrSave() where 90% of callers assume it is a read-only query.
// VIOLATION: A query that secretly mutates database state
public User getUserProfile(String userId) {
    User user = repository.findById(userId);
    user.setLastLoginTimestamp(Instant.now()); // Surprise! Read triggers write
    repository.save(user);
    return user;
}

// POLA COMPLIANT: Explicit, predictable, separate methods
public User getUserProfile(String userId) {
    return repository.findById(userId);
}

public void recordUserLogin(String userId) {
    repository.updateLastLogin(userId, Instant.now());
}

Immutability by Default (Minimizing Mutable State)

Mutable state is the root cause of the vast majority of concurrency bugs, cache synchronization anomalies, and difficult-to-trace regression errors. When multiple threads or components share references to a mutable object, any part of the system can alter that object's state at any time — a phenomenon known as “spooky action at a distance.”

Modern senior engineering favors immutability by default. Once an object is initialized, its state can never be modified. If data must change, you create a new instance with the updated values.

// Anti-Pattern: Mutable entity vulnerable to race conditions
public class Money {
    private double amount;
    public void add(double extra) { this.amount += extra; } // Shared reference hazard
}

// Modern Solution: Immutable Record / Value Object
public record Money(BigDecimal amount, Currency currency) {
    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("Currency mismatch");
        }
        return new Money(this.amount.add(other.amount), this.currency);
    }
}

Immutable objects are inherently thread-safe without locks or synchronization. They can be freely shared across threads, cached in memory, and used as hash map keys without risk of corruption.


Defensive Design: Fail-Fast & Circuit Breaking

Writing clean classes means nothing if your service collapses during downstream network hiccups. In distributed systems, failure is inevitable: third-party payment gateways experience downtime, external APIs slow down, and network partitions occur.

Two fundamental principles protect your services:

  • Fail-Fast: Validate input invariants and precondition states immediately at the boundary. If invalid parameters arrive, fail instantly with a descriptive error instead of performing half of an expensive operation.
  • Circuit Breaking: If repeated calls to a remote dependency fail or time out, trip a circuit breaker to immediately return fallback responses rather than keeping hundreds of worker threads blocked waiting on dead connections.

flowchart LR
    Client(["Client App"])
    Breaker{"Circuit Breaker"}
    Service["Remote Service\n(Payment Gateway)"]
    Fallback["Fallback Handler\n(Cached / Queued)"]

    Client --> Breaker
    Breaker -->|"Normal Operations\n(CLOSED State)"| Service
    Breaker -->|"Failures Exceed Threshold\n(OPEN State: Fast Fail)"| Fallback

Architectural Scaling: CQRS & Event Sourcing

Most web applications start with standard CRUD (Create, Read, Update, Delete). You have a single database table — say, users or orders — and your backend uses the exact same model to save data and to query data:

Traditional CRUD:
[Save Order] ---> (Orders Table) <--- [View Order History]
Both actions fight for the same table, locks, and indexing strategy.

This works fine until your application grows. Soon, you hit an uncomfortable reality: reading data and writing data have completely opposing needs.

The Everyday Dilemma

  • When writing data: You want simplicity, speed, and consistency. You normalize tables to prevent duplicate records, and you keep indexes minimal so inserts and updates execute in milliseconds.
  • When reading data: Your users don't want normalized tables. An order details screen needs customer names, shipping addresses, line items, delivery tracking, and payment statuses. Gathering all of that requires expensive SQL joins across 6 different tables. If 50,000 customers view their orders simultaneously, your database locks up trying to compute joins.

What is CQRS?

CQRS stands for Command Query Responsibility Segregation. The idea is remarkably simple: stop forcing one database model to do two opposing jobs.

Split your application into two independent pathways:

  1. Commands (Writes): “Do something.” (e.g., PlaceOrder, ChangeShippingAddress). A command modifies state, validates business rules, and saves normalized data. It returns nothing or just an ID/status — no heavy query payloads.
  2. Queries (Reads): “Show me something.” (e.g., GetCustomerOrderHistory). A query never modifies data. It reads from pre-joined, denormalized views or search caches designed purely for ultra-fast reading.

flowchart TD
    User(["Client / Browser"])

    subgraph WritePath["Write Pathway (Commands)"]
        CmdService["Order Command Service"]
        PrimaryDB[("Primary Database\n(Normalized, ACID-Compliant)")]
    end

    subgraph ReadPath["Read Pathway (Queries)"]
        QueryService["Order Query Service"]
        ReadDB[("Read Database / Cache\n(Elasticsearch / Redis / Read Replica)")]
    end

    SyncWorker["Async Background Worker\n(Kafka / Event Queue)"]

    User -->|"1. Submit Command: PlaceOrder()"| CmdService
    CmdService -->|"2. Validate & Write"| PrimaryDB
    PrimaryDB -->|"3. Trigger Change Event"| SyncWorker
    SyncWorker -->|"4. Update Pre-calculated View"| ReadDB
    User -->|"5. Instant Fetch: GetOrderHistory()"| QueryService
    QueryService -->|"6. No Joins Needed"| ReadDB

A Real-World Example: E-Commerce Product Page

To see CQRS in action, compare how both approaches handle an e-commerce catalog:

  • Without CQRS (Traditional CRUD): An admin updates the price of a laptop. That same table is constantly queried by millions of shoppers searching for “gaming laptops with 16GB RAM”. The database struggles under write locks and heavy multi-table filtering queries.
  • With CQRS: The admin updates the price through the Command service, writing to the primary database. An event syncs that change into an Elasticsearch index or Redis read model. When millions of shoppers browse laptops, their requests never touch the write database — they hit the read cache directly with zero database joins.

What About Event Sourcing?

CQRS is often paired with Event Sourcing, but they are distinct concepts.

In traditional databases, updates overwrite the past. If a customer changes their address from New York to Austin, the row is updated. The fact that they ever lived in New York is erased forever unless you built custom audit logging.

Event Sourcing stores events, not final states:

  • Instead of storing a single row: { balance: $750 }
  • You store an append-only log of facts:
    • AccountOpened (deposit: $1,000)
    • MoneyWithdrawn ($300)
    • CashbackReceived ($50)

Think of your personal bank statement or Git version control. Git doesn't just keep the current version of your project; it stores every commit. Your current code is simply the result of playing every commit from the beginning.

When should you use CQRS?
Do not build CQRS for simple apps or low-traffic services — it introduces eventual consistency and added infrastructure complexity. Reach for CQRS when your read volume dwarfs your write volume (e.g., 100:1 ratio), when complex searches slow down your primary transactional database, or when business audits require an indelible record of every action taken.


Related Guides:

Conclusion: Principles Are Heuristics, Not Dogmas

SOLID principles give you a great vocabulary for organizing classes, but they are only step one. Mastering senior-level software design means understanding when and how to zoom out:

  • Use the Law of Demeter to prevent train-wreck couplings across your modules.
  • Rely on Composition to assemble swappable components without fragile base classes.
  • Enforce Separation of Concerns so framework changes never taint your domain logic.
  • Write predictable, Least-Astonishing APIs that make the right path the obvious path.
  • Default to Immutability to eliminate entire categories of concurrency and state bugs.
  • Employ Circuit Breakers and CQRS when your applications expand into high-throughput distributed systems.

Remember: design principles are not dogmas to be applied blindly. They are risk-reduction tools. Use them thoughtfully to keep your codebase pleasant to read, easy to test, and resilient in production.

← PreviousDecoding UI/UX DesignNext →Design Trade-offs