Working with External Services

Implementing the Payment Provider Client

LugemineEelvaade

Vaatad tasuta eelvaate õppetundi.

In the previous lesson, we defined:

PayOrderUseCase
        ↓
PaymentService
        ↓
External Payment Provider

PaymentService represents the capability our application needs.

Now we need the infrastructure component that actually communicates with the external system.

This work corresponds to:

BACKEND-117
Implement Payment Provider Client

The confirmed scope is:

call provider over HTTP

externalize provider configuration

translate supported success responses

translate supported payment failures

handle timeout explicitly

keep provider-specific models
inside integration code

There is also an important constraint:

We do not yet have enough provider-specific information to finalize endpoint paths, authentication headers, provider DTOs, idempotency behavior, or payment-reference semantics.

So this lesson will build the correct integration architecture without fabricating a provider protocol.

The goal is:

Create an HTTP integration that implements PaymentService, owns provider-specific communication, and translates the provider protocol into application payment meaning without leaking transport details into PayOrderUseCase or the Order Domain.


Where the Provider Client Lives

Our application remains a modular monolith.

Payment is a logical integration capability:

io.tarkev.ordermanagement.payment

A reasonable structure is:

payment/
├── application/
│   ├── PaymentService.java
│   ├── PaymentRequest.java
│   └── PaymentResult.java
│
└── integration/
    └── provider/
        ├── ProviderPaymentService.java
        ├── ProviderPaymentProperties.java
        ├── ProviderPaymentConfiguration.java
        ├── ProviderPaymentRequest.java
        └── ProviderPaymentResponse.java

We do not need:

payment/domain/

yet because we have not established a separate Payment Domain model.

The integration capability currently exists to support:

PayOrderUseCase

Application Contract vs Provider Protocol

The application sees:

public interface PaymentService {

    PaymentResult pay(
            PaymentRequest request
    );
}

The provider implementation may internally need:

URL

authentication

HTTP headers

provider JSON request

provider JSON response

provider error codes

timeouts

Those concepts belong below the boundary.

The dependency direction is:

PayOrderUseCase
        ↓
PaymentService
        ↑
ProviderPaymentService

The application depends on the abstraction.

The provider adapter implements it.


ProviderPaymentService

The implementation conceptually looks like:

package io.tarkev.ordermanagement.payment.integration.provider;

import io.tarkev.ordermanagement.payment.application.PaymentRequest;
import io.tarkev.ordermanagement.payment.application.PaymentResult;
import io.tarkev.ordermanagement.payment.application.PaymentService;

public final class ProviderPaymentService
        implements PaymentService {

    private final ProviderPaymentClient
            client;

    public ProviderPaymentService(
            ProviderPaymentClient client
    ) {
        this.client = client;
    }

    @Override
    public PaymentResult pay(
            PaymentRequest request
    ) {
        ProviderPaymentRequest providerRequest =
                toProviderRequest(
                        request
                );

        ProviderPaymentResponse response =
                client.pay(
                        providerRequest
                );

        return toPaymentResult(
                response
        );
    }
}

This exposes the essential responsibility:

application request
    ↓
provider request
    ↓
HTTP
    ↓
provider response
    ↓
application result

Why Separate Service and Client?

We could combine:

ProviderPaymentService

and:

ProviderPaymentClient

into one class.

That may be reasonable if the integration is genuinely tiny.

But there are two distinct responsibilities available:

ProviderPaymentService
→ application ↔ provider translation
ProviderPaymentClient
→ HTTP transport

If separating them makes:

mapping

HTTP behavior

tests

clearer, it is justified.

If the final provider integration remains trivial, collapsing them later is also acceptable.

The architecture does not require abstraction for ceremony.


Do Not Build a Generic HTTP Gateway

Avoid:

public interface ExternalGateway<
        Request,
        Response
> {
    Response execute(
            Request request
    );
}

Payment has concrete semantics.

A provider-specific client is clearer:

ProviderPaymentClient

Generic abstractions should solve repeated real problems, not anticipated future ones.


Provider Request Model

Our application PaymentRequest might contain:

OrderId

totalCents

The provider may require a different representation.

So we create a provider-specific DTO only inside the provider integration.

Conceptually:

package io.tarkev.ordermanagement.payment.integration.provider;

record ProviderPaymentRequest(
        String merchantReference,
        long amount
) {
}

