Retries and Circuit Breakers Belong in the Adapter, Not Your Use Case

Published: (June 13, 2026 at 05:44 PM EDT)
5 min read
Source: Dev.to

Source: Dev.to

Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go

My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools Me: xgabriel.com | GitHub

You open a use case that places an order. It loads a customer, builds the order, charges a payment gateway, saves, publishes an event. Clean. Then a sprint ago someone added a retry loop around the charge call, because the gateway flaps under load. Now the use case has a for ($i = 0; $i http->request(‘POST’, $this->endpoint, [ ‘headers’ => [‘Idempotency-Key’ => $idempotencyKey], ‘json’ => [ ‘customer_id’ => $customerId->value, ‘amount_minor’ => $amount->amountInMinorUnits, ‘currency’ => $amount->currency, ], ‘timeout’ => 5, ]); } catch (RequestException $e) { if ($e->getResponse()?->getStatusCode() === 402) { throw new PaymentDeclined($customerId, $amount); } throw new PaymentGatewayUnavailable(previous: $e); }

    $body = json_decode(
        (string) $response->getBody(),
        associative: true,
        flags: JSON_THROW_ON_ERROR,
    );
    return new PaymentReceipt($body['receipt_id'], $body['status']);
}

}

PaymentDeclined is a business outcome — the card was refused. PaymentGatewayUnavailable is transient — the network or the remote service failed. The retry logic cares only about the second kind. A declined card should never be retried; retrying it just means asking the same question again. Now the retrying decorator: inner->charge( $customerId, $amount, $idempotencyKey, ); } catch (PaymentGatewayUnavailable $e) { if ($attempt >= $this->maxAttempts) { throw $e; } $this->sleeper->sleepMs( $this->baseDelayMs * (2 ** ($attempt - 1)), ); } } } }

It catches only PaymentGatewayUnavailable. A PaymentDeclined flies straight through, untouched, because it is not transient. The backoff doubles each attempt. The same idempotencyKey goes out on every try, so a charge that actually landed before a timeout is not double-billed — the remote side dedupes on the key. Sleeper is a tiny port (sleepMs(int): void) so a unit test can pass a fake that records delays instead of blocking the suite. Real sleeping is one line of usleep in the production implementation. Retries help when failure is brief. They hurt when the gateway is down hard: every request now waits through three timeouts before failing, and your worker pool fills with stalled calls. A circuit breaker fixes that. After enough failures it stops calling the gateway at all and fails fast, giving the remote service room to recover. state->isOpen()) { throw new PaymentGatewayUnavailable( message: ‘circuit open’, ); }

    try {
        $receipt = $this->inner->charge(
            $customerId,
            $amount,
            $idempotencyKey,
        );
        $this->state->recordSuccess();
        return $receipt;
    } catch (PaymentGatewayUnavailable $e) {
        $this->state->recordFailure();
        throw $e;
    }
}

}

CircuitState is a port too. The production version keeps the failure count and the open-until timestamp in Redis so all your workers share one view of the gateway’s health. A fake keeps it in an array for tests. The decorator does not know or care which one it holds. Note the breaker also raises PaymentGatewayUnavailable when it short-circuits. The use case sees the same domain exception whether the gateway timed out, retried out, or was fenced off by an open circuit. One failure type, handled in one place upstream. The stacking happens once, where you build the container. The use case asks for a PaymentGateway and receives the whole onion without knowing its layers. $c->set(PaymentGateway::class, fn(C $c) => new CircuitBreakerPaymentGateway( new RetryingPaymentGateway( new HttpPaymentGateway( $c->get(ClientInterface::class), $_ENV[‘PAYMENT_GATEWAY_URL’], ), $c->get(Sleeper::class), ), $c->get(CircuitState::class), ), );

Read it inside out: HTTP at the core, retries around it, the breaker on the outside so it fences off the whole retrying stack when the gateway is unhealthy. Want retries without the breaker in a low-traffic service? Drop one constructor call. Want a different backoff for a different gateway? Pass different numbers. The use case file does not change. It never knew any of this existed. The use case stays readable. Open PlaceOrder and you read the business sequence with nothing in the way. No loop, no sleep, no failure counting. The resilience is testable in isolation. RetryingPaymentGateway has its own unit test that asserts it retries three times on PaymentGatewayUnavailable, gives up on the fourth, and never retries a PaymentDeclined. It uses a fake inner gateway and a fake sleeper. No network, no real timer. public function test_retries_then_succeeds(): void { $inner = new FlakyGateway(failTimes: 2); $sleeper = new RecordingSleeper();

$gateway = new RetryingPaymentGateway($inner, $sleeper);
$receipt = $gateway->charge(
    new CustomerId('c-1'),
    new Money(3000, 'EUR'),
    'key-1',
);

self::assertSame('ok', $receipt->status);
self::assertSame(3, $inner->attempts());
self::assertSame([100, 200], $sleeper->delays());

}

And the policy lives in one place. When ops asks for five attempts instead of three, or a longer breaker cooldown, you change a constant at the composition root or in one decorator. You do not grep the application layer for hand-rolled loops, because there are none. The use case asked a port to charge a customer. Everything about how hard to try is an infrastructure detail, and infrastructure details live in adapters. The next time a retry loop shows up in a use case during review, ask what it is protecting against. If the answer is “the network,” it is in the wrong file. Move it out, wrap the adapter, and let the use case go back to describing the business. Decorating adapters with retries, breakers, timeouts, and caching, without leaking any of it into the domain, is one of the threads Decoupled PHP follows from the first port to a production-shaped service. The book builds the same vocabulary used here and pushes into the failure modes that show up once the happy path works: partial writes, poison messages, and the migration path for a framework-coupled codebase that needs this discipline most.

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

0 views
Back to Blog

Related posts

Read more »

The spec is in the wrong place

My day job is at a large tech company. Hundreds of engineering teams, and every one of them is somewhere different on AI adoption. Some are still treating codin...

The Heuristics Say Don't

A culture that only records its disasters ends up with a biased archive. Wars documented, plagues chronicled, collapses catalogued. The quiet decades go unwritt...