A payment driver received a client through a factory. The factory promised a five-operation interface. The concrete client had nine additional public methods.
Those extra methods covered token charging, status checks, settlement, cancellation, token management, payment-method lookup, and refund cancellation. None of the retained callers used them. When the transport was replaced, all nine disappeared along with more than a hundred lines of concrete client code.
The driver did not change. Its interface did not change. Its factory interface did not change.
That is what a narrow collaboration buys: not fewer methods everywhere, but a smaller answer to “what may this caller rely on?”
The concrete object is not the caller’s role
A class can do fourteen things while one caller needs five:
final class PaymentDriver
{
public function __construct(
private Client $client,
) {}
}The constructor says the driver depends on the entire concrete client. A reader must inspect the driver to learn which part. A future edit can call any new public method without changing the declared dependency.
Compare that with:
interface PaymentOperations
{
public function createCharge(array $data): Response;
public function getPayment(string $reference): Response;
public function checkReturn(array $data): array;
public function createRefund(array $data): Response;
public function getRefund(string $reference): Response;
}
final class PaymentDriver
{
public function __construct(
private PaymentOperations $payments,
) {}
}The second constructor describes a role. It says nothing about the transport, the provider’s complete API, or methods another caller may need.
The concrete adapter can still implement more than this interface. Those capabilities do not automatically belong in this collaboration.
Count the methods available to the caller
Class size is a weak proxy for dependency width. A 500-line adapter behind a three-method role may give application code a smaller dependency than a 50-line client with ten public methods passed directly to every caller.
Inventory the surface from the caller’s position:
| Caller | Required operations |
|---|---|
| charge workflow | create charge, load payment, verify return |
| refund workflow | create refund, load refund |
| token administration | create token, delete token |
| settlement tool | settle, cancel |
If one object supplies all four groups, that does not mean every caller should receive the union.
This is the useful form of interface segregation. It does not begin with splitting a large interface because a rule says interfaces should be small. It begins with different callers relying on different behavior.
A five-method role may still be too broad if the charge and refund workflows change independently. It may also be exactly right if one driver owns both operations and their authentication and failure semantics move together. Method count alone does not decide.
Unused public methods still create pressure
The nine concrete methods removed during the retained transport migration had no callers elsewhere in that revision. Their absence made deletion safe enough to establish through repository search and existing tests.
They were still public. That created several weak signals:
- autocomplete offered operations the application did not support;
- a new caller could adopt one without introducing a deliberate role;
- tests of the concrete client could make unused provider behavior look owned;
- a reviewer could not tell whether removal was a contract change without searching every call site; and
- the implementation looked responsible for a larger provider surface than its consumers required.
Unused public code is not necessarily harmful. It may be a staged capability, a library feature, or a shared adapter used outside the repository being inspected.
The question is whether the application claims to own it. If no caller, public contract, compatibility promise, or near-term migration requires the method, keeping it public makes future dependency possible without preserving current value.
A factory can preserve the role
The retained driver did not construct the client itself. It received a factory whose return type was the same five-operation interface:
interface PaymentOperationsFactory
{
public function make(Credentials $credentials): PaymentOperations;
}This matters when construction depends on credentials known only at runtime. The driver can ask for a role configured for one account without learning the adapter class or transport library.
The factory should not erase the benefit by returning a concrete type:
public function make(Credentials $credentials): VendorClient;That result reintroduces every public vendor operation at the point of use. The
factory hides new; it does not narrow the collaboration.
Nor should the factory grow operational methods of its own. Creating a client and charging through a client are separate reasons to change. A factory which also proxies every operation becomes another broad client with a less honest name.
The declaration does not enforce a sandbox
In PHP, an interface-typed property still contains the concrete object at runtime. If the object has another public method, the runtime does not remove it. Reflection, a type check, or careless code can reach beyond the declared role.
The interface is therefore not a security boundary. It helps through:
- static analysis;
- editor completion;
- constructor documentation;
- substitution in tests;
- review of dependency changes; and
- an explicit place to discuss compatibility.
If a caller must be unable to reach the larger object, pass a wrapper which implements only the narrow role and does not expose the underlying client. That is a stronger capability boundary than a type annotation.
This distinction prevents a common exaggeration: dependency inversion changes source-level knowledge and substitution. It does not make dangerous runtime capabilities disappear.
Replacement tests the role from both sides
The transport migration replaced the internals of the concrete client. The application-facing driver, five-operation interface, and factory interface had no diff in that slice. The concrete implementation lost nine unused methods and delegated the remaining five through the new transport.
That result supports two separate conclusions.
First, the declared role covered what the driver actually used. The transport could change without teaching the driver a new API.
Second, the concrete client had been wider than that role. The migration was also an opportunity to delete unsupported surface.
It does not prove that the five operations were perfectly designed. They still used arrays and transport response objects. The driver still interpreted provider-shaped data. A stable interface can preserve a semantic leak just as effectively as it preserves a good boundary.
The right verification therefore checks both continuity and containment:
continuity
existing caller behavior still passes
interface signatures are unchanged
factory still returns the declared role
containment
removed concrete methods have no callers
transport types do not enter the caller
new adapter behavior is tested at the edge“No caller changed” is useful evidence. It is not a complete design verdict.
Test the collaboration, not the library
A driver test should provide a fake for the five-operation role and assert the driver’s result. It should not reproduce the new transport library’s request builder.
An adapter test should prove request construction, authentication, response handling, and failure translation. It should not re-test the driver’s payment decisions.
The public reproduction gives one synthetic driver a declared five-operation role while its first concrete client exposes nine additional methods. It proves the driver uses only the declared operations, then removes the nine extras and changes the concrete implementation without modifying the driver. A separate projection demonstrates the stronger wrapper boundary.
The fixture does not execute the private language, transport, or payment code. Its wrapper removes runtime reachability more strongly than the retained interface declaration did.
Add a role when a caller earns one
An interface for every class produces ceremony without information. If one implementation and one caller always change together, a concrete dependency can be the clearest design.
A declared role earns its place when it does at least one useful job:
- separates what one caller needs from a broader provider;
- lets construction vary independently;
- supports a second implementation or a focused fake;
- keeps transport types out of application code; or
- makes a compatibility boundary explicit before a risky replacement.
Name it after the behavior the caller needs. Return it from factories under that same type. Keep implementation-only operations out of the role, and remove public methods which the application does not intend to support.
The collaboration is not defined by everything the concrete object can do. It is defined by what the caller is allowed to assume will remain.