Skip to content

bedwards/php-solid

Repository files navigation

PHP SOLID

Tests

Minimal examples of SOLID principles implemented in PHP.

Helpful commands

Commands I already ran:

composer require --dev phpunit/phpunit
vendor/bin/phpunit --generate-configuration

composer require --dev phpstan/phpstan

Commands you might need to run:

composer clear-cache
composer dump-autoload

Perform static analysis and run tests with:

./qa.sh

Single-responsibility principle

Code | Back to top

The Single-responsibility principle addresses a fundamental question about how to organize code: what should a module be responsible for? The principle states that a class or module should have one, and only one, reason to change. This statement is deceptively simple but profoundly important. It means that each software unit should be responsible for one cohesive aspect of the system's functionality, and all of its capabilities should be aligned with that single purpose.

The key insight lies in understanding what "reason to change" means. A reason to change is not a feature or a function, but rather a source of requirements. Different stakeholders in a system have different concerns. Business analysts care about business rules. Database administrators care about data persistence. System administrators care about logging and monitoring. When a class serves multiple stakeholders, changes requested by any one of them will force modifications to that class. This coupling between unrelated concerns creates fragility. A change to satisfy one stakeholder might inadvertently break functionality that another stakeholder depends on.

The principle is fundamentally about cohesion. Cohesion measures how closely the elements within a module belong together. High cohesion means everything in the module relates to a single, focused purpose. Low cohesion means the module contains unrelated functionality. The Single-responsibility principle demands high cohesion as a design goal. When a class has a single responsibility, it becomes easier to understand, test, and maintain because all its parts work toward the same conceptual goal.

Consider a report generation system. The naive approach creates a class called ReportGenerator that does everything related to reports. It connects to the database to fetch user data. It performs calculations to aggregate that data into meaningful statistics. It formats the results into HTML or PDF. It sends the completed report via email to specified recipients. This single class touches database connectivity, business logic, presentation formatting, and external communication. Four distinct concerns exist within one module.

Now imagine change requests arrive from different sources. The marketing team wants reports in a new visual format with different charts. The IT department needs to migrate from one database system to another. The security team requires that all emails use encrypted connections. The business analysts discover that certain calculations were using incorrect formulas. Each change forces modifications to the same ReportGenerator class. Every change risks breaking unrelated functionality because everything is entangled. Testing becomes a nightmare because you cannot test data fetching without also involving formatting logic, and you cannot verify calculations without database connections.

The proper approach separates these concerns into distinct classes, each with a single responsibility. A DataRepository class handles all database interactions. Its sole reason to change is if the data source or data access strategy changes. A ReportCalculator class contains the business logic for computing statistics from raw data. It changes only when business rules change. A ReportFormatter class transforms calculated results into presentation formats like HTML or PDF. It changes only when presentation requirements change. An EmailSender class handles the transmission of completed reports. It changes only when communication protocols or delivery mechanisms change.

Each class now has a single axis of change. When the database migration occurs, only DataRepository is modified. When new formatting is needed, only ReportFormatter is touched. When calculation errors are discovered, only ReportCalculator is updated. Changes become isolated, reducing risk. Testing becomes straightforward because each class can be tested independently with mock objects for its dependencies.

The orchestration of these responsibilities falls to a separate class, perhaps called ReportService, which coordinates the workflow. It asks DataRepository for data, passes that data to ReportCalculator for processing, hands results to ReportFormatter for presentation, and finally uses EmailSender for delivery. This coordinator has its own responsibility: workflow orchestration. It changes when the sequence of operations changes, which is itself a distinct concern.

The opposite of following this principle is creating what developers call a "God class" or "God object." These are classes that know too much and do too much. They accumulate responsibilities over time as developers continually add features to existing classes rather than creating new ones. God classes become central bottlenecks in a system. Every feature touches them. Every developer must modify them. Merge conflicts multiply. Testing becomes impossible without instantiating the entire world. These classes might have thousands of lines of code, dozens of methods, and dependencies on nearly every other part of the system.

The Single-responsibility principle differs from simply making small classes. Small classes are not inherently better. You could create tiny classes that still violate the principle by mixing concerns at a fine-grained level. A class with three methods might still have two responsibilities. Conversely, a class with twenty methods might have perfect cohesion if all twenty methods support a single, complex responsibility. Size is a heuristic, not the goal. Responsibility is measured by conceptual purpose, not by line count or method count.

This principle also differs from simple functional decomposition. Breaking a large function into smaller helper functions improves readability but does not address responsibility at the architectural level. The Single-responsibility principle operates at the module or class level, organizing entire units of functionality around distinct concerns. Functional decomposition is a technique within those units, making each responsibility's implementation clearer, but it does not substitute for proper separation of responsibilities.