But the exact field names above are illustrative only.

Until the real provider contract is known, we must not claim that the provider requires:

merchantReference

amount

under those exact names.

The important principle is:

PaymentRequest
≠
ProviderPaymentRequest

They have different owners.


Provider Response Model

Likewise:

record ProviderPaymentResponse(
        String status,
        String reference
) {
}

is only a structural example.

The real provider may return completely different JSON.

The integration layer owns whatever representation the actual provider uses.

What must not happen is:

provider response DTO
        ↓
PayOrderUseCase

The UseCase should receive:

PaymentResult

instead.


Why Provider DTOs Stay Internal

Suppose today the provider returns:

{
  "status": "approved"
}

and next year we switch providers.

The new provider returns:

{
  "resultCode": "SUCCESS"
}

If PayOrderUseCase depends on:

status = approved

then business code changes with provider protocol.

With translation:

Provider A
approved
    ↓
SUCCEEDED

and:

Provider B
SUCCESS
    ↓
SUCCEEDED

PayOrderUseCase remains unchanged.

That is the purpose of the integration boundary.


HTTP Client Responsibility

ProviderPaymentClient owns transport concerns such as:

HTTP method

provider URL

headers

serialization

deserialization

connection behavior

response status

transport errors

timeout

It does not own:

Order ownership

Order lifecycle

whether a Customer may pay

whether an Order is already PAID

Those belong to application and Domain logic.


Provider Endpoint Is Configuration

Do not write:

private static final String URL =
        "https://payments.example.com/pay";

The real deployment may use:

local stub

provider sandbox

staging endpoint

production endpoint

The same application artifact should work with different runtime configuration.

Conceptually:

payment.provider.base-url

belongs to configuration.


Provider Credentials Are Configuration

Likewise, never write:

private static final String API_KEY =
        "production-secret";

Provider credentials must come from the deployment environment or appropriate secrets mechanism.

They should never be committed to:

source code

application.yml with real production values

test fixtures

logs

Typed Payment Configuration

The course already established typed configuration for related properties.

Conceptually:

package io.tarkev.ordermanagement.payment.integration.provider;

import org.springframework.boot.context.properties.ConfigurationProperties;

import java.net.URI;
import java.time.Duration;

@ConfigurationProperties(
        prefix = "payment.provider"
)
public record ProviderPaymentProperties(
        URI baseUrl,
        Duration timeout
) {
}

We intentionally omit an exact credential property name here because the provider authentication contract is not yet defined.

Once known, it belongs to this integration configuration.


Why URI and Duration?

Prefer meaningful types.

For example:

URI

communicates:

this must be a location

and:

Duration

communicates:

this is time

Better than:

String baseUrl;
long timeout;

where:

timeout

could mean:

milliseconds?

seconds?

minutes?

Typed configuration reduces ambiguity.


Configuration Should Fail Fast

If the provider integration requires:

base URL

and it is absent, we should not wait for:

first Customer payment request

to discover the problem.

Required configuration should fail application startup clearly.

Likewise:

timeout <= 0

should be considered invalid configuration.

Failing early makes deployment problems easier to diagnose.


Safe Configuration Defaults

Some values may have an agreed safe default.

For example, a team might decide:

payment.provider.timeout

has a documented default.

But we should not invent that default in this lesson.

The real timeout policy belongs to the provider integration decision.

A dangerous default would be:

missing provider URL
→ silently use production

Never hide required setup this way.


Wiring the Integration

Spring configuration can construct the provider client and service.

Conceptually:

@Configuration
@EnableConfigurationProperties(
        ProviderPaymentProperties.class
)
public class ProviderPaymentConfiguration {

    @Bean
    ProviderPaymentClient
    providerPaymentClient(
            ProviderPaymentProperties properties
    ) {
        return new ProviderPaymentClient(
                properties
        );
    }

    @Bean
    PaymentService paymentService(
            ProviderPaymentClient client
    ) {
        return new ProviderPaymentService(
                client
        );
    }
}

Now the dependency graph is:

runtime configuration
        ↓
ProviderPaymentProperties
        ↓
ProviderPaymentClient
        ↓
ProviderPaymentService
        ↓
PaymentService
        ↓
PayOrderUseCase

PayOrderUseCase does not know:

property names

environment variables

provider URL

timeout values

Choosing an HTTP Client

Spring provides several ways to make HTTP calls.

