Lesson 90 of 159
Written lesson · 16 min

Defining the Payment Integration Boundary

A `PaymentService` port that speaks our language, not the provider's.

Preview

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 -->
java
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:

json
{
  "success": true
}

to this:

json
{
  "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:

java
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:

java
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:

json
{
  "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:

json
{
  "payment_id": "pay_987",
  "status": "captured"
}

and another could use this:

json
{
  "transaction": "987",
  "approved": true
}

Our Order workflow should depend on neither. We translate external responses into an application-defined result:

java
package com.tarkev.ordermanagement.payment.application;

public enum PaymentStatus {
    SUCCEEDED,
    DECLINED
}
java
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:

<!-- fragment -->
java
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, PaymentResult and PaymentStatus. 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/:

text
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:

java
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:

<!-- fragment -->
java
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 -->
java
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:

<!-- fragment -->
java
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 -->
java
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:

<!-- fragment -->
java
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 PaymentRequest for 1 cent is accepted; 0 cents, −4500 cents and a missing OrderId are rejected.
  • PaymentResult.declined("card declined") is not successful and carries no provider payment ID.
  • For an UNPAID Order of customer-ana with Desk Lamp × 2 at 3490 cents and Monitor Arm × 1 at 8019 cents, a request built from order.totalCents() reaches a recording PaymentService lambda 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:

  1. The provider's base URL.
  2. The rule that the amount is the Order's totalCents.
  3. Turning the provider's status code X42 into a decline.
  4. The rule that a declined payment leaves the Order UNPAID.
  5. The provider's merchant account ID.
  6. The provider payment ID pay_982173 returned after a success.
  7. 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:

<!-- fragment -->
java
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; PaymentRequest carries the OrderId and the trusted amount (amountCents, from the Order's totalCents), never an amount from the client.
  • PaymentResult (SUCCEEDED or DECLINED, checked with isSuccessful()) 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 (ProviderPaymentService in payment/integration/provider/), which implements the port: dependency inversion with a real reason.
  • The Order entity does not call PaymentService; the use case coordinates, the domain applies markPaid() 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 soon

Checkpoint questions that ask you to commit to an answer before the lesson shows it.