Working with External Services
Defining the Payment Integration Boundary
Until now, most of the backend we have built has operated on data and behaviour that our application owns.
Products, inventory, orders, and customers all belong to our application boundary.
Payment changes that.
When a customer pays for an order, our backend must communicate with a system outside our control.
Conceptually:
Our Backend
↓
Payment Provider
That sounds simple, but external integrations introduce a different set of engineering concerns:
network communication
provider-specific request formats
provider-specific response formats
authentication credentials
timeouts
temporary failures
provider errors
uncertain outcomes
If we mix all of these concerns directly into the order-payment workflow, the business logic quickly becomes coupled to one external provider.
Before implementing the HTTP client, we therefore need to define a clean integration boundary.
What We Are Building
Our application needs a capability that can answer one important question:
Can this payment be processed, and what was the outcome?
The Order workflow should not care about:
which URL was called
which HTTP client was used
which headers the provider requires
how JSON is structured
which status code means "declined"
how provider error codes are represented
Those are integration concerns.
The application should work with concepts meaningful to our system.
For example:
payment requested
payment succeeded
payment rejected
payment outcome could not be confirmed
That distinction is the foundation of our payment integration.
The Problem With Calling the Provider Directly
Imagine implementing the payment workflow like this:
public void payOrder(UUID orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://provider.example/payments"))
.header("Authorization", "Bearer ...")
.POST(...)
.build();
HttpResponse<String> response =
httpClient.send(request, ...);
if (response.statusCode() == 200) {
order.markPaid();
}
}
Several responsibilities are now mixed together.
This method is handling:
loading an Order
business eligibility
HTTP communication
provider authentication
provider protocol
provider response interpretation
Order state transition
That creates tight coupling.
The Order workflow now knows details about the external provider that have nothing to do with Order behaviour.
Why This Coupling Becomes Expensive
Suppose the provider changes:
POST /payments
to:
POST /v2/payment-intents
or changes:
{
"success": true
}
into:
{
"status": "captured"
}
The Order workflow should not need to change because an external HTTP contract changed.
Likewise, if the provider returns:
402 PAYMENT_REQUIRED
or:
422 UNPROCESSABLE_ENTITY
our business logic should not reason directly about provider-specific HTTP semantics.
Instead:
provider protocol
↓
integration adapter
↓
application payment outcome
Introduce an Application-Facing Boundary
We will define a payment capability that the application can depend on.
Conceptually:
PayOrder
↓
PaymentService
↓
Payment Provider Client
↓
External Payment Provider
The important dependency direction is:
application workflow
↓
application-defined interface
The application does not depend on the provider's API directly.
PaymentService
A simplified starting point could look like:
public interface PaymentService {
PaymentResult processPayment(
PaymentRequest request
);
}
This interface belongs to our application's view of payment.
It does not expose things such as:
HttpResponse
JSON
provider DTOs
provider HTTP status codes
SDK-specific objects
That is deliberate.
Define the Input Around What Our Application Needs
The application may need to provide:
order reference
amount
currency
For example:
public record PaymentRequest(
UUID orderId,
BigDecimal amount,
Currency currency
) {
}
Notice what is absent:
String providerApiVersion
String authorizationHeader
String providerMerchantId
String endpointUrl
Those values may be necessary to call the provider.
But they are not part of the business request created by the Order workflow.
They belong to integration configuration.
Do Not Let the Client Decide the Amount
This deserves special attention.
Our API may receive something like:
POST /orders/123/payment
The client should not tell us:
{
"amount": 1.00
}
for an Order whose actual total is:
€149.99
The authoritative payment amount must come from the Order maintained by our backend.
Conceptually:
Client
↓
Pay Order 123
↓
Backend loads Order 123
↓
Backend determines trusted total
↓
PaymentRequest
The integration boundary receives application-trusted information.
Define a Provider-Independent Result
The provider will have its own response format.
For example, one provider might return:
{
"payment_id": "pay_987",
"status": "captured"
}
Another could theoretically use:
{
"transaction": "987",
"approved": true
}
Our Order workflow should not depend on either representation.
We translate external responses into an application-defined result.
For example:
public record PaymentResult(
PaymentStatus status,
String externalReference
) {
}
with:
public enum PaymentStatus {
SUCCEEDED,
REJECTED,
UNKNOWN
}
The precise implementation can evolve as we learn more about the provider contract.
The important idea is that the application owns this model.
Why UNKNOWN Matters
At first it may look like payment has only two outcomes:
success
failure
Distributed systems are rarely that simple.
Imagine:
Backend sends payment request
↓
Provider processes payment
↓
Network connection disappears
↓
Backend receives no response
Did payment fail?
We do not know yet.
It is unsafe to assume:
no response == payment failed
because the provider may already have charged the customer.
For now, our boundary needs to leave room for an outcome that cannot immediately be confirmed.
We will investigate this much more deeply in the upcoming lessons on failures, retries, idempotency, and payment consistency.
Provider Reference vs Internal Order ID
The payment provider will usually create its own identifier.
For example:
Our Order ID:
8c37...
Provider Payment ID:
pay_982173
These identifiers represent different things.
Our Order ID identifies:
our business Order
The provider reference identifies:
the external payment operation
If a payment succeeds, preserving that external reference is useful for later:
investigation
support
reconciliation
refunds
incident handling
So an application-facing result may expose the external reference without exposing the provider's entire response object.
Don't Return the Provider DTO
A tempting design would be:
public ProviderPaymentResponse processPayment(...);
For example:
public record ProviderPaymentResponse(
String providerPaymentId,
String merchantAccount,
String apiVersion,
int responseCode,
String providerStatus,
String declineCode,
String providerMessage
) {
}
Now the provider contract has leaked across our architecture.
Application code can start doing this:
if ("CAPTURED".equals(response.providerStatus())) {
...
}
and elsewhere:
if (response.responseCode() == 200) {
...
}
Soon, changing providers or even upgrading the same provider's API affects business code throughout the system.
Instead:
Provider response
↓
provider adapter
↓
PaymentResult
↓
application workflow
Boundary Ownership
It helps to distinguish the two sides clearly.
Application owns
PaymentService
PaymentRequest
PaymentResult
PaymentStatus
These concepts express what our backend needs from payment.
Integration layer owns
provider HTTP request
provider HTTP response
provider authentication
provider-specific errors
provider URLs
provider DTO mapping
The next lesson will implement this side.
Where Should the Interface Live?
Because PaymentService describes a capability required by application logic, it should live with the application-facing payment boundary rather than inside provider-specific infrastructure.
Conceptually:
payment/
├── application/
│ ├── PaymentService
│ ├── PaymentRequest
│ └── PaymentResult
│
└── infrastructure/
└── provider/
└── ProviderPaymentClient
The exact package layout is less important than the dependency direction.
We want:
application
↓
PaymentService
and:
ProviderPaymentClient
implements PaymentService
not:
application
↓
ProviderPaymentClient
Interface Implementation
Eventually our provider client can implement the boundary:
public class ProviderPaymentClient
implements PaymentService {
@Override
public PaymentResult processPayment(
PaymentRequest request
) {
// Provider communication lives here.
throw new UnsupportedOperationException(
"Implemented in the next lesson"
);
}
}
This gives us:
Order payment workflow
↓
PaymentService
↑
ProviderPaymentClient
The application knows the contract.
Infrastructure provides the implementation.
This Is Dependency Inversion in Practice
Earlier we learned about dependency inversion.
Payment gives us a practical reason to use it.
Without the boundary:
PayOrder
↓
ProviderPaymentClient
↓
HTTP library
↓
External provider
Our application depends directly on infrastructure.
With the boundary:
PaymentService
↑ ↑
PayOrder ProviderPaymentClient
More precisely, both sides depend on an abstraction defined around application needs.
The important point is not:
Interfaces are always better.
The important point is:
A meaningful system boundary often deserves an explicit application-facing contract.
Do We Need a Generic Payment Framework?
No.
We currently have one payment provider.
We should not create:
StripePaymentService
AdyenPaymentService
PayPalPaymentService
BankPaymentService
PaymentServiceFactory
PaymentProviderRegistry
PaymentProviderStrategyResolver
because none of that is required.
The boundary exists because:
business logic
and:
external provider protocol
are different responsibilities.
It does not exist because we are predicting five future providers.
This distinction prevents unnecessary abstraction.
Boundary vs Adapter
These terms are easy to mix up.
The boundary is what the application expects:
PaymentService
The adapter is the infrastructure implementation that talks to the external system:
ProviderPaymentClient
Conceptually:
Application
|
| PaymentService
|
v
Provider Adapter
|
| HTTP
|
v
External Provider
This style is often associated with:
Ports and Adapters
Hexagonal Architecture
Clean Architecture
We do not need to adopt an entire architecture framework to benefit from the principle.
Keep Provider Authentication Outside the Boundary
The provider may require something like:
Authorization: Bearer secret-key
Should PayOrder pass that key?
No.
Bad:
paymentService.processPayment(
orderId,
amount,
currency,
providerApiKey
);
The Order workflow should not know provider credentials.
The infrastructure implementation should obtain provider configuration through application configuration.
Conceptually:
ProviderPaymentClient
├── endpoint
├── credentials
└── HTTP client
The next lesson will deal with these details.
Don't Put Spring HTTP Types in the Contract
Avoid an application interface like:
ResponseEntity<ProviderResponse> processPayment(
PaymentRequest request
);
Why?
ResponseEntity represents an HTTP transport concern.
Our application does not need:
HTTP 200
HTTP 422
HTTP 503
It needs meaningful payment outcomes.
The adapter should translate:
HTTP/provider semantics
into:
application semantics
Don't Throw Provider-Specific Exceptions Across the Boundary
Imagine:
throw new ProviderCardDeclinedException();
and then application code catches:
catch (ProviderCardDeclinedException ex) {
...
}
Now application behaviour depends on the provider SDK.
Instead, provider-specific errors should be translated at the integration boundary.
For example:
ProviderCardDeclinedException
↓
PaymentResult(REJECTED)
or another application-defined failure representation where appropriate.
We will refine error handling once we implement the actual provider interaction.
What Belongs in the Domain?
Should the Order entity call PaymentService?
For example:
order.pay(paymentService);
Usually that would mix two different responsibilities.
The Order domain should express business rules such as:
Can this Order be paid?
What state transition happens after
a confirmed successful payment?
The application workflow coordinates:
load Order
validate ownership
validate payment eligibility
invoke external payment capability
apply confirmed result
persist state
Conceptually:
PayOrder Use Case
↓
load Order
↓
check eligibility
↓
PaymentService
↓
interpret result
↓
update Order
↓
persist
External communication belongs to the application/infrastructure collaboration, not inside the domain entity itself.
The Boundary Does Not Solve Payment Consistency
It is important not to overclaim what we have achieved.
Adding:
PaymentService
does not automatically solve:
duplicate payment requests
retries
database failure after provider success
provider timeout
concurrent Pay and Cancel requests
reconciliation
Those are separate distributed-systems problems.
The boundary gives us a clean place to manage external interaction.
It does not remove the complexity of external interaction.
We will address those problems deliberately in the following lessons.
What We Should Not Implement Yet
At this point, avoid jumping ahead into:
retry loops
idempotency keys
database transaction design
payment reconciliation
concurrent payment handling
Pay vs Cancel locking
complex recovery workflows
Those topics have dedicated lessons.
Our task here is narrower:
Define what the application expects from its payment capability while keeping provider protocol details outside business logic.
A Practical Initial Contract
An initial model could look like this:
public interface PaymentService {
PaymentResult processPayment(
PaymentRequest request
);
}
public record PaymentRequest(
UUID orderId,
BigDecimal amount,
Currency currency
) {
}
public record PaymentResult(
PaymentStatus status,
String externalReference
) {
public boolean succeeded() {
return status == PaymentStatus.SUCCEEDED;
}
}
public enum PaymentStatus {
SUCCEEDED,
REJECTED,
UNKNOWN
}
This is intentionally small.
We can evolve it if implementation requirements prove that additional information is necessary.
Avoid Premature Modeling
It would be easy to add fields such as:
providerName
providerRegion
providerErrorCategory
issuerCountry
riskScore
captureMode
authorizationCode
paymentNetwork
cardBrand
settlementDate
But our current workflow does not require them.
A good integration contract should expose:
what the application needs
not:
everything the provider can return
Acceptance Criteria
For the integration boundary to be useful, we should be able to say:
The application can request a payment
without knowing provider HTTP details.
The payment amount comes from trusted
application state.
Provider-specific request and response
models do not leak into Order logic.
Successful, rejected, and unconfirmed
outcomes can be represented.
The external provider reference can be
preserved when relevant.
Provider credentials and endpoint
configuration remain infrastructure concerns.
The boundary contains only capabilities
required by the current payment workflow.
Common Mistake: Boundary Mirrors Provider API
Suppose the provider exposes:
createPayment
capturePayment
retrievePayment
cancelAuthorization
listTransactions
A common mistake is creating:
public interface PaymentService {
void createPayment();
void capturePayment();
void retrievePayment();
void cancelAuthorization();
void listTransactions();
}
simply because those operations exist in the provider documentation.
That means the provider API is designing our application interface.
Instead, ask:
What capability does our application currently require?
For our current scope:
process payment for an eligible Order
That should drive the boundary.
Common Mistake: Over-Generalizing
Another version:
public interface ExternalService<
REQUEST,
RESPONSE,
ERROR
> {
RESPONSE execute(REQUEST request);
}
Technically reusable.
Semantically almost meaningless.
Compare:
PaymentService
with:
ExternalService<Request, Response, Error>
The first communicates business intent.
The second mostly communicates mechanics.
Prefer explicit boundaries over generic abstractions when the domain meaning matters.
Common Mistake: Provider Name Everywhere
Avoid letting classes such as:
ProviderXPaymentResult
ProviderXPaymentStatus
ProviderXOrderPayment
ProviderXPaymentException
spread throughout application code.
Provider naming is appropriate inside the integration implementation.
Application code should normally speak our language:
PaymentRequest
PaymentResult
PaymentService
Testing Benefit
A clear boundary also improves testing.
A PayOrder use case should not need a real external provider during a unit test.
We can substitute the payment capability:
PaymentService paymentService =
request -> new PaymentResult(
PaymentStatus.SUCCEEDED,
"test-payment-123"
);
Then test:
given an eligible Order
when payment succeeds
then the successful result can be
applied according to the workflow
Likewise:
PaymentService paymentService =
request -> new PaymentResult(
PaymentStatus.REJECTED,
null
);
This lets application tests focus on application behaviour.
The provider client itself will be tested separately.
We will cover the full testing strategy later in the course.
The Architecture After This Lesson
We now have the conceptual structure:
Our Application
┌─────────────────┐
│ PayOrder │
│ Use Case │
└────────┬────────┘
│
│
┌────────▼────────┐
│ PaymentService │
└────────▲────────┘
│ implements
│
┌──────────┴──────────┐
│ ProviderPaymentClient│
└──────────┬──────────┘
│
│ HTTP
│
┌───────▼────────┐
│ Payment │
│ Provider │
└────────────────┘
This is the boundary we need before writing infrastructure code.
Engineering Principle
The main principle from this lesson is:
Define external integrations in terms of what your application needs, not in terms of what the provider API happens to expose.
And:
Keep provider protocols at the edge of the system.
The Order workflow should understand payment as a business capability.
The payment adapter should understand the provider protocol.
Those responsibilities meet at the integration boundary.
Summary
In this lesson, we:
- identified payment as an external system boundary;
- saw why direct provider calls inside Order logic create unnecessary coupling;
- introduced an application-facing
PaymentService; - defined provider-independent
PaymentRequestandPaymentResultconcepts; - kept HTTP, credentials, provider DTOs, and provider error codes out of application logic;
- preserved the external payment reference without exposing the entire provider response;
- distinguished the application boundary from its infrastructure adapter;
- avoided building abstractions for imaginary future providers;
- left retries, idempotency, transactions, reconciliation, and concurrency for their dedicated lessons.
We now know what our application expects from payment.
Next, we can implement the infrastructure component that fulfills that contract.
Next: Implementing the Payment Provider Client
In the next lesson, we will implement the provider-facing side of the boundary and learn how to:
construct the provider request
perform the HTTP call
configure endpoint and credentials
translate provider responses
keep provider-specific protocol details
inside the integration layer
That will turn our PaymentService boundary from an architectural contract into a working external integration.
