This class uses constructor injection and still points in the wrong direction:
final readonly class QuoteShipment
{
public function __construct(private GuzzleHttp\Client $http) {}
public function handle(Shipment $shipment): Money
{
$response = $this->http->post('/carrier/rates', [
'json' => CarrierPayload::fromShipment($shipment),
]);
return Money::EUR(
json_decode($response->getBody(), true)['price_cents'],
);
}
}The dependency is visible and testable, but the application use case knows an
HTTP client, carrier endpoint, payload, response shape, and currency
assumption. Replacing new Client() with an injected client improved assembly;
it did not protect policy from the detail.
Composition can create a seam for behavior that changes independently. Dependency inversion asks which side gets to define that seam.
Dependency inversion changes who defines that boundary. The use case should depend on the capability it needs. The carrier adapter should translate that capability to HTTP.
The caller defines the smallest useful contract
QuoteShipment needs a shipping quote, not a generic request method:
interface ShippingQuotes
{
public function for(Shipment $shipment): ShippingQuote;
}final readonly class QuoteShipment
{
public function __construct(private ShippingQuotes $quotes) {}
public function handle(Shipment $shipment): ShippingQuote
{
if (! $shipment->isReadyToQuote()) {
throw ShipmentCannotBeQuoted::becauseItIsIncomplete();
}
return $this->quotes->for($shipment);
}
}Place the interface beside the application capability that owns its meaning,
not inside an Infrastructure\Contracts directory. The high-level code now
defines what a valid request, result, and failure mean.
The adapter depends on that contract:
final readonly class AcmeCarrierQuotes implements ShippingQuotes
{
public function __construct(private ClientInterface $http) {}
public function for(Shipment $shipment): ShippingQuote
{
try {
$response = $this->http->request('POST', '/rates', [
'json' => AcmeRateRequest::from($shipment)->toArray(),
]);
} catch (ConnectException | ServerException $exception) {
throw ShippingQuotesUnavailable::from($exception);
} catch (ClientException $exception) {
throw ShippingQuoteRequestWasRejected::from($exception);
}
try {
return AcmeRateResponse::fromJson((string) $response->getBody())
->toShippingQuote();
} catch (InvalidAcmeRateResponse $exception) {
throw ShippingQuoteResponseWasInvalid::from($exception);
}
}
}Those exceptions are application-level failure categories. Guzzle documents separate network, client-response, and server-response errors in its exception hierarchy; the adapter maps them to unavailable or rejected outcomes without making transport types part of the use-case contract. A malformed provider response is a third failure, so the example does not collapse every problem into one generic exception.
Control still flows outward to the carrier at runtime. Source dependencies point inward: the adapter imports the application contract, while the use case does not import Guzzle or the carrier schema. Robert Martin’s Clean Architecture dependency rule describes this distinction between inner policy and outer mechanism.
A generic abstraction can preserve the wrong dependency
This interface changes only the spelling:
interface HttpClient
{
public function post(string $url, array $payload): array;
}The use case still owns URLs, transport verbs, and external payloads. It can swap Guzzle for cURL, but cannot change the carrier protocol without changing policy code.
Good dependency-inversion boundaries use application language:
ShippingQuotes
Payments
Orders
Clock
ExchangeRatesThey do not need one-to-one parity with vendors. Two carrier adapters can
implement ShippingQuotes, while one carrier SDK may support several
application capabilities through separate adapters.
The contract should not contain every feature exposed by the vendor. It should contain what this policy requires. Otherwise a low-level API has been copied upward and renamed.
The composition root knows both sides
Some code must select the implementation. Laravel describes service
providers as the central place for application
bootstrapping and tells providers to register container bindings. A provider’s
register method can therefore hold the Laravel side of this composition
choice:
$this->app->bind(
ShippingQuotes::class,
AcmeCarrierQuotes::class,
);Laravel’s container documentation explains that interface bindings are needed when the container cannot infer an implementation. The binding is wiring, not evidence that the abstraction is good.
One provider may contain every application-owned binding in a small codebase.
In a larger one, the composition root is the inspectable set of providers and
their registration in bootstrap/providers.php, not each provider in
isolation. The important property is that implementation choices can be found
without tracing business code.
Keep selection at the edge. A use case that calls
app(ShippingQuotes::class) hides its dependency. A domain object that reads
config('shipping.carrier') owns deployment policy. Constructor injection
keeps both decisions visible.
Contextual bindings can select different implementations for different consumers, but business selection based on shipment or customer data belongs in an explicit resolver. Container configuration should not become a hidden rules engine.
Invert dependencies at boundaries that matter
Not every concrete class needs an interface. This adds no useful direction:
interface TaxCalculatorInterface
{
public function calculate(Money $subtotal): Money;
}
final class TaxCalculator implements TaxCalculatorInterface
{
// One implementation, stable pure calculation.
}Depending directly on a stable domain service is fine. An interface earns its place when it:
- keeps framework or vendor types out of policy;
- allows an independently changing implementation;
- represents an input/output boundary such as time, persistence, or network;
- supports implementations selected by consumer or deployment context; or
- reverses a package dependency that would otherwise point outward.
“It is easier to mock” is weak evidence by itself. Excessive interfaces can turn simple tests into mock choreography and obscure which dependencies are actual architectural boundaries.
Test policy, adapter, and wiring separately
The policy test uses a small implementation in application terms:
$quotes = new FixedShippingQuotes(
new ShippingQuote(Money::EUR(12_00), ServiceCode::Express),
);
$result = (new QuoteShipment($quotes))->handle(aShipment());
expect($result->price)->toEqual(Money::EUR(12_00));The adapter test verifies real mapping and normalized failures with an HTTP fake or provider sandbox:
it('maps an Acme rate response to our quote', function () {
$http = httpRespondingWith(fixture('acme-rate-success.json'));
$adapter = new AcmeCarrierQuotes($http);
$quote = $adapter->for(aShipment());
expect($quote->service)->toBe(ServiceCode::Express)
->and($quote->price)->toEqual(Money::EUR(12_00));
});Add deterministic cases for network and server failures becoming
ShippingQuotesUnavailable, a client response becoming
ShippingQuoteRequestWasRejected, and malformed provider data becoming
ShippingQuoteResponseWasInvalid. A provider sandbox can check the external
contract, but it does not replace fixtures for failures the provider cannot
reliably produce on demand.
One container integration test proves ShippingQuotes resolves to the
configured adapter. The application test should not know the provider JSON;
the adapter test should not repeat shipment eligibility rules.
Contract tests are valuable when several adapters exist. They should assert application semantics—currency, identifiers, error categories, and supported services—not that each adapter makes identical HTTP calls.
Direction is the design decision
Dependency injection answers how an object receives a collaborator. Dependency inversion answers which side owns the collaborator’s abstraction.
Let high-level policy state the capability it needs. Make volatile adapters translate vendor, framework, and persistence details into that language. Wire them together at the edge. If the application contract still speaks in URLs, PDO rows, or SDK responses, the dependency has been injected but not inverted.