Index

Object Design

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 constructor is cleaner, but the direction is still wrong. The application use case knows an HTTP client, carrier endpoint, payload, response shape, and currency assumption. Moving new Client() into the container made assembly easier. It did nothing to keep carrier details out of application policy.

Composition can create a seam for behavior that changes independently. The design decision is who gets to name what crosses that seam. Here, the use case should state the capability it needs and 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);
    }
}

Put the interface beside the application capability that owns its meaning. An Infrastructure\Contracts directory may look tidy, but it gives the wrong side the vocabulary. The high-level code should define 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 describe failures the application can reason about. Guzzle documents separate network, client-response, and server-response errors in its exception hierarchy . The adapter maps them to unavailable or rejected outcomes without leaking transport types into the use case. A malformed provider response is a different problem again. Calling all three IntegrationException would only make the catch block shorter; it would also throw away the distinction the application needs.

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 looks respectable and buys almost nothing:

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, which is rarely the change that hurts. Changing the carrier protocol still means 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 contain what this policy requires, not every feature the vendor happens to expose. Copying a low-level API upward and renaming its methods is busywork, not inversion.

The composition root knows both sides

Someone still has to 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.

A small codebase may keep every application-owned binding in one provider. In a larger one, the composition root is the inspectable set of providers and their registration in bootstrap/providers.php. The exact file count is not interesting. What matters is being able to find implementation choices 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 one only adds paperwork:

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. Add an interface when it does real architectural work:

  • 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. Enough interfaces will turn simple tests into mock choreography while making the real architectural boundaries harder to spot. That is a bad trade even if every class looks perfectly decoupled in a diagram.

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 is enough to prove that ShippingQuotes resolves to the configured adapter. Keep the responsibilities boring and obvious: the application test should not know the provider JSON, and 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. The important design question is who owns the language of that collaboration.

A quick check is to read the application layer’s imports. If a quote use case still speaks in URLs, PDO rows, or SDK responses, moving object construction into a container has only cleaned up the wiring. The inversion exists once the application states what it needs and the volatile adapter does the translating.