The relationship to other SOLID principles is instructive. The Open-closed principle concerns how modules handle extension without modification. The Single-responsibility principle concerns what each module should be responsible for in the first place. A module might be perfectly closed for modification yet still violate the Single-responsibility principle by handling multiple concerns. The Interface segregation principle extends the Single Responsibility idea to interfaces, stating that clients should not depend on interface methods they do not use. Both principles fight against bloat, but at different levels of abstraction.

A common confusion arises between the Single-responsibility principle and the separation of concerns. Separation of concerns is a broader architectural concept about organizing a system into distinct sections, each addressing a separate concern. The Single-responsibility principle is a specific application of this concept at the class or module level. Separation of concerns might guide you to separate your user interface from your business logic from your data access. The Single-responsibility principle then guides how you organize classes within each of those layers, ensuring each class has one focused purpose even within its layer.

Another confusion involves thinking that single responsibility means "does only one thing." A responsibility is not a single action but a cohesive set of related capabilities serving one conceptual purpose. A ReportCalculator might perform many different calculations—averages, totals, percentages, trends. These are all part of its single responsibility: computing business metrics. The responsibility is defined by the reason to change, not by the number of functions.

In practice, identifying responsibilities requires judgment and experience. Responsibilities that seem distinct might actually be facets of the same concern, and what appears unified might hide multiple concerns. The test is to ask: if this aspect of the system needs to change, what else must change with it? If many unrelated things must change together, they probably share a responsibility. If they can change independently, they are likely separate responsibilities. The principle guides toward a design where changes ripple through the minimum necessary scope, keeping modifications localized and predictable.

Open-closed principle

Code | Back to top

The Open-closed principle fundamentally addresses a tension in software design: how do you accommodate new requirements without constantly rewriting existing, working code? The principle states that software entities should be open for extension but closed for modification. This means you should be able to add new functionality to a system without changing its existing source code.

To understand this deeply, consider what "closed for modification" actually means. It means that once a module is written, tested, and deployed, its source code should remain untouched when new features are added. This is crucial because modifying existing code risks introducing bugs into previously working functionality. Every time you change a working module, you must retest it entirely. Closure protects stability.

Conversely, "open for extension" means the module's behavior can be expanded. The system anticipates future growth through abstraction. You add new capabilities by writing new code, not by editing old code. Extension provides flexibility.

The mechanism that enables both properties simultaneously is abstraction combined with polymorphism. You define an abstract interface that represents a concept, and you write your core logic to depend on that abstraction rather than concrete implementations. New behaviors are introduced by creating new implementations of that interface, which the existing code can work with without being aware of the specifics.

Here is a thorough demonstration using a notification system. Imagine a system that sends notifications to users. Initially, it might only support email notifications. The naive approach would hardcode email logic throughout the application. When SMS support is needed later, you would modify that code, adding conditional branches everywhere notifications are sent. This violates the Open-closed principle because the existing code is being modified.

The proper approach begins by defining an abstraction called Notifier that represents the concept of sending a notification. This abstraction declares a method signature like "send notification to recipient with message" without specifying how the notification is actually delivered. The core application code then depends on this abstraction. It might have a NotificationService that accepts a collection of Notifiers and uses them to send messages. Critically, this service is written once and never modified again.

The email functionality becomes one concrete implementation of Notifier. It implements the send method by connecting to an SMTP server and delivering the message via email. When SMS support is needed, you create a new SMSNotifier class that implements the same interface but sends messages through a telecommunications gateway instead. The NotificationService requires no changes because it only knows about the Notifier abstraction. You've extended the system's capabilities without modifying existing code.

To demonstrate thoroughness, consider adding push notifications, Slack messages, or webhook deliveries. Each becomes another implementation of Notifier. The system grows by addition, not by alteration. You might even create composite notifiers that send through multiple channels, or decorators that add logging or retry logic, all without touching the original service.

The opposite of following this principle is the modification-based approach where you repeatedly edit the same code to add features. In that approach, the NotificationService would contain a type field and a large conditional statement checking whether to send email, SMS, or another type. Each new notification method requires modifying this conditional, adding branches, increasing complexity, and risking the introduction of bugs into previously working code paths. The code becomes increasingly fragile and difficult to test.

The Open-closed principle differs from other SOLID principles in important ways. The Single-responsibility principle focuses on cohesion, ensuring each class has one reason to change. The Open-closed principle focuses on stability and extensibility, ensuring that when changes do occur, they happen through extension rather than modification. The Dependency inversion principle, while related, specifically addresses the direction of dependencies, stating that high-level modules should not depend on low-level modules but both should depend on abstractions. The Open-closed principle is enabled by such abstractions but focuses on the extension mechanism itself.

