Infrastructure: adapters between the Domain and the outside world
Part of the Labrodev playbook. The law for this component lives in the always-on labrodev-infrastructure guideline (musts, must-nots); the per-file checklist is rules/infrastructure.md. This skill holds the craft: anatomy, canonical templates, and edge cases.
Four pieces make up the pattern: a contract the Domain depends on, one adapter per vendor implementing it, a resolver for when the vendor is chosen at runtime, and a boundary DTO mapping vendor payloads into Domain-friendly shapes. Splitting it this way means adding or swapping a vendor never touches Domain code.
The pattern: contract → adapters → resolver
Worked example — one messaging capability, several vendors:
Core/Infrastructure/Messaging/
├── Contracts/
│ └── MessageNotifier.php the capability contract
├── SlackNotifier.php per-vendor adapter
├── TelegramNotifier.php per-vendor adapter
├── MessageNotifierResolver.php picks the adapter by domain enum
└── OutboundMessagePayload.php boundary DTO (final readonly)
Contract
<?php
declare(strict_types=1);
namespace Core\Infrastructure\Messaging\Contracts;
use Core\Infrastructure\Messaging\OutboundMessagePayload;
interface MessageNotifier
{
public function send(OutboundMessagePayload $outboundMessagePayload): void;
}
Boundary DTO
<?php
declare(strict_types=1);
namespace Core\Infrastructure\Messaging;
final readonly class OutboundMessagePayload
{
public function __construct(
public string $recipient,
public string $subject,
public string $body,
) {}
}
Adapter (one per vendor)
<?php
declare(strict_types=1);
namespace Core\Infrastructure\Messaging;
use Core\Infrastructure\Messaging\Contracts\MessageNotifier;
use Illuminate\Support\Facades\Http;
final readonly class SlackNotifier implements MessageNotifier
{
public function __construct(
private string $webhookUrl,
) {}
public function send(OutboundMessagePayload $outboundMessagePayload): void
{
Http::asJson()
->post($this->webhookUrl, [
'text' => sprintf('%s — %s', $outboundMessagePayload->subject, $outboundMessagePayload->body),
])
->throw();
}
}
Resolver (when the vendor is chosen at runtime)
<?php
declare(strict_types=1);
namespace Core\Infrastructure\Messaging;
use Core\Domain\Channel\Enums\ChannelType;
use Core\Infrastructure\Messaging\Contracts\MessageNotifier;
final readonly class MessageNotifierResolver
{
public function __invoke(ChannelType $channelType): MessageNotifier
{
return match ($channelType) {
ChannelType::Slack => resolve(SlackNotifier::class),
ChannelType::Telegram => resolve(TelegramNotifier::class),
};
}
}
Wiring (deliberate, in a provider)
$this->app->when(SlackNotifier::class)
->needs('$webhookUrl')
->giveConfig('services.slack.webhook_url');
Consuming from the Domain
A Pipeline step, Service, or Job injects the contract (single vendor) or the resolver (runtime vendor) — never a concrete adapter:
final readonly class PushBookingToCrm
{
public function __construct(
private CrmClient $crmClient,
) {}
}
Inbound payload mapping (fail loud)
Inbound vendor data is mapped by a static factory on the DTO (or a dedicated {Thing}Mapper when the mapping is large). The mapping commits to the vendor's documented contract — one payload key per field, required fields throw, nullable only where the docs say optional (→ labrodev-core contract commitment):
<?php
declare(strict_types=1);
namespace Core\Infrastructure\Crm;
use Core\Infrastructure\Crm\Exceptions\CrmContactPayloadException;
final readonly class CrmContactPayload
{
public function __construct(
public int $externalId,
public string $email,
public ?string $phone,
) {}
/**
* @param array<string, mixed> $payload
*/
public static function fromPayload(array $payload): self
{
$externalId = $payload['id'] ?? null;
$email = $payload['email'] ?? null;
$phone = $payload['phone'] ?? null;
if (! is_int($externalId) || ! is_string($email) || ($phone !== null && ! is_string($phone))) {
throw CrmContactPayloadException::make(payload: $payload);
}
return new self(
externalId: $externalId,
email: $email,
phone: $phone,
);
}
}
?? null here is isset-safe reading of the one documented key, immediately followed by a throw — not a fallback. The exception lives in Core/Infrastructure/{Integration}/Exceptions and follows the make() convention (→ labrodev-exception skill).
The anti-pattern this exists to prevent — hedged mapping that guards against imagined shape variants:
// ❌ contract-blind: guesses keys, coerces everything to null
$firstname = $this->stringOrNull($client['first_name'] ?? $client['firstname'] ?? null);
$phone = $this->stringOrNull($client['mobile'] ?? $client['phone'] ?? null);
When the vendor renames a key, the hedged version silently writes null into persisted data; the strict mapper throws at the boundary, where the bug is visible and attributable. If the payload shape is genuinely unknown, capture a real payload or read the vendor docs before writing the mapper — the fallback chain is never the answer.
Placement decision
| Situation | Home |
|---|---|
| Talking to an external system (transport, auth, mapping) | Core/Infrastructure/{Integration} |
| Deciding whether/when to talk to it | Domain Action/Service/Rule |
| Multi-step flow that includes external calls | Pipeline with an Infrastructure-calling step → see the labrodev-pipeline skill |
| Async/retryable external work | Queued Job delegating to the contract |
| Generic technical helper with no external system | Core/Support → see the labrodev-core skill |