Defining the Payment Integration Boundary
A `PaymentService` port that speaks our language, not the provider's.
You are viewing a free preview lesson.
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 payment provider, a system outside our control.
That sounds simple, but external integrations bring a different set of engineering concerns: network communication, provider-specific request and response formats, authentication credentials, timeouts, temporary failures, provider errors and outcomes we cannot confirm. If we mix all of these directly into the Order payment workflow, the business logic quickly becomes coupled to one external provider. Before implementing the HTTP client, we therefore define a clean integration boundary. This is the backlog's BACKEND-119 Define Payment Integration Boundary.
What We Are Building
Our application needs a capability that answers one important question:
Can this payment be processed, and what was the outcome?
The Order workflow should not care which URL was called, which HTTP client was used, which headers the provider requires, how its JSON is structured, which status code means "declined" or how provider error codes are represented. Those are integration concerns. The application should work with concepts meaningful to our system: a payment was requested, it succeeded, it was rejected, or its 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:
<!-- fragment -->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();
}
}
This one method loads an Order, decides business eligibility, performs HTTP communication, authenticates with the provider, speaks the provider's protocol, interprets its response and changes the Order's state. That is 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 moves POST /payments to POST /v2/payment-intents, or changes its answer from this:
{
"success": true
}
to this:
{
"status": "captured"
}
The Order workflow should not need to change because an external HTTP contract changed. Likewise, if the provider answers 402 Payment Required or 422 Unprocessable Entity, our business logic should not reason directly about provider-specific HTTP semantics. The provider protocol should end at an integration adapter, which turns it into an application payment outcome.
An Application-Facing Boundary: PaymentService
We define a payment capability that the application depends on. The application does not depend on the provider's API directly; it depends on an interface it defines itself:
Drawing diagram…
The interface is small:
package com.tarkev.ordermanagement.payment.application;
public interface PaymentService {
PaymentResult pay(PaymentRequest request);
}
This interface belongs to our application's view of payment, the boundary the course has used since module 3. It does not expose HttpResponse, JSON, provider DTOs, provider HTTP status codes or SDK-specific objects. That is deliberate.
Define the Input Around What Our Application Needs
The application provides what a payment needs from our side: which Order is being paid and how much. Money in the Order Management Backend is long cents in EUR, so the amount is the Order's totalCents, passed as amountCents:
package com.tarkev.ordermanagement.payment.application;
import com.tarkev.ordermanagement.order.domain.OrderId;
import java.util.Objects;
public record PaymentRequest(OrderId orderId, long amountCents) {
public PaymentRequest {
Objects.requireNonNull(orderId, "orderId");
if (amountCents <= 0) {
throw new IllegalArgumentException("amountCents must be positive");
}
}
}
The course charges in one currency, so the request has no currency field; one arrives with the requirement for a second currency. Notice also what is absent: a provider API version, an authorization header, a provider merchant ID, an endpoint URL. 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 POST /api/v1/orders/123/pay, and the client must not be able to tell us the amount:
{
"amount": 1.00
}
for an Order whose actual total is €149.99. The authoritative payment amount comes from the Order maintained by our backend: the backend loads Order 123, takes its trusted totalCents and builds the PaymentRequest from it. The integration boundary receives only application-trusted information.
Define a Provider-Independent Result
The provider will have its own response format. One provider might return this:
{
"payment_id": "pay_987",
"status": "captured"
}
and another could use this:
{
"transaction": "987",
"approved": true
}
Our Order workflow should depend on neither. We translate external responses into an application-defined result:
package com.tarkev.ordermanagement.payment.application;
public enum PaymentStatus {
SUCCEEDED,
DECLINED
}
package com.tarkev.ordermanagement.payment.application;
public record PaymentResult(
PaymentStatus status, String providerPaymentId, String declineReason) {
public static PaymentResult succeeded(String providerPaymentId) {
return new PaymentResult(PaymentStatus.SUCCEEDED, providerPaymentId, null);
}
public static PaymentResult declined(String reason) {
return new PaymentResult(PaymentStatus.DECLINED, null, reason);
}
public boolean isSuccessful() {
return status == PaymentStatus.SUCCEEDED;
}
}
SUCCEEDED means the provider confirmed the payment; DECLINED means the provider confirmed it did not accept the payment, for example because the card was declined. The decline reason is our own short wording for logs and support, not the provider's raw code. These are the "successful and failed provider outcomes" that BACKEND-119 asks us to represent. The precise model can evolve as we learn more about the provider contract; the important idea is that the application owns this model.
Why an Unconfirmed Outcome Matters
At first it may look like payment has only two outcomes, success and failure. Distributed systems are rarely that simple. Imagine the backend sends a payment request, the provider processes it, and then the network connection disappears before the response reaches us. Did the payment fail? We do not know yet. It is unsafe to assume that no response means the payment failed, because the provider may already have charged the customer.
That is why PaymentStatus has no value for "we could not find out". An unconfirmed outcome is not a third kind of answer from the provider; it is the absence of an answer, and squeezing it into DECLINED would claim knowledge we do not have. Our boundary has to leave room for it as a separate case: the next lesson gives it its own representation, PaymentOutcomeUnknown, a failure that says "outcome unknown", and the following lessons on failures, retries, idempotency and payment consistency decide what the workflow does with it.
Provider Reference vs Internal Order ID
The payment provider usually creates its own identifier. Our OrderId (for example 8c37…) identifies our business Order; the provider's ID (for example pay_982173) identifies the external payment operation. They represent different things. If a payment succeeds, keeping that external reference is useful later for investigation, support, reconciliation, refunds and incident handling. So PaymentResult can carry the provider's payment ID without exposing the provider's entire response object. Whether we persist it, and in what form, is decided once the provider contract is known.
Do Not Return the Provider DTO
A tempting design is a PaymentService that returns the provider's response type, such as ProviderPaymentResponse processPayment(...), with a response like this:
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 starts checking "CAPTURED".equals(response.providerStatus()) in one place and response.responseCode() == 200 in another. Soon, changing providers, or even upgrading the same provider's API, affects business code throughout the system. Instead, the provider response stops at the provider adapter, which turns it into a PaymentResult for the application workflow.
Boundary Ownership and Package Layout
It helps to distinguish the two sides clearly:
- The application owns
PaymentService,PaymentRequest,PaymentResultandPaymentStatus. These express what our backend needs from payment. - The integration layer owns the provider HTTP request and response, provider authentication, provider-specific errors, provider URLs and the mapping between provider DTOs and our types. The next lesson implements this side.
Because PaymentService describes a capability required by application logic, it lives with the application-facing payment boundary, not inside provider-specific infrastructure. Following our feature layout, the provider adapter goes under payment/integration/provider/:
payment/
├── application/
│ ├── PaymentService.java
│ ├── PaymentRequest.java
│ ├── PaymentResult.java
│ └── PaymentStatus.java
└── integration/
└── provider/
├── ProviderPaymentService.java
└── ProviderPaymentClient.java
The package names matter less than the dependency direction. PayOrderUseCase depends on PaymentService, and ProviderPaymentService, the provider adapter, implements it, using ProviderPaymentClient for the HTTP calls. PayOrderUseCase never depends on the adapter or the client.
Implementing the Interface
The provider adapter implements the boundary. For now, it is a placeholder:
package com.tarkev.ordermanagement.payment.integration.provider;
import com.tarkev.ordermanagement.payment.application.PaymentRequest;
import com.tarkev.ordermanagement.payment.application.PaymentResult;
import com.tarkev.ordermanagement.payment.application.PaymentService;
public class ProviderPaymentService implements PaymentService {
@Override
public PaymentResult pay(PaymentRequest request) {
// Provider communication lives here.
throw new UnsupportedOperationException("Implemented in the next lesson");
}
}
The application knows the contract; infrastructure provides the implementation.
This Is Dependency Inversion in Practice
Earlier we learned about dependency inversion, and payment gives us a practical reason to use it. Without the boundary, PayOrderUseCase depends on the provider client, which depends on an HTTP library, which talks to the external provider: our application depends directly on infrastructure. With the boundary, both sides depend on an abstraction defined around application needs:
Drawing diagram…
The important point is not that interfaces are always better. It is this:
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, so we do not create StripePaymentService, AdyenPaymentService, a PaymentServiceFactory or a PaymentProviderRegistry. The boundary exists because business logic and the external provider protocol are different responsibilities, not because we are predicting five future providers.
Boundary vs Adapter
These terms are easy to mix up. The boundary (or port) is what the application expects: PaymentService. The adapter is the infrastructure implementation that talks to the external system: ProviderPaymentService, with its HTTP client. The application calls the boundary, the adapter speaks HTTP to the provider. This style is associated with Ports and Adapters (Hexagonal Architecture) and Clean Architecture. We do not need to adopt an entire architecture framework to benefit from the principle.
Keep Provider Details Out of the Contract
Three kinds of provider detail tend to creep into a boundary like this. Each one belongs on the adapter's side.
Authentication
The provider may require something like Authorization: Bearer secret-key. Should PayOrderUseCase pass that key? No:
paymentService.pay(orderId, amountCents, providerApiKey);
The Order workflow should not know provider credentials. The adapter obtains the endpoint, the credentials and its HTTP client through application configuration; the next lesson deals with these details.
Spring HTTP Types
Avoid an application interface like this:
<!-- fragment -->ResponseEntity<ProviderResponse> pay(PaymentRequest request);
ResponseEntity represents an HTTP transport concern. Our application does not need 200, 422 or 503; it needs meaningful payment outcomes. The adapter translates HTTP and provider semantics into application semantics.
Provider-Specific Exceptions
Imagine the adapter throws a provider SDK's ProviderCardDeclinedException and application code catches it. Now application behaviour depends on the provider SDK. Instead, provider-specific errors are translated at the integration boundary: a confirmed decline becomes PaymentResult.declined(...), and technical problems become application-defined failures, which we define when we implement the actual provider interaction.
What Belongs in the Domain?
Should the Order entity call PaymentService, as in order.pay(paymentService)? Usually that mixes two different responsibilities. The Order domain expresses business rules: can this Order be paid, and what state transition happens after a confirmed successful payment (order.markPaid())? The application workflow coordinates the steps around it:
Drawing diagram…
External communication belongs to the collaboration between application and infrastructure, not inside the domain entity.
What the Boundary Does Not Solve
It is important not to overclaim what we have achieved. Adding PaymentService does not solve duplicate payment requests, retries, a database failure after the provider succeeded, provider timeouts, concurrent Pay and Cancel requests or reconciliation. Those are separate distributed-systems problems, each with its own lesson in this module. The boundary gives us a clean place to manage external interaction; it does not remove the complexity of external interaction.
The four application types are also intentionally small. It would be easy to add providerName, providerRegion, issuerCountry, riskScore, captureMode, cardBrand or settlementDate, but our current workflow requires none of them. A good integration contract exposes what the application needs, not everything the provider can return, and evolves only when implementation requirements prove that more information is necessary.
Common Mistakes
The Boundary Mirrors the Provider API
Suppose the provider exposes createPayment, capturePayment, retrievePayment, cancelAuthorization and listTransactions. A common mistake is creating this, simply because those operations exist in the provider documentation:
public interface PaymentService {
void createPayment();
void capturePayment();
void retrievePayment();
void cancelAuthorization();
void listTransactions();
}
That lets the provider API design our application interface. Instead, ask which capability the application currently requires. For our scope, that is "pay for an eligible Order", and that drives the boundary.
Over-Generalizing
Another version is technically reusable and semantically almost meaningless:
<!-- fragment -->public interface ExternalService<REQUEST, RESPONSE, ERROR> {
RESPONSE execute(REQUEST request);
}
PaymentService communicates business intent; ExternalService<Request, Response, Error> mostly communicates mechanics. Prefer explicit boundaries over generic abstractions when the domain meaning matters.
Provider Names Everywhere
Avoid letting classes such as ProviderXPaymentResult, ProviderXPaymentStatus, ProviderXOrderPayment or ProviderXPaymentException spread through application code. Provider naming is appropriate inside the integration implementation; application code should speak our language: PaymentRequest, PaymentResult, PaymentService.
Testing Benefit
A clear boundary also improves testing. A PayOrderUseCase unit test should not need a real external provider. Because PaymentService has a single method, a test can substitute the payment capability with a lambda:
PaymentService succeeding = request -> PaymentResult.succeeded("test-payment-123");
PaymentService declining = request -> PaymentResult.declined("card declined");
With the first, the test checks that given an eligible Order, a successful payment is applied according to the workflow; with the second, that a rejection leaves the Order unpaid. Application tests focus on application behaviour, the provider adapter is tested separately, and module 10 covers the full testing strategy.
Practice Exercise 1: Test the Boundary Types
Solutions: lesson 9.12.
The boundary is plain Java, so it needs no Spring and no HTTP to test. Write a plain JUnit test, M9PaymentBoundaryTest in payment.application, that proves:
- A
PaymentRequestfor 1 cent is accepted; 0 cents, −4500 cents and a missingOrderIdare rejected. PaymentResult.declined("card declined")is not successful and carries no provider payment ID.- For an
UNPAIDOrder ofcustomer-anawith Desk Lamp × 2 at 3490 cents and Monitor Arm × 1 at 8019 cents, a request built fromorder.totalCents()reaches a recordingPaymentServicelambda with exactly 14,999 cents and the Order's ID.
Practice Exercise 2: Which Side Owns It?
For each item, decide whether it belongs to the application side (PaymentService and its types, PayOrderUseCase, the Order) or to the provider adapter, and give one reason:
- The provider's base URL.
- The rule that the amount is the Order's
totalCents. - Turning the provider's status code
X42into a decline. - The rule that a declined payment leaves the Order
UNPAID. - The provider's merchant account ID.
- The provider payment ID
pay_982173returned after a success. - The JSON field name
merchant_reference.
Practice Exercise 3: Review a Boundary Draft
A teammate opens a pull request with this boundary, and PayOrderUseCase marks the Order paid when response.getStatusCode().value() == 200:
public interface PaymentService {
ResponseEntity<ProviderPaymentResponse> pay(
UUID orderId, long amountCents, String apiKey);
}
public enum PaymentStatus {
SUCCEEDED,
DECLINED,
DECLINED_TIMEOUT
}
List every problem you would raise in review, and say what the lesson's version does instead.
Knowledge Check
Question 1
The client saw the price on the product page. Why does POST /api/v1/orders/{orderId}/pay still not accept an amount?
Question 2
There is one provider and one implementation. Why is PaymentService an interface at all, when the lesson rejects a generic payment framework?
Question 3
The provider renames "status": "approved" to "state": "CAPTURED". Which of our classes change?
Question 4
Why must "the provider did not answer" never become PaymentResult.declined(...)?
Question 5
Why does PayOrderUseCase call PaymentService, rather than an order.pay(paymentService) method on the Order?
Knowledge Check Answers
Answer 1
The amount must be the Order's trusted totalCents, computed from the prices stored when the Order was created. An amount from the request would let a client pay €1.00 for a €149.99 Order.
Answer 2
The interface separates two responsibilities, the business workflow and the provider protocol, and lets the dependency point from the adapter to the application. It is not there for imagined future providers.
Answer 3
Only the provider adapter: the type it reads the provider's JSON into and the mapping that turns CAPTURED into PaymentResult.succeeded(...). PaymentResult, PaymentStatus and PayOrderUseCase stay as they are, which is the point of the boundary.
Answer 4
A missing answer is a lack of knowledge, not a confirmed refusal: the provider may already have charged the customer. Calling it a decline claims something we do not know.
Answer 5
The Order owns business rules such as markPaid(); calling a remote system is coordination. Keeping it in the use case leaves the domain free of I/O and testable without any provider.
Engineering Principle
Define external integrations in terms of what your application needs, not in terms of what the provider API happens to expose. Keep provider protocols at the edge of the system.
The Order workflow understands payment as a business capability; the payment adapter understands the provider protocol. Those responsibilities meet at the integration boundary.
Summary
- Payment is our first external system boundary, and calling the provider directly from Order logic couples the business workflow to a provider's URLs, headers, JSON and status codes.
PaymentService.pay(PaymentRequest)is the application-facing contract;PaymentRequestcarries theOrderIdand the trusted amount (amountCents, from the Order'stotalCents), never an amount from the client.PaymentResult(SUCCEEDEDorDECLINED, checked withisSuccessful()) is provider-independent and can carry the provider's payment ID without exposing the provider's response.- A missing answer is not a failure: an unconfirmed outcome must never be squeezed into
DECLINED. - HTTP types, credentials, provider DTOs and provider exceptions stay in the adapter (
ProviderPaymentServiceinpayment/integration/provider/), which implements the port: dependency inversion with a real reason. - The
Orderentity does not callPaymentService; the use case coordinates, the domain appliesmarkPaid()after a confirmed success. - We build no generic payment framework and model no field the workflow does not need; retries, idempotency, transactions, reconciliation and concurrency get their own lessons.
We now know what our application expects from payment. In the next lesson, Implementing the Payment Provider Client, we build the provider-facing side of the boundary: the HTTP call with Spring's RestClient, endpoint and timeout configuration, and the translation of provider responses and failures, so PaymentService turns from an architectural contract into a working integration.
Think first
Coming soonCheckpoint questions that ask you to commit to an answer before the lesson shows it.