The exact client technology is an implementation choice.

What matters architecturally is:

HTTP client stays
inside provider integration

We should not let the choice of:

RestClient

WebClient

another supported client

change the application contract.

The course should focus first on:

request

response

timeout

mapping

failure meaning

rather than turning the integration into an HTTP-library tutorial.


Keep HTTP Types Inside the Integration

Avoid:

public PaymentResult pay(
        HttpRequest request
);

or:

ResponseEntity<ProviderResponse>

as the return value of PaymentService.

Those transport types would leak upward.

Correct:

PaymentResult pay(
        PaymentRequest request
);

The provider adapter handles HTTP internally.


Building the Request

Conceptually:

PaymentRequest
    ↓
map
    ↓
ProviderPaymentRequest
    ↓
serialize
    ↓
HTTP request

The mapper may need:

Order ID

amount

provider-required metadata

But every field must come from an actual provider requirement.

Do not enrich provider requests with speculative fields merely because payment APIs often have them.


Authentication Header

The source tells us the provider will require credentials to be externally configured.

It does not tell us whether the provider uses:

Bearer token

Basic authentication

API key header

signed request

OAuth client credentials

Therefore we cannot write the real authentication code yet.

The correct lesson is:

ProviderPaymentClient
owns authentication protocol
once provider contract defines it

not:

guess Authorization: Bearer ...

Provider Success Mapping

Suppose the actual provider defines some response as a confirmed successful payment.

The provider adapter translates:

provider success representation
        ↓
PaymentResult.SUCCEEDED

The rest of the application does not need to know how the provider expresses success.

The mapping should be explicit and testable.


Provider Rejection Mapping

Similarly:

provider confirmed payment rejection
        ↓
PaymentResult.FAILED

A confirmed rejection is not:

system failure

The provider responded successfully at the integration level.

The payment itself was not accepted.

That distinction matters.


HTTP 2xx Does Not Necessarily Mean Payment Success

A common integration mistake is:

HTTP 2xx
→ payment succeeded

We cannot assume that universally.

Some provider APIs may return a successful HTTP response containing:

payment rejected

or another non-successful business status.

Therefore:

HTTP transport success

and:

payment business success

are separate concepts.

The actual provider contract decides the mapping.


HTTP Error Does Not Automatically Mean Payment Failed

Likewise:

HTTP 5xx

may mean:

provider unavailable

It does not necessarily mean:

Customer payment was declined

That should be treated as an integration/system failure unless the provider contract says otherwise.


Confirmed Failure vs Technical Failure

Our boundary should preserve this distinction:

confirmed payment rejection
→ PaymentResult FAILED

versus:

transport / infrastructure problem
→ exception

Examples of technical failure:

DNS failure

connection refused

provider 5xx

malformed provider response

timeout

The exact exception taxonomy can stay small.


Integration Exception

A provider integration may define an exception such as:

package io.tarkev.ordermanagement.payment.integration.provider;

public class PaymentProviderException
        extends RuntimeException {

    public PaymentProviderException(
            String message
    ) {
        super(message);
    }

    public PaymentProviderException(
            String message,
            Throwable cause
    ) {
        super(
                message,
                cause
        );
    }
}

This represents:

payment integration
could not obtain a normal
application payment result

It does not represent:

payment rejected

Do Not Expose Provider Errors Directly

Suppose provider returns:

X42

meaning:

payment rejected

The provider integration can map:

X42
→ FAILED

PayOrderUseCase should not contain:

if (
        response.code()
                .equals("X42")
) {
    ...
}

That would leak provider vocabulary into application logic.


Unexpected Provider Response

Suppose we receive a provider status we do not understand.

Bad:

unknown provider status
→ assume FAILED

That can hide integration incompatibility.

Better:

unknown provider response
→ integration/system failure

because the application cannot safely determine payment outcome.

This deserves investigation rather than silent translation.


Malformed Response

If the provider returns:

invalid JSON

missing required fields

unexpected schema

the application should not manufacture:

FAILED

The integration could not understand the provider response.

That is a technical failure.


Timeout

BACKEND-117 explicitly requires timeout behavior.

A remote call must not wait indefinitely.

Conceptually:

request sent
    ↓
wait up to configured timeout
    ↓
response
or
timeout

But timeout has special payment semantics.


Why Payment Timeout Is Dangerous

Imagine:

backend sends payment request

Provider processes it successfully.

Before the response reaches us:

network connection breaks

Our client sees:

timeout

But provider state may be:

payment succeeded

Therefore:

timeout
≠ confirmed payment failure

Do Not Map Timeout to FAILED

Avoid:

catch (
        TimeoutException exception
) {
    return PaymentResult.failed();
}

That claims knowledge we do not have.

The payment result may actually be uncertain.

Timeout handling deserves its own integration failure path.

The deeper recovery/idempotency behavior belongs to the upcoming external-services lessons.


Do Not Automatically Retry Yet

Another tempting implementation:

catch (
        TimeoutException exception
) {
    return retry();
}

That may charge the Customer twice if:

first request succeeded
but response was lost

Whether retry is safe depends on:

provider idempotency semantics

payment reference semantics

query/reconciliation capability

Those are explicitly unresolved.

So BACKEND-117 should not hide a generic retry loop inside the client.


Timeout Must Be Explicit

Even though we do not yet implement retry/reconciliation policy, the provider client must still distinguish timeout from:

normal payment rejection

Conceptually:

timeout
→ PaymentProviderTimeoutException

or another explicit integration-level representation.

The exact class name matters less than preserving the semantics.


Example Timeout Exception

Conceptually:

public final class
PaymentProviderTimeoutException
        extends PaymentProviderException {

    public PaymentProviderTimeoutException(
            Throwable cause
    ) {
        super(
                "Payment provider request timed out",
                cause
        );
    }
}

Notice what is not included:

API key

full provider response

sensitive request body

Provider 5xx

Suppose the provider returns:

500

We should generally treat that as:

external system failure

not:

payment rejected

unless the actual provider contract explicitly defines otherwise.

Again, application semantics come from the provider specification, not generic assumptions about HTTP.


Provider 4xx

Even 4xx requires provider-specific interpretation.

Some 4xx responses may mean:

invalid integration request

which could indicate:

our bug

wrong credentials

invalid provider mapping

Others might represent:

payment business rejection

depending on the provider.

We cannot collapse:

all 4xx
→ FAILED

without the real contract.


Payment Result Must Be Provider-Independent

By the time control returns to:

PayOrderUseCase

the result should use application vocabulary.

For example:

SUCCEEDED

FAILED

not:

HTTP_200

HTTP_402

X42

AUTHORIZED

unless one of those concepts genuinely becomes part of the application contract.


Payment Reference

The architecture review identified payment-reference semantics as unresolved.

Many providers return some identifier that can support:

idempotency

support investigation

reconciliation

duplicate detection

But we do not yet know:

whether such a reference exists

when it is returned

whether it is stable

whether we need to persist it

Therefore we do not invent:

String providerPaymentId

as mandatory application state yet.

That decision must come from the real provider contract.


Why This Blocks Final Payment Design

If provider behavior is:

client supplies idempotency key

the workflow may differ significantly from a provider that:

does not support idempotent payment creation

Likewise, if the provider supports:

GET payment by merchant reference

timeout recovery can use reconciliation.

If not, recovery is different.

This is why the original design deliberately kept BACKEND-117 not-ready until these questions were answered.


What We Can Implement Before Those Answers

We can safely establish:

package boundaries

PaymentService implementation

configuration binding

HTTP client construction

request/response translation location

timeout configuration

exception boundaries

integration testing approach

We cannot safely finalize:

real endpoint path

real authentication mechanism

real JSON schema

success/rejection mapping

idempotency headers

retry behavior

payment-reference persistence

without provider documentation.

That is disciplined engineering, not incomplete architecture.


No Fake Provider Contract in Production Code

Avoid creating a placeholder production API such as:

POST /charge

with:

{
  "orderId": "...",
  "amount": 100
}

and then letting course architecture grow around it as though it were real.

A fake provider can be useful for:

tests

local simulation

but it must be clearly identified as a test double, not mistaken for the production integration contract.


Local Development

Local development should not accidentally call:

production payment provider

A safe setup may eventually point:

payment.provider.base-url

to:

sandbox

stub server

local fake

depending on the real provider environment.

The exact setup belongs to the provider implementation.

The important safety rule is:

Local and automated development must not unintentionally perform real production payments.


Integration Testing Without the Real Provider

We should not make normal automated tests depend on:

internet access

provider sandbox availability

real credentials