A common confusion arises between the Open-closed principle and simple modular design. Modular design means organizing code into separate units, but those modules might still require modification when requirements change. The Open-closed principle goes further, using abstraction to ensure modules can accommodate new behaviors without internal changes. It's not merely about separation but about designing for future extension from the beginning.

The principle also differs from configuration-driven systems, though they share similarities. A configuration-driven system allows behavior changes through external configuration files rather than code changes. While this achieves a form of extension without modification, the Open-closed principle operates at the code structure level, using polymorphism and inheritance or composition to enable new behaviors that the original authors never anticipated. Configuration typically selects from predefined options, while the Open-closed principle allows entirely new implementations.

In practice, achieving perfect closure is impossible. At some point, new requirements will necessitate changes to existing code. The principle guides design toward minimizing such modifications and isolating them to specific points in the system. You identify the dimensions along which change is likely and create abstractions there, accepting that unforeseen dimensions may eventually require some modification. The goal is not absolute immutability but strategic stability in the face of predictable variations.

Liskov substitution principle

Back to top

Note

The OpenClosed NotifierService demonstrates the Liskov principle. EmailNotifier and SMSNotifier are substitutable for the Notifier interface. The service works correctly regardless of which concrete notifier it receives. Both honor the contract: accept a recipient key and message, send the notification.

The Liskov substitution principle addresses a critical question about inheritance and polymorphism: when can one type safely replace another? The principle states that objects of a supertype should be replaceable with objects of any of its subtypes without altering the correctness of the program. This means that if your code works with a base class, it must work equally well with any derived class, without the calling code needing to know which specific subtype it is using.

The principle is named after Barbara Liskov, who formalized the notion of behavioral subtyping. The key word here is "behavioral." The principle is not about whether subclasses inherit the methods of their parent classes—that is mere syntactic inheritance, which programming languages enforce automatically. Rather, it concerns whether subclasses preserve the behavioral expectations, the contracts, that the parent class establishes. A subclass might have all the same method signatures as its parent yet still violate the Liskov substitution principle if those methods behave in ways that break the promises the parent made.

Understanding this principle requires understanding the concept of a contract. When you design a class, you implicitly or explicitly define what it promises to do. These promises include preconditions—what must be true before a method is called—and postconditions—what will be true after a method completes. They also include invariants—properties that remain true throughout the object's lifetime. The contract defines the behavioral expectations that any code using this class can rely upon.

The Liskov substitution principle demands that subclasses honor the contracts of their parent classes. More precisely, a subclass can weaken preconditions, making methods more accepting of different inputs, but it cannot strengthen them. A subclass can strengthen postconditions, promising more than the parent did, but it cannot weaken them. Invariants must be preserved entirely. This ensures that code written to work with the parent class will not be surprised by a subclass's behavior.

Consider a classic example that demonstrates a violation: rectangles and squares. At first glance, modeling a Square as a subclass of Rectangle seems logical. After all, mathematically, a square is a special case of a rectangle where all sides are equal. You might create a Rectangle class with separate width and height properties, along with methods to set each independently. The Square class inherits from Rectangle but overrides the setWidth and setHeight methods to keep both dimensions equal—setting width also sets height to the same value, and vice versa.

This design violates the Liskov substitution principle. Imagine code that uses a Rectangle. It reasonably expects that calling setWidth will change the width without affecting the height, and calling setHeight will change the height without affecting the width. These are implicit postconditions of the Rectangle contract. A function might receive a Rectangle, set its width to five and its height to ten, and then calculate its area expecting fifty. When a Square is substituted for the Rectangle, this calculation breaks. Setting width to five also changes height to five, and setting height to ten changes width to ten. The final area is one hundred, not fifty. The code that worked correctly with Rectangle now produces wrong results with Square, even though no compile-time errors occurred.

The root problem is that Square violates the behavioral contract of Rectangle. The contract promised independent control of width and height. Square cannot fulfill this promise because its very nature requires width and height to be identical. The mathematical relationship between squares and rectangles does not translate directly into an inheritance relationship in code. The "is-a" relationship that guides inheritance is not about taxonomy but about behavioral substitutability.

The proper approach recognizes that Square and Rectangle should not exist in an inheritance hierarchy, or at least not in this direction. Perhaps both should implement a Shape interface that does not promise independent dimension control. Or perhaps Square should not exist as a separate class at all, with the concept represented by a Rectangle where width equals height. The design must reflect behavioral contracts, not merely conceptual categories.

