Working with External Services

Defining the Payment Integration Boundary

ReadingPreview

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 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 PaymentRequest and PaymentResult concepts;
  • 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.