Instead, integration tests can run the provider adapter against a controlled HTTP stub.

Conceptually:

ProviderPaymentService
        ↓
HTTP
        ↓
Stub Provider Server

This lets tests verify real:

serialization

headers

response mapping

timeouts

without depending on an external environment.


Provider Client Integration Test — Success

Given a stub response representing the real provider's confirmed success:

ProviderPaymentService.pay(...)

should return:

PaymentResult
→ SUCCEEDED

The test should verify:

expected HTTP request sent

expected configured endpoint used

response translated correctly

The exact JSON must come from the actual provider contract.


Provider Client Integration Test — Rejection

Stub the real provider representation for:

confirmed payment rejection

Expected:

PaymentResult
→ FAILED

No application-level provider codes should leak upward.


Provider Client Integration Test — Server Failure

Stub:

provider 5xx

Expected:

PaymentProviderException

or the agreed technical integration failure.

Specifically not:

PaymentResult.FAILED

unless provider documentation says the response represents a confirmed payment failure.


Provider Client Integration Test — Timeout

Configure the stub to delay beyond:

payment.provider.timeout

Expected:

explicit timeout failure

and not:

SUCCEEDED

FAILED

The payment outcome is not safely known from timeout alone.


Test Configuration

Tests can use:

local stub base URL

short timeout

test credential

through configuration.

Do not modify production code paths merely to make testing possible.

The same configurable client should point to different environments.


Do Not Log Secrets

The provider client may log useful operational information later.

Never log:

API secret

Authorization header

raw card data

sensitive provider credentials

Even when debugging.

Production observability does not justify credential exposure.


Be Careful With Request/Response Logging

Likewise, avoid blindly logging:

entire provider request

entire provider response

Payment payloads may contain sensitive information once the real provider integration exists.

Logging strategy belongs to deliberate observability design.


Correlation and Provider References

Later production observability may connect:

our request correlation ID

OrderId

provider reference

for troubleshooting.

But we do not invent those fields before:

provider-reference semantics

are known.

The integration architecture simply leaves room for them.


Provider Client Is Not a Repository

Do not call it:

PaymentRepository

A Repository represents:

application-owned persistent state

The payment provider is:

an external capability

Use:

PaymentService

ProviderPaymentService

ProviderPaymentClient

or equivalent external-integration terminology.


Provider Client Is Not a UseCase

Likewise:

CallPaymentProviderUseCase

would be misleading.

Calling the provider is not an independent application business operation.

It is one external step inside:

PayOrderUseCase

The provider adapter supports that workflow.


Provider Client Must Not Load Orders

Bad:

class ProviderPaymentService {

    private final OrderRepository
            orderRepository;

    PaymentResult pay(
            OrderId orderId
    ) {
        Order order =
                orderRepository
                        .findById(
                                orderId
                        );

        // ...
    }
}

Now the external integration owns application workflow behavior.

Correct:

PayOrderUseCase
→ loads Order
→ determines amount
→ calls PaymentService

Provider integration receives only the required payment request.


Provider Client Must Not Mark Orders Paid

Also avoid:

if (providerSucceeded) {
    order.markPaid();
}

inside:

ProviderPaymentService

The integration returns:

PaymentResult

Then PayOrderUseCase decides how confirmed success affects the Order.

That preserves responsibility boundaries.


Configuration Should Stay Near Payment

Keep:

ProviderPaymentProperties

ProviderPaymentConfiguration

ProviderPaymentClient

near:

payment integration

Avoid a giant root:

ApplicationProperties

holding:

database

payment

security

every future service

in one unrelated object.

Configuration ownership should mirror capability ownership.


No Retry Configuration Yet

Do not add:

payment:
  retry-count: 3
  retry-delay: 500ms

merely because retries are common.

We have not established that repeating a payment request is safe.

Retry policy must follow:

provider idempotency contract

That is a later lesson.


No Circuit Breaker Yet

Similarly, do not add:

circuit breaker

bulkhead

fallback

just because this is an external call.

Those can be useful patterns.

But each adds behavior and operational complexity.

We first establish:

correct request/response/failure semantics

Then we introduce resilience mechanisms only when justified.


No Generic Fallback

Payment cannot safely fall back to:

another provider

because multiple providers are outside v1.

Nor should it fall back to:

pretend payment succeeded

when provider communication fails.

Financial operations require truthful results.


