Understanding SOLID in Context
This post brings together notes from earlier writing on SOLID and related design principles.
The aim is to provide an accessible overview in one read, demonstrating how these principles reinforce one another, and how they connect to broader concerns in object-oriented design and architecture.
It also shows how many modern principles are refinements or expressions of older ones.
SOLID
The SOLID principles are often taught as isolated rules, but in practice they are most useful when understood as a connected set of design heuristics. Each principle reinforces the others, and each becomes clearer when seen alongside related design ideas.
At an architectural level, this way of thinking aligns naturally with Clean Architecture: details should depend upon abstractions; business rules should not know how the system stores data or presents it. High-level policy depends on abstractions, while low-level details implement them.
The beauty of SOLID lies not just in the individual principles, but in their interdependence. The Single Responsibility Principle (SRP) sharpens responsibility assignment. The Open-Closed Principle (OCP) protects stable behavior while allowing extension. The Liskov Substitution Principle (LSP) preserves trust in abstraction. The Interface Segregation Principle (ISP) keeps client-facing contracts focused. The Dependency Inversion Principle (DIP) turns abstractions into architectural boundaries.
Together, they form a practical toolkit for object-oriented design.
Single Responsibility and Responsibility Assignment
SRP teaches that a class should have only one reason to change. At heart, this is about responsibility assignment. It asks whether a class is truly centered on one coherent concern, or whether it has become a container for multiple unrelated changes. It is closely tied to cohesion.
A cohesive class is easier to name, easier to understand, and easier to change safely. When classes start accumulating vague names such as Manager, Helper, or Processor, that is often a hint that responsibilities have become muddled. Semantics are signposts. A class that is difficult to name precisely is often an indication that it has too many concerns. A simple example makes the point:
public class Order
{
public void ProcessOrder();
public void PrintOrder();
public void ShowOrder();
}
Order mixes domain behavior with presentation concerns. Processing the order may belong to the order itself, but printing and displaying it are separate responsibilities with separate reasons to change.
SRP can be understood as a specific application of Separation of Concerns (SoC) at the class level. SoC is the broader principle: different concerns should be addressed in different places. SRP sharpens that into a more local question: does this class have one coherent job, or several competing ones?
Here, GRASP helps clarify how to keep responsibilities well placed. Information Expert suggests that behavior should live where the necessary knowledge already exists. High Cohesion reminds us that only responsibilities which truly belong together should stay together.
Sometimes that means introducing a new abstraction that is not directly part of the problem domain. GRASP calls this Pure Fabrication. If assigning a responsibility to a domain object violates SRP, it can be cleaner to invent a dedicated type to carry that responsibility. The fabricated type may not correspond to a real-world noun, but it can still improve the design by distributing responsibilities more cleanly.
This is one reason SRP should not be reduced to "small classes are good". The real issue is not size, but coherence. A class with ten methods may still have one responsibility, while a class with three methods may already be doing too many unrelated things.
Rebecca Wirfs-Brock's work on object roles and agency also fits naturally here. Good objects are not simple collections of properties. They have agency: they hold responsibility, protect their own invariants, and collaborate with other objects. A die should roll itself. A shopping cart should manage its own contents. A payment gateway should process payments. When responsibility is placed where it belongs, behavior becomes easier to reason about, and objects stop turning into oversized coordinators.
Roles sharpen this further. An object may participate in several collaborations, but it should not absorb every operation associated with them. A User may act as a buyer in one context and a seller in another, yet the responsibilities of buying and selling still belong primarily to other collaborating objects such as a ShoppingCart, ProductListing, or PaymentGateway. The user supplies identity and relevant data; it should not become the dumping ground for every workflow in which it participates.
Domain-Driven Design (DDD) reinforces the same idea at a larger scale. Bounded contexts separate distinct areas of responsibility. Aggregates protect specific consistency boundaries. Domain services exist precisely because some behaviors do not belong naturally inside an entity or value object. Repositories isolate persistence concerns. In each case, the underlying judgment is the same: keep responsibilities coherent, and do not let unrelated reasons to change accumulate in the same place.
SRP should not be over-applied. If every operation is pushed into its own class, the result is fragmentation rather than clarity. Over-simplification creates thin wrappers, trivial indirections, and classes that are technically pure but practically exhausting. Classes such as OrderAddProduct, OrderRemoveProduct, and OrderCalculateTotal might satisfy a rigid interpretation of SRP, but together they may make the system harder to understand than a well-designed Order object ever would.
SRP is a guide to responsibility boundaries, not a mandate for endless splitting. Its purpose is to separate concerns that change for different reasons, while keeping together those that belong together. Used well, it leads to higher cohesion, clearer semantics, and object designs that feel natural rather than forced.
Open for Extension, Closed Around Stable Behavior
OCP is often summarized as "open for extension, closed for modification." The point is not that code should never be changed, but that stable, trusted behavior should not need to be constantly rewritten in order to support new cases. A simple example is a base type designed to be extended:
public abstract class Shape
{
public abstract double Area();
}
public class Rectangle : Shape
{
public double Height { get; set; }
public double Width { get; set; }
public override double Area() => Height * Width;
}
public class Circle : Shape
{
public double Radius { get; set; }
public override double Area() => Radius * Radius * Math.PI;
}
Behavior is added by introducing new shapes, not by reopening and modifying existing implementations each time a new case appears. This principle is strongly associated with Bertrand Meyer, who framed it as one of the central ideas of object-oriented construction. Once a class is developed and trusted, new functionality should ideally be introduced by extension rather than by repeatedly altering stable code. That reduces the chance of regressions and makes the system easier to evolve safely.
OCP is powerful alongside Design by Contract (DbC). A well-defined abstraction establishes expectations through its contract: what inputs are valid, what outputs are guaranteed, and what invariants must always hold. Extension then happens inside that protected boundary. Preconditions guard what may enter, postconditions guard what must come out, and invariants protect the object's core state. OCP is not just about polymorphism; it is about extending behavior without quietly corrupting trusted assumptions.
This is also where extension points matter. In a well-designed class, the points intended for variation should be visible and deliberate. Abstract methods, virtual methods, interfaces, strategy objects, and policy abstractions all serve this purpose. They do not merely enable extension; they communicate to other developers where variation is expected and how it should happen.
GRASP's Protected Variations principle fits naturally here. The idea is to identify likely points of instability and place a protective abstraction around them so changes do not ripple through the rest of the system. Database providers, external services, pricing policies, serialization strategies, and presentation mechanisms are all typical sources of variation. OCP provides the practical discipline: keep stable behavior closed, and expose carefully chosen seams where extension is expected.
That does not mean every possible future change deserves an abstraction. This is where design judgment becomes important. There is a real tension between predicted variation and speculative generality. Sometimes a variation is genuinely likely, based on requirements, domain knowledge, or known business pressure. In those cases, protecting against it is wise. If not, the abstraction has no value.
This is why OCP must be balanced with YAGNI. Designing for extension is useful when the point of variation is credible. Designing for every hypothetical future is how systems become over-engineered. Good design is not about maximizing flexibility in the abstract; it is about placing flexibility where it is justified.
OCP preserves trust: stable code should stay stable, variation should be channeled through explicit seams, contracts should protect what matters, and abstractions should be introduced in response to likely change, not speculative anxiety. Applied well, OCP makes software easier to extend without making it fragile. Applied poorly, it can degenerate into speculative architecture.
Substitutability and Behavioral Trust
LSP is less about inheritance syntax than about behavioral trust. If a subtype cannot stand in for its supertype without surprising the caller, then the abstraction has failed. A classic example is a subtype that technically inherits from a base type, but violates an expected behavior:
public class Bird
{
public virtual void Fly() {}
}
public class Crow : Bird { }
public class Ostrich : Bird
{
public override void Fly()
{
throw new NotImplementedException();
}
}
Here, Ostrich is substitutable only in the narrow type-theoretic sense that it inherits from Bird.
Behaviorally, it breaks the expectation that a Bird can fly. Any code written against the abstraction now has to start making exceptions for specific subtypes. That is exactly what LSP is meant to prevent.
LSP is tightly linked to OCP. Once substitutability is broken, extension stops being safe. Client code has to branch around the violating subtype, and the abstraction is no longer a reliable extension point.
In practice, a violation of LSP often forces a violation of OCP.
DbC makes the principle even clearer: a subtype should not strengthen preconditions, weaken postconditions, or violate invariants established by the supertype. If a base type says, in effect, "you may call me under these conditions, and I will guarantee this result," then a subtype must honor that promise. It may refine behavior, but it must not undermine the contract.
This applies to error behavior. If a base method does not establish that callers must expect a certain kind of failure, then a subtype should not introduce new exceptions or failure modes as part of ordinary use. Otherwise, the subtype silently changes the expectations of the abstraction and breaks trust.
LSP keeps abstractions honest: does the subtype preserve the meaning of the base type, or does it merely reuse its shape while weakening the design? That is a crucial distinction.
LSP pushes designers toward stronger abstractions. Sometimes that means a hierarchy was drawn too broadly. If Bird was never really a good abstraction for "things that fly," then the problem is not just the Ostrich; the problem is that the parent type promised too much. Better abstractions preserve substitutability because they encode the right guarantees from the start.
LSP creates confidence. A caller should not have to care which subtype it receives. If it does, then the hierarchy is leaking behavioral inconsistency. That is the warning sign. LSP is about preserving trust in abstraction so that extension, reuse, and polymorphism remain safe rather than merely convenient.
Focused Interfaces and Cohesive Clients
ISP says that clients should not be forced to depend on methods they do not use. Like SRP, it is really about cohesion. A good interface gathers together operations that genuinely belong together from the client's point of view. A simple example makes the problem clear:
public interface IOrder
{
void Purchase();
void Deliver();
}
public class OnlineOrder : IOrder
{
public void Purchase()
{
// Implementation of purchase method for online order
}
public void Deliver()
{
// Implementation of deliver method for online order
}
}
public class InStoreOrder : IOrder
{
public void Purchase()
{
// Implementation of purchase method for in-store order
}
public void Deliver()
{
// Method is not required but has to be defined to satisfy interface
}
}
Here, InStoreOrder is forced to depend on a delivery operation that it does not need. The interface is too broad for the client. A better design is to split the contract into interfaces that match actual responsibilities:
public interface IOrderPurchase
{
void Purchase();
}
public interface IOrderDeliver
{
void Deliver();
}
public class OnlineOrder : IOrderPurchase, IOrderDeliver
{
public void Purchase()
{
// Implementation of purchase method for online order
}
public void Deliver()
{
// Implementation of deliver method for online order
}
}
public class InStoreOrder : IOrderPurchase
{
public void Purchase()
{
// Implementation of purchase method for in-store order
}
}
Now each class depends only on what it actually needs.
This is why ISP is so closely related to cohesion. Fat interfaces are often a sign that unrelated responsibilities have been bundled together for the convenience of the implementer rather than the clarity of the client. GRASP's High Cohesion principle points in the same direction: related concerns should stay together, and unrelated ones should not be forced into the same abstraction.
At the same time, ISP can be over-applied. Avoiding fat interfaces does not mean carving every contract into tiny fragments. Too many highly specific interfaces can create a different kind of complexity, where the design becomes difficult to follow because the conceptual whole has been scattered into dozens of small pieces. The goal is not maximal thinness, but focus.
A good interface should reflect a meaningful client view of the system. It should expose what a given client genuinely needs, and no more. If clients repeatedly implement or depend on methods they do not care about, the interface is polluted. But if every tiny distinction becomes its own interface, the design may be drifting into fragmentation.
ISP should lead to interfaces that are cohesive, discoverable, and shaped by usage. Used well, it reduces coupling, limits the impact of change, and makes abstractions easier to understand. Used mechanically, it can produce a forest of thin interfaces that are technically pure but practically exhausting.
Dependency Inversion and Architectural Boundaries
DIP is what object-oriented design looks like at architectural scale. High-level policy should not depend directly on low-level detail. Both should depend on abstractions. A small example shows the idea:
public class Customer
{
private IOrder order;
public Customer(IOrder order)
{
this.order = order;
}
public void Purchase()
{
order.Purchase();
}
}
Here, Customer does not depend on a specific concrete Order implementation. It depends on the abstraction IOrder. The detail is supplied from outside. That single move changes the structure of the design: the higher-level behavior is no longer tied directly to one low-level implementation.
This becomes even more important in real application architecture. Business logic should not depend directly on databases, frameworks, HTTP clients, ORMs, or UI concerns. Those are details. The more tightly the core of the system depends on them, the harder the system becomes to test, change, and understand. DIP inverts that relationship by making both the high-level policy and the low-level detail depend on the same abstraction. The repository pattern is a common example:
public interface IProductRepository
{
Product GetProductById(int id);
}
public class ProductService
{
private readonly IProductRepository productRepository;
public ProductService(IProductRepository productRepository)
{
this.productRepository = productRepository;
}
public Product HandleProduct(int id)
{
var product = productRepository.GetProductById(id);
// Perform various operations on the product...
return product;
}
}
public class SqlProductRepository : IProductRepository
{
public Product GetProductById(int id)
{
// Implementation details of how to get the product from a database...
}
}
ProductService expresses business behavior. SqlProductRepository expresses a persistence detail. The abstraction in the middle allows them to vary independently. The service does not need to know whether the product came from SQL Server, PostgreSQL, an in-memory stub, or a test double.
This is where DIP connects directly back to Clean Architecture. The dependency rule says that dependencies should point inwards, toward higher-level policy. Business rules should not know how the system stores data or presents it. Instead, infrastructure and delivery mechanisms should adapt themselves to abstractions defined closer to the core. The result is not just decoupling at class level, but a plug-and-play style of architecture in which details remain replaceable.
DDD relies on the same idea. A domain model should remain focused on business meaning, not on technical concerns such as persistence or transport. Repository interfaces, service abstractions, and other ports allow the domain to stay clean while infrastructure concerns remain outside it. The domain depends on what it needs in abstract terms; the concrete technical details depend on the domain's abstractions, not the other way around.
Testing also becomes much simpler. Once high-level modules depend on abstractions, alternate implementations can be substituted easily in tests. Mock repositories, fake gateways, in-memory stores, and other test doubles become straightforward to introduce. This does not just make testing possible; it makes it easier to write tests that are focused, isolated, and predictable.
DIP is also an antidote to a less obvious design smell: the Entourage Anti-Pattern. This occurs when a class depends on a large interface or module just to access one small piece of functionality. The class may technically depend on an abstraction, but in practice it is coupled to a much larger surface than it actually needs. That increases complexity, weakens modularity, and often drags in irrelevant details. This is where DIP and ISP overlap: abstractions should not only exist, they should be small and cohesive enough that clients depend only on what they truly need.
Used well, DIP gives software architectural leverage. It keeps policy independent from mechanism, supports testing, and helps systems tolerate change in the parts most likely to vary. It is also the principle that most clearly lifts SOLID out of local object design and into system structure. At that level, abstractions stop being just coding conveniences and become architectural boundaries.
Conclusion
SOLID is a connected set of heuristics for assigning responsibility, protecting stable behavior, preserving trust in abstractions, and managing dependencies well.
Like all design principles, SOLID can be over-applied. The goal is prudence, not dogma. Used well, these principles help build systems that are easier to understand, easier to extend, easier to test, and safer to change.
Beneath it all lie the principles of structured programming: high cohesion, low coupling, and indirection.