Working with External Services
Implementing the Payment Provider Client
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 intoPayOrderUseCaseor 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.
ProviderPaymentServiceimplements the application-facingPaymentService.- 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
ProviderPaymentPropertieskeeps integration setup explicit. - Meaningful configuration types such as
URIandDurationreduce ambiguity. - Required invalid configuration should fail early.
PayOrderUseCasedoes 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.