Provider Failure Must Never Become Success

BACKEND-117 explicitly requires:

Provider failures do not appear as successful payments.

So this is forbidden:

catch (Exception exception) {
    return PaymentResult.succeeded();
}

Obvious as it looks, similar mistakes can happen through:

default values

loose response parsing

unknown status treated as success

Success must be explicit.


Prefer Explicit Success Recognition

A safe mapping principle is:

known confirmed success
→ SUCCEEDED

Everything else must be deliberately classified as:

confirmed failure

or

technical/uncertain failure

Do not let unknown provider responses accidentally flow into success.


HTTP Client Timeouts Are Required

External calls need bounded waiting.

At minimum, provider integration needs an agreed:

timeout

The exact split between:

connect timeout

read/response timeout

depends on the HTTP client and provider requirements.

BACKEND-117 only establishes that timeout behavior must be explicit.

The next lesson will examine timeout semantics more deeply.


Connection Failure vs Timeout

Both are technical failures, but they may carry different operational meaning.

For example:

connection refused
→ provider endpoint unreachable
response timeout
→ request may have reached provider

That second case is particularly important for payments because the outcome may be uncertain.

We should preserve enough distinction for the later recovery design.


Avoid Broad catch (Exception)

Bad:

try {
    return client.pay(
            request
    );
} catch (Exception exception) {
    return PaymentResult.failed();
}

This hides:

configuration bugs

serialization bugs

network outages

timeouts

unexpected provider responses

inside:

payment declined

That violates our business-vs-system-error model.


Internal Diagnostics vs Public API

The provider adapter may eventually produce internal diagnostic information such as:

provider response category

HTTP status

timeout type

provider reference

But that does not mean our Customer API should expose raw provider details.

The HTTP API later maps application/payment failures into our stable Problem Details contract.

Provider internals remain internal.


No Raw Provider Message in Customer Response

Suppose provider says:

PAYMENT_GATEWAY_X_ERROR_981

Do not return that directly to the Customer.

Our external API should expose stable application meaning.

Provider wording can:

change

contain implementation detail

be inappropriate for clients

Translation remains part of the integration/application error boundary.


Provider Client Responsibilities

At this point, the provider integration owns:

provider configuration

provider authentication

provider URL construction

provider DTOs

HTTP communication

timeout enforcement

provider response parsing

provider-specific status mapping

provider-specific technical failures

It does not own:

Customer ownership

Order payability

Order mutation

Order persistence

REST API responses

PayOrderUseCase Responsibilities Remain Separate

Later:

PayOrderUseCase

will own:

authenticated CustomerId

owned Order lookup

payment eligibility

constructing PaymentRequest

calling PaymentService

applying confirmed success

persisting resulting Order state

cross-system consistency strategy

That workflow is BACKEND-118.

We do not implement it inside the provider client.


What Is Still Blocked?

Before the real production provider client can be considered complete, we still need the provider contract to answer:

What endpoint is called?

What HTTP method?

How is authentication performed?

What request schema is required?

What represents confirmed success?

What represents confirmed rejection?

What does timeout mean operationally?

Does provider support idempotency keys?

Who generates the idempotency key?

Can payment status be queried later?

What stable provider reference exists?

When is that reference returned?

Does retry repeat or reuse
the same payment attempt?

Does provider ever complete asynchronously?

These are not implementation trivia.

They determine payment correctness.


BACKEND-117 Readiness Gate

The original design deliberately made this ticket not-ready until:

provider idempotency semantics

timeout semantics

payment-reference semantics

are confirmed.

That remains the correct engineering position.

We can build the integration shell.

We cannot responsibly finalize production payment behavior without those answers.


Definition of Done for the Client

Once the real provider contract is available, BACKEND-117 can be Done when:

configured provider endpoint is called

credentials are externally configured

request matches provider contract

confirmed success maps correctly

confirmed rejection maps correctly

technical failures remain technical failures

timeout is explicit

provider-specific DTOs stay internal

no provider details leak into Order

configuration is not hardcoded

integration tests cover
supported provider responses

Retry/idempotency correctness may require the subsequent lessons before the entire payment workflow is Done.


Common Mistakes

Avoid:

provider URL in Java constant

Avoid:

real credential in Git

Avoid provider DTOs in:

order/application

Avoid:

HTTP status

inside PaymentResult.

Avoid:

all HTTP 2xx
→ payment success

