SOLID Principles: Forks to Eat Soup

I specialize in developing object-oriented java applications that aligns with business objectives, using Domain-Driven Design principles to ensure technical decisions drive tangible value. By focussing on a deep understanding of the business domain, I craft solutions that solve real problems while maximizing ROI. My approach evaluates the cost/profit ratio of every decision—only implementing technologies when the benefits outweigh the costs. I’ve been called in to revive stalled projects and address challenges where others have struggled. My focus is on creating software that not only meets but exceeds business expectations. Whether working with legacy systems or modern frameworks, I select the right technologies to maximize value—not just follow trends. I believe software should be a strategic asset, and this mindset guides every decision I make in development.
The Problem With Architecture Debates
Most software architecture debates can't actually be settled. Every system is built once. The alternative approach — the one that wasn't chosen — is never built alongside it, under the same conditions, with the same team, against the same market. So when a system works, "it works" gets quietly promoted to "the approach was right," and when a system rots, the rot gets blamed on the domain being inherently complex, or the requirements changing too much, or the previous developers having been careless. Almost never does anyone conclude that the architecture itself was the variable that mattered, because there is no control group to compare it to.
This is the unfalsifiability problem, and it is the reason architecture discussions tend to be so unproductive. Everyone is generalizing from an n of one, or a handful of isolated ones, with team skill, domain difficulty, and plain luck as uncontrolled variables throughout. Two competent engineers can each have ten years of experience, complete confidence in their conclusions, and have learned nothing transferable to each other, because neither has ever seen their belief tested against an alternative.
It is also the reason a particular class of mistake can persist for decades, spread through teams and codebases, get taught in courses and validated in job interviews, and still never be clearly identified as a mistake. The code ships. The system works well enough. The costs are real but diffuse — spread across maintenance cycles, onboarding friction, debugging sessions that take longer than they should. Nobody writes a post-mortem that says "we had too many interfaces." They write one that says "the codebase had become difficult to change," and then someone suggests that what happens is the nature of enterprise software.
It is easy to use the wrong tool for the job. The wrong tool could even lead to a worse outcome than the problem it was intended to solve. SOLID principles are design principles for an object-oriented domain model. Applying them outside one is every bit as effective as eating soup with a fork.
Forks to Eat Soup
Nobody uses a fork to eat soup. You could. With enough patience and a sufficiently shallow bowl, you would eventually get most of it. If you had only ever eaten soup with a fork, you might genuinely believe that forks are general-purpose eating tools, and that the difficulty of soup simply reflects the nature of liquids rather than the inadequacy of the instrument.
SOLID principles have a specific, valid purpose: structuring a rich object-oriented domain model. Applied there, they produce code that is coherent, maintainable, and expressive. Applied elsewhere — to procedural code, to technical infrastructure, to layered frameworks with no meaningful domain model underneath — they tend to produce indirection without purpose. The complexity that follows gets blamed on the problem domain. The instrument is rarely questioned.
When the domain modelling step is skipped, or reduced to a handful of JPA-annotated data transfer objects that carry field values but no behavior, the problems start. What remains is procedural code and technical plumbing. SOLID principles, taught as universal good practice in object-oriented application, get applied to the codebase that remains — the one where the domain model should have been. The result is the software equivalent of eating soup with a fork: technically possible, enormously effortful, and solving a problem that the right tool would not have created.
To understand why, it helps to look at what each principle actually means — and what it tends to mean in practice instead.
Single Responsibility Principle
A class should have only one reason to change.
The intent is to prevent fat objects — objects that accumulate unrelated responsibilities until they become incomprehensible. A Customer object should be responsible for what a customer is and does in the domain. It should not also be responsible for rendering HTML, managing database transactions, and sending emails. Those are different concerns, and mixing them means changes to any one of them risk breaking all the others.
At the modelling level this is a sound and useful principle. The difficulty arises when it gets applied at the code level, where it tends to appear in two distinct patterns.
Pattern 1: Technology Layering as Responsibility Separation
A common pattern treats technology boundaries as responsibility boundaries. A Customer domain object gets split into CustomerDTO, CustomerRepository, CustomerService, and CustomerController — one object per architectural layer. The reasoning tends to follow SRP vocabulary: "the domain object shouldn't know about persistence," "the service layer shouldn't know about HTTP."
What this produces, though, is not responsibility separation. It is technology separation. The responsibility of understanding what a customer is and does in the domain has not been clarified — it has been distributed across four classes that must all be read together to recover the meaning that one coherent object would have expressed directly.
The cost is not visible at the time of writing. The developer who built it holds all the context in their head. The cost materializes six months later, when something needs changing, or when a new developer joins the team.
Context is what keeps software maintainable. Distributing it across layers replaces semantic meaning with structural convention. The cognitive load of every subsequent change increases, because understanding what the code does requires mentally reassembling the essential logic from across an accidental structure.
Technology boundaries are not responsibility boundaries. A domain object can contain a database query if that is what its responsibility requires. It can make an HTTP call. Consider email.send() — not a static utility method, but a method on an existing email instance, because sending is part of what an email is. The SMTP detail is essential complexity: it exists because reality requires it. The EmailService, IEmailSender, SmtpEmailSender stack that replaces it is accidental complexity: it exists because architectural doctrine requires it. The Email concept is the only place in the entire application where sending belongs, and it is exactly where a reader would expect to find it.
Pattern 2: Dual Responsibility Objects in DDD Contexts
Domain-Driven Design introduces bounded contexts — the idea that the same real-world entity may appear differently in different parts of the system. A customer in a shipping context has different relevant attributes and behaviors than a customer in a billing context. This is correct and useful modelling.
One reading of this is to create multiple Customer variants — a ShippingCustomer, a BillingCustomer — each carrying a subset of customer responsibility plus their context-specific concerns mixed together. The intent is usually good: keep each object focused, avoid a single bloated Customer that knows too much. But what tends to happen is that customer responsibility ends up duplicated and fragmented across multiple objects, none of which is a coherent model of what a customer actually is. The object has not been given a single responsibility — it has been given two, then renamed to disguise the second one.
A more faithful reading is composition rather than decomposition. Customer retains its single responsibility: being a customer. Shipping-specific behavior belongs in a separate object that has a Customer as an attribute and adds its own behavior alongside it — not a Customer subtype, but an add-on. Consider the difference in how objects are constructed:
Order order = new Order(customer, shoppingCart);
Payment payment = new Payment(invoice);
Email email = new Email(subject, content);
email.send();
Each object owns exactly what its concept requires. Customer does not know about Order. Order knows about Customer because an order belongs to someone — that relationship is explicit in the construction, not smuggled in through inheritance or layer-crossing services. The domain model is the spoon. The principles apply cleanly when there is something to apply them to.
What bounded contexts produce, when modelled this way, is not multiple versions of the same object — it is context-specific objects that use domain objects without absorbing them. The core object stays coherent because it is never asked to be something it isn't.
Open/Closed Principle
Software entities should be open for extension but closed for modification.
The principle addresses inheritance. A well-designed superclass establishes invariants and behavior that subclasses can extend — adding new behavior — without modifying what the superclass already guarantees. The superclass contract remains stable. Subclasses enrich it.
The classic illustration of where this gets complicated is the circle and ellipse problem. Should Circle extend Ellipse, or Ellipse extend Circle? The intuition that one is a special case of the other seems to invite inheritance. But the behavioral contracts pull in different directions: a circle maintains the invariant that all radii are equal, which an ellipse cannot guarantee. Inheriting one from the other tends to force the subclass to either suppress an inherited invariant or override inherited behavior — which is precisely the situation OCP is designed to avoid. The more natural model is that both are shapes. What they share belongs in a common abstraction. What makes them distinct belongs in separate implementations.
A constructor exists to bring an object to a correct, fully initialized state — to establish its invariants before anything else acts on it. A subclass that introduces behavior dependent on superclass state before that initialization is complete is extending something that does not yet fully exist. When this pattern becomes necessary, it is often worth asking whether the inheritance relationship itself is the right one. The language permitting something does not make it structurally sound.
Liskov Substitution Principle
Objects of a subclass should be substitutable for objects of the superclass without altering the correctness of the program.
Liskov Substitution follows naturally from Open/Closed. If a subclass only extends the superclass — adding behavior without overriding or suppressing it — then substitutability tends to follow. The situations where LSP breaks down are usually the same situations where OCP has already broken down: a subclass that modifies rather than extends.
The substitutability question is not about whether two things look similar in some states. An empty bank account and a non-existent bank account may show the same balance. They are not substitutable — one has an owner, a history, a legal existence, and obligations that the other does not. A broken watch displays the correct time twice each day, by coincidence rather than function. Apparent equivalence in certain states is not a subtype relationship. The contract needs to hold across all states and all behaviors, not just the ones that happen to align at a given moment.
The practical guide to avoiding these violations is simpler than it might appear. Inheritance is the right relationship when something genuinely is a more specific version of something else — when the full behavioral contract of the parent holds for the child without suppression or override. Composition is the right relationship when something has a reference to something else and adds its own behavior alongside it. The domain tells you which one applies.
A useful test is to say the relationship out loud. "A ShippingCustomer is a Customer" — but is it? A ShippingCustomer is not a more specific kind of Customer. It is shipping-related information that belongs to a Customer. Saying it out loud already suggests the right structure: it has a Customer, it is not one. Inheritance chosen for convenience rather than conceptual accuracy is where both OCP and LSP tend to break down — not because the principles are hard to understand, but because the modelling question was never asked in the first place.
Interface Segregation Principle
Clients should not be forced to depend on interfaces they do not use.
ISP is to interfaces what SRP is to objects. A fat interface — one that bundles more methods than any single client will ever use — puts implementors in an awkward position. Every class that implements it must account for methods it has no use for, either by leaving them empty, throwing exceptions, or providing stub implementations that do nothing useful. The interface has become a convenience bundle rather than a coherent behavioral contract.
The Java Servlet interface is a well-known example of this. It defines methods for handling every HTTP verb, managing initialization and destruction, accessing configuration, and more. A developer implementing a simple endpoint must engage with the full surface of this interface regardless of how little of it they need. The interface grew to represent everything that might ever be useful to something servlet-like, rather than any single coherent concept. When an interface has accumulated that many methods, it is usually worth asking whether it is modelling one thing or several — and whether splitting it into focused contracts might serve implementors better.
Dependency Inversion Principle
High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details.
DIP is arguably the most consequential of the five principles in terms of how it has shaped contemporary codebases — and also the one where the distance between the original intent and common practice is widest.
The principle addresses conceptual dependencies. In a rich domain model, a high-level concept should not be coupled to the concrete technical mechanism that implements a lower-level concern. The dependency should point toward an abstraction that is meaningful in the domain — one that a domain expert would recognize — rather than toward a specific technical artifact.
This is a modelling principle. It says that domain concepts should be defined in terms of what they need behaviorally, not in terms of the specific technology that happens to fulfill that need today.
In contemporary practice, DIP is frequently invoked to justify dependency injection — the pattern of passing dependencies into a class from the outside rather than having the class create them directly. DI frameworks have made this the architectural default: everything should be injectable, which means everything needs an interface, which means interface creation becomes a routine act disconnected from any modelling decision.
The reasoning tends to be circular. DI is presented as an application of DIP. But the abstraction that DI creates — a production datasource and a mock datasource sitting behind a common interface — is not necessarily a domain concept. It may exist primarily because the test harness requires something swappable, and the production codebase has been structured to accommodate that requirement.
This points to a tension that is worth naming directly: a codebase optimized for production and a codebase optimized for testing tend to pull in different directions. A production-optimized codebase is direct and expressive. The model is clear. The surface area of failure is small. Fewer tests are needed because the code is understandable and the concepts are coherent. A test-optimized codebase introduces interfaces, injection seams, and mock implementations. The production behavior becomes something inferred through a scaffolding of substitutes. More tests tend to be needed partly because the indirection introduced for testing creates new failure modes that themselves require coverage.
There is a certain irony in this: a test-optimized codebase can end up requiring more tests to manage the complexity that was introduced in order to make testing easier.
The analogy to aspect-oriented programming is worth considering here. AOP was introduced as a way to separate concerns. The practical difficulty was that behavior was happening in the codebase that could not be found by reading the code. Large dependency injection containers can have the same quality. The actual wiring of the application is not directly readable — it is inferred from annotations and container configuration at runtime. Debugging means navigating proxy layers and generated code rather than following what is explicitly written. The indirection that was meant to simplify things for the original author can make things considerably harder for everyone who comes after.
The question DIP is actually asking at the modelling level is different: does this dependency reflect a real conceptual relationship, or is it an artifact of how the code happens to be built? If a domain concept depends on a behavioral abstraction that belongs in the domain — something a domain expert would name and recognize — then DIP is doing its intended work. If the abstraction exists only because a framework requires it, or because a test needs something swappable, then DIP's vocabulary is being borrowed to justify an infrastructure decision. The principle has not been applied. It has been appropriated.
What The Principles Actually Share
Looking across all five, the same underlying concern appears in each one: conceptual integrity at the model level.
SRP: one coherent concept per object
OCP: inheritance respects the concept's contract
LSP: substitutability follows from genuine subtype relationships
ISP: interfaces reflect actual behavioral contracts, not convenience bundles
DIP: dependencies follow conceptual relationships, not technical ones
When these principles are applied to a rich domain model, they reinforce each other. When they are applied to layers, frameworks, and testing infrastructure — in the absence of a model — they tend to produce fragmentation instead of coherence, indirection instead of clarity, and more complexity in the name of managing complexity.
The principles were designed for a specific activity: building a rich object-oriented model of a domain. When that activity is skipped — when the model is reduced to annotated data containers and the real work happens in procedural service classes — the principles have no natural target. What gets built in their name may look structured, but the structure serves the principles rather than the domain. The soup gets eaten. It just takes considerably longer than it should.
The Practical Case for Getting This Right
A rich domain model and the principles that support it can look like a theoretical exercise — an indulgence for architects with time to spare, impractical against real deadlines. This perception is worth examining because the costs and benefits are often misunderstood.
A domain model does not require weeks of upfront design. Even a first pass — an hour or two of thinking through what the concepts are, what they own, what they do — produces something to code from. Not instructions to implement, but domain knowledge to express. That difference in orientation matters more than it might sound. Procedural development requires scaffolding: repositories to call, services to extend, state to pass between layers, behavior to locate across a structure that exists for technical rather than conceptual reasons. A domain model reduces that scaffolding substantially. The concepts know what they are. The behavior lives where it belongs. Adding a capability often means adding a method to an existing object rather than extending a service, adding a repository call, and wiring the result through a chain of layers.
The total amount of code a rich domain model requires is less — often considerably less — than its procedural equivalent. This is not a long-term payoff that arrives after years of maintenance. It is present from the beginning, in the clarity of direction the model provides and the scaffolding it makes unnecessary.
The maintenance advantage compounds from there. Procedural systems with fat services and an anemic model distribute context across layers. Each change requires reassembling that context. Each new developer inherits a cognitive load that grows with the codebase. The tests added to manage the complexity require maintenance of their own. A rich domain model stays navigable because the concepts remain coherent and the code remains an expression of them.
The other thing that happens with practice is harder to quantify but worth naming. Thinking in responsibilities — asking what a concept is, what it does, what it should know — becomes second nature. The modelling step stops feeling like overhead and starts feeling like the work itself. Code becomes an expression of that model rather than a template-filling exercise. The principles stop being rules to apply and start being observations about whether the model is coherent.
That is what SOLID principles are for. Not for services, not for layers, not for test infrastructure. For the model. And a good model, it turns out, is not a theoretical luxury. It is the most practical thing a codebase can have.
Forks are excellent tools. So are SOLID principles. The question worth asking is not whether you are applying them, but whether you are applying them to something they were designed for.