Another illuminating example involves collections and their operations. Imagine a base class called Collection that includes an add method for inserting elements. It promises that after calling add, the collection's size increases by one. Now consider an ImmutableCollection subclass. It inherits the add method signature, but what should add do for an immutable collection? If it throws an error, it violates the parent's contract—the parent promised that add would succeed and increase size. If it silently does nothing, it still violates the contract because size does not increase. If it returns a new collection with the added element, the signature might not match if the parent's add returns void. No matter how you implement it, ImmutableCollection cannot satisfy the behavioral contract that Collection establishes. The inheritance relationship is fundamentally flawed.

The opposite of following this principle is creating substitution violations where subclasses surprise their callers. These violations manifest as unexpected exceptions, incorrect results, or the need for type-checking and downcasting. When you find yourself writing code that checks the runtime type of an object before calling its methods, you are witnessing a Liskov substitution principle violation. The code says "if this is actually a Square, do something different than if it is a Rectangle." This defeats the entire purpose of polymorphism. You lose the benefits of abstraction because you must know about concrete types.

The principle differs fundamentally from simple type compatibility. Most programming languages enforce that subclasses can be assigned to variables of their parent type—this is called covariance or type substitutability. But type substitutability is syntactic, not semantic. The compiler ensures that the Square has all the methods that Rectangle has, but it cannot verify that those methods behave according to Rectangle's contracts. The Liskov substitution principle concerns the deeper behavioral compatibility that compilers cannot check automatically.

The principle also differs from the notion that subclasses should extend functionality without changing existing behavior. While related, this is not quite the same. A subclass may override parent methods, and those overrides might behave quite differently internally, using different algorithms or data structures. What matters is not that the implementation remains the same, but that the external contract remains satisfied. A subclass might implement a sort method with a completely different algorithm than its parent, and this is perfectly acceptable as long as both produce correctly sorted results according to the same criteria.

The relationship to other SOLID principles reveals interesting insights. The Open-closed principle states that classes should be open for extension but closed for modification. The Liskov substitution principle enables this openness by ensuring that extensions—subclasses—can be used wherever the base class is expected. Without Liskov substitutability, polymorphism breaks, and the Open-closed principle becomes impossible to achieve. You cannot safely extend behavior through subclassing if subclasses violate their parents' contracts.

The principle also connects to the Dependency inversion principle, which advocates depending on abstractions rather than concrete implementations. But depending on abstractions only works if all implementations of those abstractions are genuinely substitutable for one another. If some implementations violate the abstraction's contract, the benefit of the abstraction disappears. The Liskov substitution principle is the guarantee that makes abstraction-based design reliable.

A common confusion involves thinking that the principle prohibits throwing exceptions. In fact, a subclass may throw exceptions that its parent does not, provided these are more specific exceptions within a documented error hierarchy, or exceptions that arise from strengthened postconditions. What the principle prohibits is throwing exceptions in cases where the parent promised success. If the parent method promises to handle all valid inputs without error, the subclass cannot reject those inputs. However, if the parent documents that certain conditions might result in exceptions, the subclass may specify more precisely which exceptions occur under which conditions.

Another confusion concerns whether subclasses can have additional methods beyond those defined in the parent. This is entirely acceptable and does not violate the principle. The Liskov substitution principle only governs the methods that are part of the parent's interface. Additional methods represent genuine extensions of functionality. The issue arises only when overridden methods fail to honor the contracts of the methods they override.

The principle also addresses property mutations in subtle ways. If a parent class allows certain properties to be modified, a subclass cannot make those properties read-only without violating the contract. Code that depends on being able to modify a property will break when given the subclass. Conversely, if the parent makes a property read-only, a subclass can offer setter methods as an extension, because this only adds capability rather than removing it.

In practice, achieving proper Liskov substitutability requires careful design of class hierarchies. You must think deeply about what contracts each level of abstraction establishes and ensure that subclasses genuinely satisfy those contracts. This often means that inheritance hierarchies are shallower than intuition suggests. Many relationships that seem like natural inheritance in the real world are not appropriate inheritance relationships in code. Favoring composition over inheritance often emerges as a practical strategy because composition relationships do not carry the substitutability requirements that inheritance imposes.

The test for Liskov substitutability is straightforward in principle though challenging in practice: write tests for the parent class that verify its contract, then run those same tests against each subclass. If any subclass fails tests that the parent passes, you have found a violation. The subclass is not honoring the behavioral contract. This test-based approach makes the abstract principle concrete and actionable, revealing design flaws that might otherwise remain hidden until runtime failures occur in production.

Interface segregation principle

Code | Back to top

