# PHP Errors, Exceptions, and Result Types: Designing Failure Boundaries

> A deep dive into PHP failure boundaries, from validation and result types to exception translation, safe retries, background workers, observability, and testing partner-payment failures.

- URL: https://wendelladriel.com/blog/php-errors-exceptions-and-result-types-designing-failure-boundaries
- Author: Wendell Adriel
- Published: Aug 27, 2026
- Reading time: 20 minutes
- Tags: backend, architecture, php

## Introduction

Most payment code looks simple on the happy path. We validate an amount, call a provider, save a
payment attempt, and return a response.

The difficult part is deciding what happens when that flow does not complete. A missing payment
method, a declined card, an unavailable provider, an invalid provider response, and a `TypeError`
inside our own code are all failures. They are not, however, the **same kind of failure**.

If we treat them all as exceptions, our endpoint code eventually catches broad types and turns every
problem into a vague error. If we turn them all into `false`, `null`, or an array with a `status` key,
the important meaning disappears before the caller can make the right decision.

The useful question is not "should we use exceptions or result types?" The useful question is:

> Which part of the application owns this failure, and what can it meaningfully do next?

In this article, we will build a partner-payment workflow using only plain PHP. We will separate
invalid input, expected business rejections, transient provider failures, and programming defects.
Then we will translate each failure only at the boundary that owns its presentation, test the
contracts, and make the operational path visible without putting payment data in our logs.

## One Payment, Four Different Failures

Let's start with code that looks defensive but creates a dangerous failure boundary:

```php
function capturePayment(PaymentGateway $gateway, PaymentAttempt $attempt): array
{
    try {
        $outcome = $gateway->capture($attempt);

        return [
            'status' => 201,
            'body' => $outcome,
        ];
    } catch (Throwable $exception) {
        error_log($exception->getMessage());

        return [
            'status' => 422,
            'body' => ['message' => 'Payment failed.'],
        ];
    }
}
```

This function has collapsed several distinct situations into one `422` response:

1. The input is missing a payment method ID.
2. The customer has insufficient funds.
3. The partner API timed out.
4. Our code passed a string where an integer amount was required.

Those situations need different behavior. The first should be rejected before the payment use case
runs. The second is an expected business outcome that a caller can explain and act on. The third may
be retryable and should become a `503` response or background recovery. The fourth is a defect that
needs an alert and a safe generic `500` response, not a message telling the customer to try another
card.

Our workflow has four failure boundaries:

```text
untrusted input               Payment application                 Partner provider
───────────────               ───────────────────                 ────────────────

invalid fields ───────▶ validation result ───────▶ do not enter payment flow
                                      │
                                      ▼
                         business decision ───────▶ captured or declined
                                      │
                                      ▼
                         transport failure ───────▶ retry or recover later
                                      │
                                      ▼
                         programming defect ──────▶ report and stop safely
```

The diagram is deliberately simple: failures should become more specific as they move toward the
center of our application. At the outer boundary, input is just untrusted data. Inside the payment
workflow, a decline is a meaningful decision. At the provider boundary, a timeout is a transport
problem. A type error is neither a payment decision nor a transport problem; it is a signal that our
code has violated a contract.

## The Failure Boundary Is the Design

Before naming exceptions, write down what each kind of failure means. For our partner payment, the
following distinctions are useful:

**Missing or malformed input** is expected and belongs to the API client or UI. Return a validation
result with field errors, such as a `422 Unprocessable Entity` response. Do not enter the payment
flow at all.

**A declined card or payment method** is also expected, but it is a business outcome. Return a result
type so the customer or calling workflow can choose the next step. A web endpoint might return `422`;
a CLI command might print the decline reason and return a known exit code.

**A product that cannot be paid for** is another business rule. Use a result type when the caller can
continue with an alternative, or a domain exception when the current operation must stop. Neither
case should be retried.

**A timeout, rate limit, or unavailable provider** may be transient. Represent it with a retryable
exception so a background worker or recovery workflow can retry deliberately, eventually present a
safe `503`, and alert when attempts are exhausted.

**Invalid provider credentials or a changed provider contract** are problems for the engineering
team, not the customer. They need a non-retryable exception, a generic `500` presentation, and an
operator alert.

**A `TypeError`, missing method, or broken invariant** is a programming defect. Let the `Error`
propagate to the process boundary, where it can be reported and rendered safely. Do not turn it into
a payment decline.

