Index

Dependency Inversion Lets Policy Own the Boundary

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
ExchangeRates

They 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.