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