These are not universal status codes. A web endpoint, a CLI command, and a recurring worker do not
all present the same result in the same way. The important decision is ownership.

For example, a card decline is often expected at the payment boundary. Calling a network API to ask
whether a card can be charged is still part of the business flow. A timeout is different: we cannot
claim that the payment failed because a timeout only says that **we did not receive a response**.

That distinction matters a lot when money is involved. A timeout is not evidence that the provider
did nothing. The provider might have captured the payment and lost the response on its way back to
us. Retrying blindly with a new identifier can charge the customer twice.

## PHP Has More Than `Exception`

PHP represents every throwable value through the `Throwable` interface. The two main branches are
`Exception` and `Error`:

```text
Throwable
├── Exception
│   ├── RuntimeException
│   ├── InvalidArgumentException
│   └── application and library exceptions
└── Error
    ├── TypeError
    ├── ValueError
    ├── AssertionError
    └── engine and programming errors
```

`Exception` is the branch we normally use to describe an interrupted operation. An unavailable
provider, unreadable import file, or violated domain rule may be represented with an exception when

`Error` represents a lower-level failure, often caused by invalid code or a broken runtime contract.
Passing the wrong type to a strictly typed method can cause a `TypeError`. Calling an undefined
method causes an `Error`. These failures implement `Throwable`, but they should not be converted
into ordinary business behavior.

This is why these two catches have very different meanings:

```php
try {
    $gateway->capture($attempt);
} catch (Exception $exception) {
    // Handles Exception and its subclasses, but not Error.
}
```

```php
try {
    $gateway->capture($attempt);
} catch (Throwable $throwable) {
    // Handles Exception and Error.
}
```

`catch (Throwable)` is occasionally useful at a true process boundary. A command runner can use it
throwable to one central error renderer. PHP also provides `set_exception_handler()` for an uncaught
throwable that reaches the top of a process.

It is almost never the right choice inside an endpoint, application service, domain object, or
provider adapter. Those layers should catch only the exception types they understand. Catching
`Throwable` around a payment attempt and returning `false` makes a `TypeError` look exactly like a
should.

Do not use an exception's message as an error code either. Messages are written for people, may be
translated, and change during normal maintenance. The class, a stable domain reason, or a dedicated
property should carry the branching information.

## Result Types Are for Normal Alternatives

PHP does not have native algebraic result types such as Rust's `Result` or Swift's `Result`. We can
still model a small, explicit set of normal outcomes with an interface and focused value objects.

For a capture operation, success and a provider-declared decline are both normal alternatives. The
caller needs to decide what to do next in both cases, so returning a value is clearer than throwing
an exception for the decline:

```php
interface PaymentOutcome
{
}

final readonly class PaymentCaptured implements PaymentOutcome
{
    public function __construct(
        public string $providerPaymentId,
    ) {}
}

final readonly class PaymentDeclined implements PaymentOutcome
{
    public function __construct(
        public string $reason,
    ) {}
}

final readonly class PaymentDetails
{
    public function __construct(
        public int $amountInCents,
        public string $currency,
        public string $paymentMethodId,
    ) {}
}

final readonly class PaymentAttempt
{
    public function __construct(
        public string $id,
        public PaymentDetails $payment,
    ) {}
}

interface PaymentGateway
{
    public function capture(PaymentAttempt $attempt): PaymentOutcome;
}
```

The type signature tells every caller that a successful method call has two possible business
outcomes. We cannot accidentally ignore a decline because there is no `null` value to forget to
check and no magic `false` that could be confused with an implementation failure.

The application service can make the two paths explicit:

```php
interface PaymentAttempts
{
    public function start(PaymentDetails $payment): PaymentAttempt;

    public function markCaptured(string $attemptId, string $providerPaymentId): void;

    public function markDeclined(string $attemptId, string $reason): void;

    public function markFailed(string $attemptId, string $reason): void;
}

final readonly class CapturePayment
{
    public function __construct(
        private PaymentGateway $gateway,
        private PaymentAttempts $attempts,
    ) {}

    public function handle(PaymentDetails $payment): PaymentOutcome
    {
        $attempt = $this->attempts->start($payment);
        $outcome = $this->gateway->capture($attempt);

        if ($outcome instanceof PaymentCaptured) {
            $this->attempts->markCaptured($attempt->id, $outcome->providerPaymentId);

            return $outcome;
        }

        $this->attempts->markDeclined($attempt->id, $outcome->reason);

        return $outcome;
    }
}
```