without provider contract.

Avoid:

all HTTP 4xx
→ payment declined

without provider contract.

Avoid:

all exceptions
→ FAILED

Avoid:

timeout
→ FAILED

Avoid automatic payment retries before idempotency semantics are known.

Avoid:

PaymentRepository

for an external provider client.

Avoid letting PaymentService:

load Order

mutate Order

save Order

Avoid inventing:

refund

stored cards

multiple providers

webhooks

without requirements.


Provider Client Review Checklist

Before approving the integration, ask:

Is the provider URL configurable?

Are credentials externalized?

Are secrets excluded from logs?

Are provider DTOs isolated?

Does PaymentService expose
application language?

Can provider failure accidentally
become payment success?

Can timeout accidentally
become confirmed failure?

Are unknown provider statuses
handled safely?

Are HTTP details leaking
into PayOrderUseCase?

Is the payment amount still
server-authoritative?

Is retry behavior being invented?

Is idempotency behavior based
on the real provider contract?

Can integration tests run
without the real production provider?

Final Provider Integration Shape

The intended architecture is:

PayOrderUseCase
        ↓
PaymentService
        ↓
ProviderPaymentService
        ↓
ProviderPaymentClient
        ↓
HTTP
        ↓
External Payment Provider

Configuration flows separately:

Runtime Environment
        ↓
ProviderPaymentProperties
        ↓
ProviderPaymentClient

Provider protocol flows inward:

ProviderPaymentResponse
        ↓
ProviderPaymentService
        ↓
PaymentResult

Provider-specific types stop there.


Engineering Principle

The core principle is:

An external-service adapter translates between two worlds: the application's language and the provider's protocol. Keep provider URLs, authentication, DTOs, HTTP semantics, and error codes on the provider side of that boundary.

Another:

A confirmed business rejection and a technical inability to determine the payment outcome are different states of knowledge. Never turn network failures, timeouts, or unknown provider responses into ordinary payment rejection just to simplify the interface.

And:

Do not invent retry or idempotency behavior before understanding the provider's contract. For financial side effects, a plausible-looking assumption can become a duplicate charge.


Summary

In this lesson, we established that:

  • BACKEND-117 implements the external Payment Provider integration.
  • ProviderPaymentService implements the application-facing PaymentService.
  • Provider-specific communication remains below the PaymentService boundary.
  • Provider-specific DTOs stay inside the integration package.
  • PayOrderUseCase never handles provider JSON or HTTP status codes.
  • The provider endpoint is runtime configuration.
  • Provider credentials are externalized and never committed in source.
  • Typed configuration such as ProviderPaymentProperties keeps integration setup explicit.
  • Meaningful configuration types such as URI and Duration reduce ambiguity.
  • Required invalid configuration should fail early.
  • PayOrderUseCase does not read environment variables.
  • HTTP client choice is an integration implementation detail.
  • HTTP transport success is not automatically payment success.
  • Provider business success must be translated explicitly.
  • Confirmed provider rejection can map to application payment failure.
  • Database/network/provider failures must not masquerade as payment rejection.
  • Unknown provider responses should fail safely rather than defaulting to success.
  • Malformed provider responses are integration failures.
  • Timeouts must be explicitly bounded.
  • A timeout does not prove the payment failed.
  • Automatic retries are not added before idempotency semantics are known.
  • Provider 4xx and 5xx meanings must come from the real provider contract.
  • Payment-reference semantics remain unresolved until the provider contract is known.
  • We do not invent a payment table or provider reference field prematurely.
  • ProviderPaymentService does not load, mutate, or persist Orders.
  • PaymentService does not make authorization decisions.
  • Local automated tests should use a controlled HTTP stub rather than the real production provider.
  • Integration tests should cover confirmed success, confirmed rejection, technical failure, and timeout once the real protocol is known.
  • Secrets and potentially sensitive payment payloads must not be blindly logged.
  • BACKEND-117 remains design-blocked for production completion until provider idempotency, timeout, and payment-reference semantics are confirmed.

Next lesson:

Handling Timeouts and External Failures

We will examine why:

provider rejection

connection failure

provider 5xx

malformed response

timeout

cannot all be treated the same way, with particular attention to the dangerous case:

provider may have completed payment
+
our backend did not receive confirmation

and establish the failure taxonomy needed before we can design safe retries and payment idempotency.