Hexagonal Architecture in Practice: Ports, Adapters, and Tests That Skip the Database
Decoupling Infrastructure from Business Logic: A Contract-First Approach to Backend Architecture
Current Situation Analysis
Modern web frameworks prioritize developer velocity by providing tightly integrated tooling: ORMs, mailers, queue workers, and analytics SDKs are all available as global facades or singleton services. This convenience creates a subtle but pervasive architectural debt. Controllers and route handlers gradually accumulate direct dependencies on databases, external APIs, and background job dispatchers. What begins as a straightforward CRUD endpoint evolves into a monolithic procedure that orchestrates persistence, notifications, metrics, and third-party synchronization.
The problem is rarely recognized until the test suite slows down or infrastructure upgrades trigger cascading failures. Framework-provided test doubles (Mail::fake(), Queue::fake(), DB::transaction()) create an illusion of isolation. In reality, they force every test to boot the application container, load service providers, and resolve framework-specific bindings. As the codebase grows, test execution time scales linearly with infrastructure complexity. CI pipelines that once completed in minutes routinely stretch past twenty minutes, primarily because tests are waiting for container initialization, database migrations, and mock server spin-ups rather than executing business logic.
This coupling is overlooked because it aligns with framework defaults. Developers are taught to inject services directly into controllers and rely on facades for cross-cutting concerns. The maintenance burden compounds silently: every time a mailer library changes, a queue driver is swapped, or an analytics SDK is upgraded, dozens of test files require updates to match the new fake implementations. Real-world migrations away from this pattern consistently demonstrate CI pipeline duration reductions from ~20 minutes to ~5 minutes, alongside a measurable drop in test maintenance overhead. The root cause is not the framework itself, but the absence of a clear boundary between application intent and infrastructure implementation.
WOW Moment: Key Findings
The architectural shift from framework-coupled controllers to contract-driven orchestration produces measurable improvements across four critical dimensions. The following comparison illustrates the operational impact of adopting a ports-and-adapters boundary.
| Approach | Test Execution Time | Framework Boot Dependency | Infrastructure Swap Effort | Test Maintenance Frequency |
|---|---|---|---|---|
| Traditional MVC + Framework Fakes | High (20+ min CI) | Mandatory per test | High (refactor controllers & tests) | High (breaks on SDK/driver updates) |
| Contract-First (Ports & Adapters) | Low (~5 min CI) | Optional (pure PHP/TS tests) | Low (swap adapter, keep contract) | Low (contracts remain stable) |
This finding matters because it decouples business logic validation from infrastructure provisioning. When controllers depend on application-owned interfaces rather than concrete services, tests can execute against in-memory implementations that require zero container boot, zero database connections, and zero external service mocks. The business flow is validated in isolation, while infrastructure behavior is verified separately in adapter-level integration tests. This separation enables parallel test execution, faster feedback loops, and safe infrastructure upgrades that only touch the outer hexagon.
Core Solution
The architecture relies on three layers: application contracts (ports), orchestration logic (actions/use cases), and infrastructure implementations (adapters). The following implementation demonstrates the pattern using a subscription billing domain. All code is written in modern PHP, but the structure translates directly to TypeScript, Go, or Java.
Step 1: Define Application Contracts
Contracts must describe what the application needs, not how it gets it. Naming should reflect business intent. Read and write operations should be segregated to enforce the Interface Segregation Principle.
namespace App\Application\Billing\Contracts;
interface IWriteSubscriptions
{
public function persist(array $payload): Subscription;
public function markRenewed(int $id, \DateTimeImmutable $nextBillingDate): void;
}
interface IReadSubscriptions
{
public function findById(int $id): ?Subscription;
public function findByCustomerId(int $customerId): array;
}
interface IAlertCustomers
{
public function sendRenewalNotice(Subscription $subscription, Customer $customer): void;
}
interface ITrackUsageMetrics
{
public function recordBillingEvent(string $eventName, array $metadata): void;
}
interface ISyncBillingProvider
{
public function scheduleProviderSync(int $subscriptionId): void;
}
Architectural Rationale:
- Contracts live in the application layer. They contain zero infrastructure imports.
IWriteSubscriptionsandIReadSubscriptionsare split because consumers rarely need both. A reporting dashboard only requires reads; a checkout flow only requires writes.IAlertCustomersavoids naming specific transports (email, SMS, push). The adapter decides the channel.ISyncBillingProviderabstracts the third-party vendor. Replacing Stripe with Paddle later only requires a new adapter.
Step 2: Build the Orchestrator
The action class coordinates the business flow. It depends exclusively on contracts. It contains no framework calls, no config checks, and no direct infrastructure references.
namespace App\Application\Billing\Actions;
final class ProcessRenewalAction
{
public function __construct(
private IReadSubscriptions $reader,
private IWriteSubscriptions $writer,
private IAlertCustomers $notifier,
private ITrackUsageMetrics $metrics,
private ISyncBillingProvider $providerSync,
) {}
public function execute(int $subscriptionId): Subscription
{
$subscription = $this->reader->findById($subscriptionId);
if ($subscription === null) {
throw new SubscriptionNotFoundException($subscriptionId);
}
$nextBilling = (new \DateTimeImmutable())->modify('+1 month');
$this->writer->markRenewed($subscriptionId, $nextBilling);
$customer = $subscription->getCustomer();
$this->notifier->sendRenewalNotice($subscription, $customer);
$this->metrics->recordBillingEvent('subscription_renewed', [
'subscription_id' => $subscriptionId,
'plan' => $subscription->getPlan(),
]);
$this->providerSync->scheduleProviderSync($subscriptionId);
return $subscription;
}
}
Architectural Rationale:
- The action is
finaland immutable. Dependencies are injected via constructor. - Business rules (date calculation, validation, sequencing) live here.
- Configuration checks (e.g.,
if (config('billing.sync_enabled'))) are removed. The adapter handles feature toggles internally. The application always requests the sync; the adapter decides whether to execute it.
Step 3: Implement Infrastructure Adapters
Adapters live in the infrastructure layer. They implement contracts and handle framework-specific concerns: Eloquent queries, mailer facades, queue dispatchers, SDK clients, and configuration resolution.
namespace App\Infrastructure\Persistence\Eloquent;
final class DatabaseSubscriptionRepository implements IReadSubscriptions, IWriteSubscriptions
{
public function findById(int $id): ?Subscription
{
$model = SubscriptionModel::find($id);
return $model ? SubscriptionMapper::toDomain($model) : null;
}
public function findByCustomerId(int $customerId): array
{
return SubscriptionModel::where('customer_id', $customerId)
->get()
->map(fn($m) => SubscriptionMapper::toDomain($m))
->all();
}
public function persist(array $payload): Subscription
{
$model = SubscriptionModel::create($payload);
return SubscriptionMapper::toDomain($model);
}
public function markRenewed(int $id, \DateTimeImmutable $nextBillingDate): void
{
SubscriptionModel::where('id', $id)->update([
'status' => 'active',
'next_billing_at' => $nextBillingDate,
]);
}
}
namespace App\Infrastructure\Notifications;
final class EmailAlertGateway implements IAlertCustomers
{
public function __construct(private Mailer $mailer) {}
public function sendRenewalNotice(Subscription $subscription, Customer $customer): void
{
$this->mailer->to($customer->getEmail())
->queue(new RenewalNoticeMailable($subscription));
}
}
namespace App\Infrastructure\BillingProviders;
final class StripeSyncAdapter implements ISyncBillingProvider
{
public function __construct(
private Dispatcher $queue,
private bool $syncEnabled,
) {}
public function scheduleProviderSync(int $subscriptionId): void
{
if (!$this->syncEnabled) {
return;
}
$this->queue->dispatch(new SyncSubscriptionJob($subscriptionId));
}
}
Architectural Rationale:
- Adapters are allowed to know about Eloquent, Mailer, Queue, and config.
- Feature flags and environment checks are encapsulated in the adapter. The application layer remains pure.
EmailAlertGatewayusesqueue()instead ofsend(). The adapter decides sync vs async behavior based on infrastructure capabilities.
Step 4: Wire Contracts to Adapters
Dependency injection binds application contracts to infrastructure implementations. In Laravel, this occurs in a service provider. The pattern is identical in NestJS, Spring, or Go wire.
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use App\Application\Billing\Contracts\{IReadSubscriptions, IWriteSubscriptions, IAlertCustomers, ITrackUsageMetrics, ISyncBillingProvider};
use App\Infrastructure\Persistence\Eloquent\DatabaseSubscriptionRepository;
use App\Infrastructure\Notifications\EmailAlertGateway;
use App\Infrastructure\BillingProviders\StripeSyncAdapter;
use App\Infrastructure\Metrics\DatadogMetricLogger;
final class BillingServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(IReadSubscriptions::class, DatabaseSubscriptionRepository::class);
$this->app->bind(IWriteSubscriptions::class, DatabaseSubscriptionRepository::class);
$this->app->bind(IAlertCustomers::class, EmailAlertGateway::class);
$this->app->bind(ITrackUsageMetrics::class, fn() => new DatadogMetricLogger(
enabled: config('services.datadog.enabled'),
client: $this->app->make(DatadogClient::class),
));
$this->app->bind(ISyncBillingProvider::class, fn() => new StripeSyncAdapter(
queue: $this->app->make(Dispatcher::class),
syncEnabled: config('billing.stripe_sync'),
));
}
}
Architectural Rationale:
DatabaseSubscriptionRepositoryis bound twice. The container resolves the same instance, but consumers only request the interface they need.- Factory bindings allow constructor parameters to be resolved from config or other services without polluting the adapter with global helpers.
Step 5: Create Pure Test Doubles
Framework fakes are replaced with in-memory implementations and recording gateways. These require zero container boot and execute in microseconds.
namespace Tests\Support\Doubles;
final class InMemorySubscriptionStore implements IReadSubscriptions, IWriteSubscriptions
{
private array $storage = [];
private int $idCounter = 1;
public function findById(int $id): ?Subscription
{
return $this->storage[$id] ?? null;
}
public function findByCustomerId(int $customerId): array
{
return array_values(array_filter($this->storage, fn($s) => $s->getCustomerId() === $customerId));
}
public function persist(array $payload): Subscription
{
$subscription = Subscription::fromArray(array_merge($payload, ['id' => $this->idCounter]));
$this->storage[$this->idCounter] = $subscription;
$this->idCounter++;
return $subscription;
}
public function markRenewed(int $id, \DateTimeImmutable $nextBillingDate): void
{
if (!isset($this->storage[$id])) {
throw new SubscriptionNotFoundException($id);
}
$this->storage[$id]->setNextBillingDate($nextBillingDate);
$this->storage[$id]->setStatus('active');
}
}
namespace Tests\Support\Doubles;
final class AlertCaptureGateway implements IAlertCustomers
{
public array $capturedAlerts = [];
public function sendRenewalNotice(Subscription $subscription, Customer $customer): void
{
$this->capturedAlerts[] = [
'subscription' => $subscription,
'customer' => $customer,
];
}
}
Architectural Rationale:
InMemorySubscriptionStoreis a real implementation, not a mock. It maintains state, supports cross-method queries, and mimics persistence behavior without a database.AlertCaptureGatewayrecords command executions for assertion. Tests verify side effects by inspecting$capturedAlerts.- No framework facades are used. Tests instantiate the action directly with these doubles and execute synchronously.
Pitfall Guide
1. Leaking Infrastructure Types in Contracts
Explanation: Importing Eloquent models, Laravel collections, or framework-specific DTOs into port interfaces forces adapters to translate infrastructure types at the boundary, but leaks implementation details into the application layer. Fix: Define domain entities and value objects in the application layer. Contracts should only accept and return application-owned types. Use mappers in adapters to convert between infrastructure and domain representations.
2. The "God Interface" Anti-Pattern
Explanation: Creating a single IOrderService interface with 15+ methods violates interface segregation. Consumers depend on methods they never call, making refactoring difficult and increasing coupling.
Fix: Split interfaces by consumer need. A checkout flow needs IWriteOrders and IAlertCustomers. A reporting dashboard needs IReadOrders and ITrackMetrics. Keep contracts narrow and intent-focused.
3. Ignoring Transaction Boundaries in In-Memory Fakes
Explanation: Database adapters wrap operations in transactions. In-memory fakes that execute immediately can mask race conditions or partial failure scenarios that only appear in production.
Fix: Implement a simple transaction simulation in in-memory stores. Track pending changes, allow commit() and rollback() methods, and ensure the action layer can trigger them. Alternatively, accept that in-memory tests validate business logic, while transaction integrity is verified in adapter-level integration tests.
4. Config/Feature Flag Leakage into Business Logic
Explanation: Leaving if (config('feature.enabled')) checks inside actions or controllers forces the application layer to understand infrastructure state. This breaks testability and couples business rules to deployment configuration.
Fix: Move all configuration resolution into adapters. The application requests an operation unconditionally. The adapter reads config, feature flags, or environment variables and decides whether to execute, queue, or silently skip.
5. Treating Recording Fakes as Mocks
Explanation: Recording fakes should execute the command and store state for inspection. Mocks that only verify method calls (->shouldReceive('send')->once()) hide implementation bugs and require updates when internal logic changes.
Fix: Use recording fakes to capture actual payloads. Assert on the captured data structure, not on call counts. This validates that the correct information flows through the system, regardless of how many times the adapter is invoked.
6. Over-Abstracting Stable Dependencies
Explanation: Creating ports for every single dependency, including highly stable ones like a logging library or a configuration reader, introduces unnecessary indirection and boilerplate. Fix: Apply ports only to dependencies that change frequently, have slow test overhead, or represent external boundaries. Stable, framework-agnostic utilities can be injected directly. Reserve the hexagonal boundary for infrastructure that impacts test speed or deployment flexibility.
7. Neglecting Async/Sync Semantics in Adapters
Explanation: Actions assume synchronous execution. If an adapter switches from synchronous API calls to background jobs, the action may proceed before the side effect completes, causing inconsistent state in tests or race conditions in production. Fix: Explicitly document sync/async expectations in contracts. If an operation must be synchronous for business correctness, enforce it in the adapter. If async is acceptable, design tests to wait for job completion or assert on job dispatch rather than immediate state changes.
Production Bundle
Action Checklist
- Identify infrastructure couplings in existing controllers (ORM, mailer, queue, SDKs)
- Define application contracts using intent-based naming (
IReadX,IWriteX,IAlertX) - Extract business orchestration into action classes that depend only on contracts
- Move configuration checks, feature flags, and sync/async decisions into adapters
- Implement in-memory stores for read/write contracts with local state management
- Create recording gateways for command-style contracts to verify side effects
- Wire contracts to adapters in a dedicated service provider or DI module
- Replace framework fakes with pure test doubles and remove container boot from unit tests
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| High-frequency CRUD with stable DB | Direct framework usage | Low change probability, minimal test overhead | Low |
| External API/SDK with slow response or frequent updates | Contract + Adapter | Isolates business logic, enables fast in-memory tests | Medium (initial abstraction) |
| Notification/Mailer system with multiple channels | Contract + Adapter | Swaps transport without touching business flow | Low |
| Background job queue with complex retry logic | Contract + Adapter | Decouples orchestration from queue driver | Medium |
| Analytics/Telemetry SDK | Contract + Adapter | Prevents SDK upgrades from breaking tests | Low |
| Internal utility (string formatter, date helper) | Direct injection | No infrastructure boundary, stable API | None |
Configuration Template
// Service Provider / DI Module
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use App\Application\Billing\Contracts\{IReadSubscriptions, IWriteSubscriptions, IAlertCustomers, ISyncBillingProvider};
use App\Infrastructure\Persistence\Eloquent\DatabaseSubscriptionRepository;
use App\Infrastructure\Notifications\EmailAlertGateway;
use App\Infrastructure\BillingProviders\StripeSyncAdapter;
final class BillingServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(IReadSubscriptions::class, DatabaseSubscriptionRepository::class);
$this->app->bind(IWriteSubscriptions::class, DatabaseSubscriptionRepository::class);
$this->app->bind(IAlertCustomers::class, EmailAlertGateway::class);
$this->app->bind(ISyncBillingProvider::class, function ($app) {
return new StripeSyncAdapter(
queue: $app->make(Dispatcher::class),
syncEnabled: config('billing.stripe_sync'),
);
});
}
}
Quick Start Guide
- Extract a single controller action that interacts with 2+ infrastructure services. Identify the business intent.
- Create two interfaces: one for data access (
IReadX/IWriteX), one for side effects (IAlertX/ISyncX). Place them inApp/Application/{Domain}/Contracts/. - Build an action class that accepts these interfaces via constructor. Move business sequencing and validation into the action. Strip all framework calls.
- Implement adapters in
App/Infrastructure/. Wire them in a service provider. Move config/feature flag checks into the adapters. - Replace framework fakes with in-memory stores and recording gateways. Run tests without booting the container. Measure CI time reduction.
Mid-Year Sale β Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register β Start Free Trial7-day free trial Β· Cancel anytime Β· 30-day money-back