This code assumes `PaymentOutcome` has only these two implementations. PHP cannot enforce that an
interface is closed, so do not let the set grow casually. When a third outcome becomes useful, add
it deliberately, update every presenter, and decide whether it is really a normal alternative or an
interruption that should be an exception.

Result types have trade-offs too:

* They make expected alternatives visible in method signatures and tests.
* They keep ordinary control flow out of `try` and `catch` blocks.
* They can become noisy when every small helper returns another wrapper.
* They do not replace exceptions for broken I/O, missing configuration, or conditions the caller
  cannot reasonably handle in the same flow.

Use a result when the caller has a meaningful next action. Use an exception when normal work cannot
continue and control must leave the current path.

## Translate Provider Failures at the Boundary

A provider integration speaks its own language: connection failures, response codes, and
provider-specific payloads. The rest of our application should not need to know those details.

The provider adapter is the right place to translate that language into payment language. First,
let's define the exceptions that cross this boundary:

```php
final class PaymentProviderUnavailable extends RuntimeException
{
    public function __construct(
        public readonly string $provider,
        Throwable $previous,
    ) {
        parent::__construct(
            message: "Payment provider [{$provider}] is unavailable.",
            previous: $previous,
        );
    }

    /** @return array<string, string> */
    public function logContext(): array
    {
        return ['provider' => $this->provider];
    }
}

final class PaymentProviderRequestFailed extends RuntimeException
{
    public function __construct(
        public readonly string $provider,
        Throwable $previous,
    ) {
        parent::__construct(
            message: "Payment provider [{$provider}] rejected the integration request.",
            previous: $previous,
        );
    }
}
```

Both exceptions preserve the original throwable as `previous`. This is **exception translation**,
not exception erasure. Our logs and error tracker can still show the transport failure, while
application code can catch `PaymentProviderUnavailable` without importing a provider library.

The added context is intentionally small. A provider name and attempt ID help an operator diagnose
an integration. Raw request headers, authorization tokens, full request bodies, card data, and a
provider response copied into an exception message do not belong in application logs.

Keep the concrete transport implementation behind a small provider-facing contract so the payment
boundary stays plain PHP:

```php
enum PartnerCaptureStatus
{
    case Captured;
    case Declined;
    case RetryableFailure;
    case RequestFailure;
}

final readonly class PartnerCaptureResponse
{
    public function __construct(
        public PartnerCaptureStatus $status,
        public ?string $providerPaymentId = null,
        public ?string $declineReason = null,
    ) {}
}

final class PartnerConnectionFailed extends RuntimeException
{
}

interface PartnerPaymentApi
{
    public function capture(PaymentAttempt $attempt): PartnerCaptureResponse;
}
```

This interface is intentionally provider-specific. The code that performs an HTTP request can map
the provider's response into `PartnerCaptureResponse`. The rest of our application only sees the
stable payment contract:

```php
final readonly class PartnerPaymentGateway implements PaymentGateway
{
    public function __construct(
        private PartnerPaymentApi $api,
    ) {}

    public function capture(PaymentAttempt $attempt): PaymentOutcome
    {
        try {
            $response = $this->api->capture($attempt);
        } catch (PartnerConnectionFailed $exception) {
            throw new PaymentProviderUnavailable('partner-pay', $exception);
        }

        return match ($response->status) {
            PartnerCaptureStatus::Captured => new PaymentCaptured(
                $response->providerPaymentId
                    ?? throw new PaymentProviderRequestFailed(
                        'partner-pay',
                        new RuntimeException('Partner Pay omitted the payment ID.'),
                    ),
            ),
            PartnerCaptureStatus::Declined => new PaymentDeclined(
                $response->declineReason ?? 'payment_declined',
            ),
            PartnerCaptureStatus::RetryableFailure => throw new PaymentProviderUnavailable(
                'partner-pay',
                new RuntimeException('Partner Pay reported a temporary failure.'),
            ),
            PartnerCaptureStatus::RequestFailure => throw new PaymentProviderRequestFailed(
                'partner-pay',
                new RuntimeException('Partner Pay rejected the integration request.'),
            ),
        };
    }
}
```

The exact mapping belongs to the provider adapter. Another provider may use a status code, a field
inside a successful response, or a provider-specific exception to represent a decline. Keep that
vocabulary at the edge. Return our stable `PaymentDeclined` value to the rest of the application.