The Interface segregation principle addresses a fundamental question about the design of abstractions: how should we define the contracts that clients depend upon? The principle states that no client should be forced to depend on methods it does not use. This means that interfaces should be designed from the perspective of the clients that use them, not from the perspective of the classes that implement them. Fat interfaces that bundle many methods together force clients into unnecessary dependencies, while properly segregated interfaces provide exactly what each client needs and nothing more.

The term "interface" here transcends the language-specific keyword found in PHP (or Java or C#). An interface, in the broader sense, is any abstraction that defines a contract—a set of methods or operations that something provides. This includes abstract base classes, protocols, traits, or even the public methods of a concrete class. Anywhere you have a defined contract that code depends upon, you have an interface in this architectural sense.

The critical insight concerns what happens when interfaces become bloated. When an interface declares many methods, every class that implements that interface must provide implementations for all of them, even methods that make no sense for that particular implementer. Every client that depends on that interface becomes coupled to all those methods, even methods that particular client never calls. These unnecessary dependencies create fragility. When methods that one client needs are changed, other clients that never used those methods must still be recompiled, retested, and redeployed. Changes ripple farther than they should.

Consider a document management system with various operations. The naive approach creates a single Document interface that declares every operation the system might perform: read, write, delete, print, email, archive, encrypt, compress, index, and search. This interface seems comprehensive and convenient. A single abstraction covers everything related to documents. However, this design creates severe problems.

Imagine a document viewer component that only needs to read documents and display them. It depends on the Document interface to obtain document content. But the Document interface includes write, delete, and encrypt operations. The viewer never calls these methods, yet it depends on them. If the signature of the encrypt method changes, the viewer must be recompiled even though encryption is irrelevant to viewing. If a new implementation of Document struggles with the email operation, the viewer might receive an implementation that throws errors for operations it never invokes, yet those errors become its problem because it holds a reference to an object that might fail.

Now consider implementing Document for different document types. A read-only archived document can legitimately implement read but cannot sensibly implement write or delete. The implementer faces an impossible choice: throw exceptions for write operations, violating any expectation that Document promises working write methods; or provide dummy implementations that do nothing, creating silent failures; or somehow implement write operations that conceptually should not exist. All options are unsatisfactory because the interface demands capabilities that this implementation cannot provide.

The proper approach segregates the interface into focused abstractions, each serving specific client needs. A Readable interface declares only the read operation. A Writable interface declares write operations. A Deletable interface declares deletion. A Printable interface declares printing operations. Each interface is small and cohesive, defining one category of related operations.

Clients now depend on precisely what they need. The document viewer depends only on Readable. If print operations change, the viewer remains unaffected because it has no dependency on Printable. The viewer can accept any object that implements Readable, whether that object also supports writing, deleting, or any other operations. The viewer's concerns are properly isolated.

Implementations become honest about their capabilities. A read-only archived document implements Readable but not Writable or Deletable. The type system now expresses this truthfully. Code that needs to modify documents depends on Writable, and the type system prevents passing read-only documents to such code, catching errors at compile time rather than runtime. A full-featured document class might implement multiple interfaces—Readable, Writable, Deletable, Printable—advertising all its capabilities. Clients choose which interface to depend upon based on their needs.

The composition of multiple small interfaces is not merely acceptable but desirable. A class can implement as many interfaces as appropriate for its capabilities. A client can require multiple interfaces when it needs multiple categories of operations. The difference is that each dependency is explicit and justified. No client is forced to depend on operations it does not use.

The opposite of following this principle is creating what developers call "fat interfaces" or "polluted interfaces." These are interfaces that accumulate methods over time as new requirements emerge. Each new feature adds methods to the existing interface, growing it without bound. Eventually, the interface becomes a dumping ground for every operation anyone might want, forcing all implementers to support everything and all clients to depend on everything. The interface loses focus and becomes a liability rather than an asset.

The Interface segregation principle differs from the Single-responsibility principle, though they are related and often confused. The Single-responsibility principle concerns classes and modules, stating that each should have one reason to change. The Interface segregation principle concerns interfaces, stating that each should serve one type of client. A class with a single responsibility might legitimately implement multiple interfaces because different clients need different aspects of that single responsibility. The principles operate at different levels of abstraction and address different concerns.

The relationship becomes clearer through an example. Consider a class responsible for user authentication—a single responsibility. Different clients need different aspects of authentication. A login form needs to verify credentials. An audit system needs to record authentication attempts. An administration panel needs to manage user accounts. The class has one responsibility, but multiple client perspectives. The Interface segregation principle guides creating separate interfaces for each client: Authenticator for credential verification, AuditLog for recording attempts, UserManager for account management. The single authentication class implements all three interfaces, maintaining its single responsibility while providing segregated interfaces to different clients.

The principle also differs from simply creating many small interfaces. Segregation is not about minimizing interface size but about aligning interfaces with client needs. An interface with ten methods is perfectly acceptable if a coherent client type needs all ten methods. What matters is that clients are not forced to depend on methods they do not use. An interface serves a specific kind of client, and its size naturally follows from that client's legitimate needs.

Another important distinction exists between the Interface segregation principle and the concept of role interfaces. Role interfaces are interfaces designed around specific roles or use cases in the system. While role interfaces often align well with the Interface segregation principle, the principle is broader. It applies even when clear roles are not evident, simply requiring that interfaces match client needs rather than implementation capabilities. Role-based design is one strategy for achieving interface segregation but not the only strategy.

The principle connects deeply with dependency management. In modern software systems, dependencies are not merely about compile-time coupling but also about deployment, packaging, and change propagation. When a client depends on a fat interface, changes to any method in that interface potentially affect the client, even methods the client never uses. This creates false dependencies—dependencies that exist in the dependency graph but represent no true relationship. Interface segregation eliminates false dependencies, making the dependency graph accurately reflect actual relationships.

The relationship to other SOLID principles reveals a cohesive design philosophy. The Dependency inversion principle states that high-level modules should depend on abstractions rather than concrete implementations. But what abstractions should they depend on? The Interface segregation principle answers: abstractions tailored to their specific needs. The Open-closed principle enables extension through polymorphism, and properly segregated interfaces make polymorphism more flexible because new implementations can choose which interfaces to implement based on their actual capabilities.

A common confusion involves thinking that the principle prohibits large interfaces entirely. This is not true. The principle prohibits forcing clients to depend on large interfaces when they only need a small portion. If a client genuinely needs all methods in a large interface, that interface is appropriately sized for that client. The problem arises when different clients need different subsets of methods, yet all must depend on the entire interface. The solution is not to eliminate large interfaces but to split them so each client depends on a smaller, focused interface that matches its needs.

Another confusion concerns whether the principle applies to concrete classes or only to formal interfaces. The principle applies wherever dependencies exist. If your code depends on a concrete class, it depends on every public method that class exposes, whether or not it calls those methods. This creates the same problems as depending on a fat interface. One solution is to extract interfaces from concrete classes, allowing clients to depend on small interfaces while the concrete class implements many such interfaces. Another solution is to design concrete classes more carefully, minimizing their public surface area to only what clients legitimately need.

The principle also raises questions about interface evolution. When requirements change and new methods are needed, should they be added to existing interfaces or placed in new interfaces? If the new methods logically belong with existing methods and all clients would reasonably need them, adding to existing interfaces is acceptable. But if the new methods serve a different client type or represent a different aspect of functionality, creating new interfaces maintains segregation. Inheritance between interfaces—where one interface extends another—can sometimes help, allowing some clients to depend on the base interface while others depend on the extended interface.

In practice, applying this principle requires understanding your clients. You must know who uses your abstractions and what they need. This client-focused design approach inverts the typical implementation-focused mindset. Instead of designing interfaces based on what implementations naturally provide, you design interfaces based on what clients actually consume. This often reveals that different clients have fundamentally different needs, even when working with the same conceptual entities.

The test for proper interface segregation is to examine each client and ask whether it uses every method in the interfaces it depends upon. If a client depends on an interface but only calls a subset of its methods, segregation is incomplete. The unused methods represent unnecessary coupling. Ideally, each client depends on interfaces where it calls every declared method. When this ideal is achieved, each client has exactly the dependencies it needs, no more and no less, and changes propagate only where they genuinely matter.

Dependency inversion principle

Code | Back to top

The Dependency inversion principle addresses perhaps the most architecturally significant question in software design: in what direction should dependencies point? The principle states that high-level modules should not depend on low-level modules, but both should depend on abstractions. Furthermore, abstractions should not depend on details, but details should depend on abstractions. This principle fundamentally inverts the dependency structure that naturally emerges from naive design, creating a more stable and flexible architecture.

To understand this principle deeply, you must first understand what "high-level" and "low-level" mean in this context. High-level modules contain business logic, policy decisions, and the essential purpose of your application. They represent what your system does and why it exists. Low-level modules contain implementation details—how data is stored, how messages are transmitted, how external systems are accessed. High-level modules embody the stable, valuable core of your application. Low-level modules represent volatile, replaceable mechanisms.

In traditional procedural or naive object-oriented design, high-level modules naturally depend on low-level modules. A business rule directly calls database code. An order processing module directly invokes email sending code. A reporting module directly accesses file system utilities. This seems intuitive—the high-level module needs something done, so it calls the low-level module that does it. The dependency arrow points from high-level to low-level, from the stable core toward the volatile details.

This dependency direction creates profound problems. When high-level modules depend on low-level modules, the high-level modules become coupled to implementation details. If the database changes from MySQL to PostgreSQL, the business logic must change. If email delivery switches from SMTP to a cloud service, the order processing must change. Your most valuable, stable code—the business logic—becomes infected with knowledge of your least stable code—the implementation details. Every change to how things are done forces changes to what is being done. This is backwards.

The Dependency inversion principle reverses this flow. It achieves this reversal through abstraction. The high-level module defines an abstraction—an interface or abstract contract—that describes what it needs from the low-level world, but expressed in terms that make sense to the high-level module. The high-level module then depends on this abstraction. Critically, the low-level module also depends on the same abstraction by implementing it. The dependency arrow now points from low-level to abstraction, and from high-level to abstraction, but never from high-level to low-level. The dependency has been inverted.

Consider an order processing system as a thorough demonstration. In naive design, an OrderProcessor class directly depends on a MySQLDatabase class for persistence, an SmtpEmailer class for notifications, and a StripePaymentGateway class for payment processing. The OrderProcessor contains the business logic—validating orders, calculating totals, applying discounts, determining fulfillment—but this valuable logic is tightly coupled to specific implementation technologies. The OrderProcessor cannot be understood, tested, or modified without knowing about MySQL, SMTP, and Stripe. The business logic depends on infrastructure.

Applying the Dependency inversion principle transforms this design. The OrderProcessor defines abstractions for what it needs, expressed in domain terms. It defines an OrderRepository abstraction with methods like saveOrder and findOrderById—concepts meaningful to order processing, not database operations. It defines a NotificationService abstraction with a method like notifyCustomer—a business concept, not an email protocol. It defines a PaymentProcessor abstraction with methods like processPayment and refund—financial operations, not API calls.

The OrderProcessor now depends only on these abstractions. Its code mentions OrderRepository, NotificationService, and PaymentProcessor, never MySQL, SMTP, or Stripe. The business logic is pure, focused entirely on order processing rules, free from infrastructure concerns. You can read the OrderProcessor code and understand the business process without any knowledge of technical implementation.

The implementation classes—MySQLOrderRepository, EmailNotificationService, StripePaymentProcessor—depend on the abstractions by implementing them. These classes know both about the abstractions and about the concrete technologies. The dependency points from concrete to abstract, from low-level to high-level. The MySQLOrderRepository knows about OrderRepository and about MySQL, but OrderProcessor only knows about OrderRepository. When you need to switch from MySQL to MongoDB, you create a MongoOrderRepository that implements the same OrderRepository abstraction. The OrderProcessor requires no changes. The dependency inversion has protected it from change.

The word "inversion" is crucial. Without this principle, dependencies naturally flow from abstract to concrete, from policy to detail, from high-level to low-level. With this principle, dependencies flow in the opposite direction, from concrete to abstract, from detail to policy, from low-level to high-level. The flow has been inverted.

The ownership of abstractions reveals the principle's deeper significance. In naive design, low-level modules expose interfaces that high-level modules consume. The database layer provides a database interface, and the business layer uses it. But who defined that interface? The database layer did, based on what databases do. The high-level module adapts itself to what the low-level module offers.

The Dependency inversion principle reverses this ownership. The high-level module defines the abstractions based on what it needs, expressed in its own conceptual vocabulary. The low-level module adapts itself to satisfy the abstraction the high-level module requires. The high-level module owns the abstraction. This ownership inversion is as important as the dependency inversion. It ensures that abstractions reflect business needs rather than technical capabilities.

The opposite of following this principle is the traditional layered architecture with downward dependencies. In such architectures, the presentation layer depends on the business layer, which depends on the data access layer, which depends on the database layer. Dependencies flow downward through the layers. The top of the system depends on the bottom. This makes the top volatile because any change at the bottom ripples upward. The business logic, which should be the most stable part of the system, becomes the most fragile because it sits atop a pyramid of dependencies.

Another manifestation of violating this principle is the creation of utility classes or helper modules that accumulate low-level functions. High-level modules depend on these utilities, pulling in implementation details through the back door. A DatabaseUtils class or EmailHelper class might seem convenient, but if high-level modules depend on them, those modules become coupled to low-level concerns. The principle demands that such utilities, if they exist at all, depend on abstractions defined by high-level modules.

The Dependency inversion principle differs fundamentally from simply using interfaces or abstract classes. Merely creating interfaces does not invert dependencies. If your business logic depends on an IDatabaseAccess interface designed by the database layer, you have abstraction but not inversion. The dependency still flows from high-level to low-level, just through an interface. Inversion requires that the abstraction is defined by, and belongs to, the high-level module. The abstraction must speak the language of the business, not the language of the technology.

The principle also differs from dependency injection, though they are frequently confused. Dependency injection is a technique for providing dependencies to objects, typically through constructor parameters or property setters rather than having objects create their own dependencies. Dependency injection is a mechanism that facilitates the Dependency inversion principle but is not the principle itself. You can use dependency injection while violating the Dependency inversion principle if you inject concrete low-level classes rather than high-level abstractions. You can also achieve dependency inversion without dependency injection through other means, though injection is the most common mechanism.

The relationship to other SOLID principles forms a coherent architectural vision. The Open-closed principle states that modules should be open for extension but closed for modification. The Dependency inversion principle enables this by decoupling high-level policy from low-level detail, allowing details to vary without affecting policy. The Liskov substitution principle ensures that the abstractions central to dependency inversion actually work—that implementations can genuinely substitute for the abstractions they claim to implement. Without Liskov substitutability, inverted dependencies become unreliable.

The Single-responsibility principle and Interface segregation principle work in concert with dependency inversion. The Single-responsibility principle ensures that each class has one reason to change, which aligns with separating high-level policy from low-level detail—these are different reasons to change. The Interface segregation principle ensures that abstractions are focused and client-specific, which complements the idea that high-level modules should define abstractions matching their specific needs.

A common confusion involves thinking that all dependencies must flow upward. This is not true. Dependencies within a layer can flow in any direction. Dependencies between peer modules at the same level of abstraction do not require inversion. The principle specifically addresses dependencies that cross architectural boundaries, particularly boundaries between business logic and implementation details. Within the business logic layer, one business rule might depend on another business rule without any issue. The concern is preventing business rules from depending on infrastructure.

Another confusion concerns whether concrete classes should ever be instantiated directly. In a perfectly inverted architecture, high-level modules never create instances of low-level classes. Instead, they receive instances of abstractions through dependency injection, and some composition root—a startup routine or dependency injection container—handles creating concrete instances and wiring dependencies. However, in practice, some direct instantiation of value objects, data structures, and simple utilities is pragmatic. The principle primarily targets dependencies on significant components, particularly those representing architectural concerns like persistence, external services, and infrastructure.

The principle also raises questions about the nature of abstractions. Should abstractions be minimal interfaces with few methods, or rich interfaces with many capabilities? The Dependency inversion principle does not dictate interface size but rather ownership and conceptual level. An abstraction should reflect the needs and vocabulary of the high-level module that defines it. If the high-level module genuinely needs many operations, the abstraction should provide them. If it needs few, the abstraction should be minimal. The Interface segregation principle provides additional guidance here, suggesting that when different clients need different operations, multiple focused abstractions serve better than one large abstraction.

The testing implications of dependency inversion are profound. When business logic depends on abstractions rather than concrete implementations, testing becomes straightforward. You can test the OrderProcessor by providing test implementations of OrderRepository, NotificationService, and PaymentProcessor—simple mock objects that verify interactions without involving databases, email servers, or payment gateways. The business logic can be tested in isolation, quickly and reliably, because dependency inversion has decoupled it from infrastructure. Conversely, when business logic directly depends on infrastructure, testing requires either running that infrastructure—slow and fragile—or introducing seams and test-specific code paths that compromise the design.

The principle's impact on architecture extends beyond individual classes to entire system structure. The Dependency inversion principle is the foundation of clean architecture, hexagonal architecture, and ports-and-adapters architecture. These architectural patterns place business logic at the center, surrounded by abstractions—ports—that the business logic defines. Infrastructure implementations—adapters—exist at the periphery, depending inward on the ports. Dependencies flow from the outside in, from low-level to high-level, from implementation to abstraction. The most valuable, stable part of the system—the business logic—has no outward dependencies. Everything depends on it; it depends on nothing concrete.

In practice, achieving thorough dependency inversion requires disciplined design. You must identify architectural boundaries, recognizing where high-level and low-level concerns meet. You must define abstractions carefully, ensuring they express business concepts rather than technical mechanisms. You must resist the convenience of direct dependencies, always inserting abstractions at boundaries even when they seem to add complexity. The payoff is a system where the most important code—the code that embodies your application's purpose—remains isolated, stable, and changeable independently of the volatile details that surround it.

The test for proper dependency inversion is to examine your high-level modules and check what they import or reference. If they mention database libraries, HTTP clients, file system APIs, or other infrastructure concerns, dependencies have not been inverted. If they mention only domain concepts and abstractions they define, inversion has succeeded. The direction of knowledge, the direction of dependencies, reveals whether your architecture has achieved the inversion that protects your core logic from the chaos of changing details.

About

Minimal examples of SOLID principles implemented in PHP

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published