One detail is easy to overlook: every call should use the attempt's stable ID as the provider's
idempotency key. The transport code owns how it sends that value, but the application owns the value.
We only retry a payment when the provider documents that repeated calls with the same key return or
converge on the same charge.

## Retries Need an Idempotency Contract

Retries do not make a payment safe. An **idempotency contract** makes a retry safe.

Before calling the provider, create a durable payment-attempt record with an identifier such as
`payment_attempt_01J...`. Use that identifier as the provider idempotency key. Persist the final
provider payment ID when it is known.

Then define what happens for each uncertain state:

```text
payment attempt persisted
        │
        ▼
provider request sent with stable idempotency key
        │
        ├── decline received ───────────────▶ mark attempt declined
        ├── capture received ───────────────▶ mark attempt captured
        └── timeout or connection loss ─────▶ retry or query provider by the same key
```

If the timeout happens after the provider captures the payment, the next call with the same key must
not create a second capture. Some providers offer a lookup endpoint for the idempotency key or their
payment ID. Use it when the provider's documentation requires reconciliation instead of a repeated
capture request.

A background worker can retry only the exception that represents a temporary provider failure. This
small example uses `sleep()` to make the policy visible. In production, a process supervisor or job
system should schedule the next attempt rather than hold a worker idle:

```php
final readonly class RetryPaymentAttempt
{
    private const array BACKOFF_SECONDS = [0, 30, 120, 600];

    public function __construct(
        private PaymentGateway $gateway,
        private PaymentAttempts $attempts,
    ) {}

    public function handle(PaymentAttempt $attempt): PaymentOutcome
    {
        $lastException = null;

        foreach (self::BACKOFF_SECONDS as $delay) {
            if ($delay > 0) {
                sleep($delay);
            }

            try {
                return $this->capture($attempt);
            } catch (PaymentProviderRequestFailed $exception) {
                $this->attempts->markFailed($attempt->id, 'provider_request_failed');

                throw $exception;
            } catch (PaymentProviderUnavailable $exception) {
                $lastException = $exception;
            }
        }

        $this->attempts->markFailed($attempt->id, 'provider_unavailable');

        throw $lastException ?? new LogicException('A payment retry must fail with an exception.');
    }

    private function capture(PaymentAttempt $attempt): PaymentOutcome
    {
        $outcome = $this->gateway->capture($attempt);

        if ($outcome instanceof PaymentCaptured) {
            $this->attempts->markCaptured($attempt->id, $outcome->providerPaymentId);

            return $outcome;
        }

        $this->attempts->markDeclined($attempt->id, $outcome->reason);

        return $outcome;
    }
}
```

`PaymentProviderUnavailable` is retried with bounded backoff. `PaymentProviderRequestFailed` fails
immediately because bad credentials or a changed provider contract will not improve after several
quick retries. A normal `PaymentDeclined` is returned as a value because the worker completed its
business responsibility.

The example is deliberately small. A real worker must record attempts atomically, prevent two
workers from processing the same payment attempt concurrently, and use a durable scheduler. The
failure model still stays the same: retry only a well-defined transient condition, with the same
idempotency key, and make exhaustion visible.

## Present Failures at the Outer Boundary

Input validation belongs before the payment application service. A plain PHP validator can turn
untrusted input into a valid value or an explicit list of errors:

```php
interface PaymentInputResult
{
}

final readonly class ValidPaymentInput implements PaymentInputResult
{
    public function __construct(
        public PaymentDetails $payment,
    ) {}
}

final readonly class InvalidPaymentInput implements PaymentInputResult
{
    /** @param array<string, string> $errors */
    public function __construct(
        public array $errors,
    ) {}
}

final class PaymentInputValidator
{
    /** @param array<string, mixed> $input */
    public function validate(array $input): PaymentInputResult
    {
        $errors = [];
        $amount = filter_var($input['amount_in_cents'] ?? null, FILTER_VALIDATE_INT);
        $currency = $input['currency'] ?? null;
        $paymentMethodId = $input['payment_method_id'] ?? null;

        if (! is_int($amount) || $amount < 1) {
            $errors['amount_in_cents'] = 'The amount must be a positive integer.';
        }

        if (! is_string($currency) || preg_match('/^[A-Z]{3}$/', $currency) !== 1) {
            $errors['currency'] = 'The currency must be a three-letter uppercase code.';
        }

        if (! is_string($paymentMethodId) || $paymentMethodId === '') {
            $errors['payment_method_id'] = 'A payment method is required.';
        }

        if ($errors !== []) {
            return new InvalidPaymentInput($errors);
        }

        return new ValidPaymentInput(new PaymentDetails(
            amountInCents: $amount,
            currency: $currency,
            paymentMethodId: $paymentMethodId,
        ));
    }
}
```

The boundary can now turn the validation result and payment outcome into an HTTP-shaped array without
letting transport details leak into the payment logic:

```php
function capturePaymentEndpoint(
    array $input,
    PaymentInputValidator $validator,
    CapturePayment $capturePayment,
): array {
    $validation = $validator->validate($input);

    if ($validation instanceof InvalidPaymentInput) {
        return [
            'status' => 422,
            'body' => ['errors' => $validation->errors],
        ];
    }

    try {
        $outcome = $capturePayment->handle($validation->payment);
    } catch (PaymentProviderUnavailable $exception) {
        reportPaymentFailure($exception, 'unknown');

        return [
            'status' => 503,
            'body' => ['message' => 'Payments are temporarily unavailable.'],
        ];
    }

    if ($outcome instanceof PaymentCaptured) {
        return [
            'status' => 201,
            'body' => [
                'status' => 'captured',
                'payment_id' => $outcome->providerPaymentId,
            ],
        ];
    }

    return [
        'status' => 422,
        'body' => [
            'status' => 'declined',
            'reason' => $outcome->reason,
        ],
    ];
}
```

The same `CapturePayment` service can be called from a CLI command without pretending it is HTTP. A
command can print `Payment declined: insufficient_funds` and return a known exit code. It can let a
`PaymentProviderUnavailable` exception reach the command boundary, where the command logs the
attempt ID and exits with failure so an operator or scheduler can decide what happens next.

Notice what we did not add: a `try/catch` block around every method. The endpoint owns the
presentation of its normal `PaymentOutcome`. A process boundary owns the safe presentation of an
unhandled provider exception. This keeps the two paths explicit without repeating response code.

## Wrap Only When the Boundary Changes Meaning

Wrapping every exception is not useful. This adds no information and hides the original type from a
caller that already understands it:

```php
try {
    return $gateway->capture($attempt);
} catch (PaymentProviderUnavailable $exception) {
    throw new PaymentProviderUnavailable('partner-pay', $exception);
}
```

The gateway already expressed the payment-level meaning. The application service should let it pass.

Wrapping is useful when an exception crosses into a different vocabulary. `PartnerConnectionFailed`
is a transport concern; `PaymentProviderUnavailable` is a payment concern. A storage-layer exception
is a persistence concern; a `PaymentAttemptCouldNotBeRecorded` exception could be useful if a payment
boundary needs to communicate a durable-recording failure to its caller.

When translating, preserve the previous exception and add only the context that the new layer owns:

```php
throw new PaymentProviderUnavailable(
    provider: 'partner-pay',
    previous: $exception,
);
```

Avoid these patterns:

* catching `Throwable` inside ordinary application code
* turning every exception into `false`, `null`, or an empty collection
* creating a new exception class for every provider status without a business meaning
* branching on an exception message
* catching an exception only to log it and throw it again, which can duplicate reports
* putting request bodies, tokens, card data, or provider error payloads in an exception message
* retrying a payment because a failure is "probably temporary" without an idempotency contract

A good exception hierarchy is small. It describes the failure modes a boundary needs to distinguish,
not every line of code that can throw.

## Test the Failure Contract

Failure handling is behavior. Test the contract at the boundaries instead of asserting that an
internal `catch` block ran.

For the provider adapter, use a small in-memory fake. It returns a provider response and records the
stable attempt ID it received. Pest keeps the expected behavior easy to scan:

```php
final class FakePartnerPaymentApi implements PartnerPaymentApi
{
    /** @var list<string> */
    public array $receivedAttemptIds = [];

    public function __construct(
        private PartnerCaptureResponse $response,
    ) {}

    public function capture(PaymentAttempt $attempt): PartnerCaptureResponse
    {
        $this->receivedAttemptIds[] = $attempt->id;

        return $this->response;
    }
}

it('returns a decline for an expected provider rejection', function (): void {
    $api = new FakePartnerPaymentApi(new PartnerCaptureResponse(
        status: PartnerCaptureStatus::Declined,
        declineReason: 'insufficient_funds',
    ));
    $gateway = new PartnerPaymentGateway($api);

    $outcome = $gateway->capture(new PaymentAttempt(
        id: 'payment-attempt-123',
        payment: new PaymentDetails(2_900, 'USD', 'payment-method-123'),
    ));

    expect($outcome)
        ->toBeInstanceOf(PaymentDeclined::class)
        ->and($outcome->reason)->toBe('insufficient_funds')
        ->and($api->receivedAttemptIds)->toBe(['payment-attempt-123']);
});
```

The important assertion is that a provider-declared decline becomes a `PaymentDeclined` value. It is
not a retryable exception and it does not become a generic server error.

Test the transport boundary separately with a fake that throws `PartnerConnectionFailed`. The
assertion should prove that the gateway translates it to `PaymentProviderUnavailable` and keeps the
original throwable as `getPrevious()`.

Then add tests that matter for the outside world:

1. Invalid input produces field errors and does not call the gateway.
2. A decline returns the documented result without reporting a provider outage.
3. A provider timeout returns a safe `503` response and records an attempt ID and provider name, but
   no secrets.
4. A background retry uses the same payment attempt ID on every provider call.
5. A non-retryable integration failure stops immediately without repeated calls.
6. A timeout after an ambiguous provider call is reconciled by the same idempotency key before a new
   capture is attempted.

## Observability Without Secrets

An exception gives us a type, message, previous throwable, stack trace, and any context we attach.
That is valuable only when the context helps an operator act without exposing customer data.

At a process boundary, record a small structured event:

```php
function reportPaymentFailure(PaymentProviderUnavailable $exception, string $attemptId): void
{
    error_log(json_encode([
        'event' => 'payment.capture.failed',
        'attempt_id' => $attemptId,
        'exception' => $exception::class,
        ...$exception->logContext(),
    ], JSON_THROW_ON_ERROR));
}
```

The record includes an event name, the payment attempt ID, the exception class, and the provider. It
full previous throwable available to your error tracker when it is safe to do so, but do not build an
observability strategy around copying sensitive strings into every log line.

Monitor the number of `payment.capture.failed` events, retry count, age of pending attempts, and the
oldest unreconciled attempt. A provider outage should become an observable incident, not a slowly
growing collection of stuck rows.

## A Practical Operational Checklist

Before shipping a payment integration, check these questions:

1. Can every payment attempt be identified with a durable, stable ID?
2. Does every provider capture use that ID as an idempotency key where the provider supports it?
3. Does the timeout path reconcile an existing provider payment instead of creating a new charge?
4. Are validation failures rejected before provider calls?
5. Are normal declines returned as explicit values rather than broad exceptions?
6. Are only transient provider failures retried, with bounded backoff and a maximum attempt count?
7. Are invalid credentials and provider contract failures prevented from retrying forever?
8. Does the worker prevent concurrent processing of the same attempt?
9. Do exception reports include attempt ID, provider, and safe correlation data without request bodies,
   headers, tokens, or payment details?
10. Can an operator see failed attempts, retry counts, provider error rate, and the oldest
    unreconciled payment attempt?
11. Is there a documented repair command or runbook for reconciling an ambiguous attempt?
12. Do tests cover the failure behavior the customer, operator, and background worker each see?

If the answer to any of these is no, adding another `catch` block will not make the system safer. The
missing piece is usually a boundary, a state transition, or an operational decision.

## Conclusion

PHP errors, exceptions, and result types are not competing tools. They describe different kinds of
information.

Use validation results for malformed input at the edge. Use small result types for expected business
alternatives that the caller can continue from. Use translated exceptions for interrupted operations
such as unavailable providers. Let programming errors remain visible instead of disguising them as
normal payment failures.

The partner payment adapter owns provider details and translates them into payment language. The
application service owns the payment attempt state. The endpoint or command owns presentation. A
background worker owns delayed retry and recovery. The process boundary owns safe rendering and
reporting for unhandled exceptions.

Start with one workflow that currently catches a broad exception or returns a vague boolean. Name its
failure modes, decide which ones are normal alternatives, preserve the context at the integration
boundary, and write tests for what each caller can do next. That small design step turns failure from
an afterthought into a contract the rest of the application can trust.

I hope that you liked this article and if you do, don't forget to share this article with your
friends!!! See ya!

