Lesson 91 of 159
Written lesson · 16 min

Implementing the Payment Provider Client

The `RestClient` adapter, configuration without defaults, and tests against a stub server.

Preview

You are viewing a free preview lesson.

In the previous lesson, we defined PaymentService, the capability PayOrderUseCase needs. Now we build the infrastructure component behind it that actually communicates with the payment provider. This is the backlog's next payment ticket:

markdown
# BACKEND-120 — Implement Payment Provider Integration

## Acceptance Criteria

- The configured provider can be called.
- Successful provider responses are translated into application results.
- Provider failures are not interpreted as successful Payment.
- Timeouts are handled explicitly.
- Provider URL and credentials are not hardcoded.

One constraint matters. The design review (Revision 15 in module 2) gated real payment execution on provider semantics, and we still cannot finalize endpoint paths, authentication headers, provider DTOs, idempotency behaviour or payment-reference semantics. So this lesson builds the correct integration architecture, and a working client for it, without pretending that our placeholder protocol is the real one: an adapter that implements PaymentService, owns provider communication and translates it into application meaning without leaking transport details into PayOrderUseCase or the Order domain.

Where the Provider Client Lives

The application side from the previous lesson stays in payment/application/; everything that speaks the provider's protocol goes under payment/integration/provider/:

text
payment/
├── application/
│   ├── PaymentService.java
│   ├── PaymentRequest.java
│   ├── PaymentResult.java
│   ├── PaymentStatus.java
│   ├── PaymentOutcomeUnknown.java
│   └── PaymentProviderUnavailable.java
└── integration/
    └── provider/
        ├── ProviderPaymentService.java
        ├── ProviderPaymentClient.java
        ├── ProviderPaymentProperties.java
        ├── ProviderPaymentConfiguration.java
        ├── ProviderPaymentRequest.java
        └── ProviderPaymentResponse.java

There is no payment/domain/ yet. The two failure types sit in application/ on purpose: PayOrderUseCase catches them without importing anything from the provider package.

Two Classes, Two Responsibilities

Below the boundary, we split the provider work in two:

  • ProviderPaymentService implements PaymentService and translates: PaymentRequest to the provider's request, the provider's response to PaymentResult.
  • ProviderPaymentClient owns HTTP transport: method, URL, headers, serialization, connection behaviour, response status, transport errors and timeouts.

Combining them may be reasonable for a genuinely tiny integration; separating them is justified when it makes the mapping, the HTTP behaviour and their tests clearer. The architecture requires no abstraction for ceremony, which is also why we write a provider-specific client rather than a generic ExternalGateway<Request, Response>.

Provider Request and Response Models

Our PaymentRequest contains an OrderId and amountCents. The provider may require a different representation, so we create provider-specific DTOs, visible only inside the provider package:

java
package com.tarkev.ordermanagement.payment.integration.provider;

record ProviderPaymentRequest(String merchantReference, long amount) {
}
java
package com.tarkev.ordermanagement.payment.integration.provider;

record ProviderPaymentResponse(String status, String reference) {
}

The field names are placeholders (more on that below): every field must eventually come from an actual provider requirement, not from what payment APIs often have. What matters is that PaymentRequest and ProviderPaymentRequest have different owners, and that the response DTO never reaches PayOrderUseCase: if the provider's approved later becomes another provider's SUCCESS, only the adapter's mapping changes.

The HTTP Client: RestClient

Spring Framework 7's RestClient, a synchronous, fluent HTTP client, is the course's client. Spring Boot 4 gives it its own starter:

<!-- file: build.gradle (merge) -->
groovy
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-restclient'
}

The starter auto-configures a RestClient.Builder bean with Boot's JSON message converters (Jackson 3). The HTTP library underneath is detected from the classpath: Apache HttpClient 5, Jetty or Reactor Netty if present, otherwise the JDK's java.net.http.HttpClient, which is what our project uses. The client choice stays an implementation detail: PaymentService never declares ResponseEntity or takes an HttpRequest. What matters more than the library is the request, the response, the timeout, the mapping and the meaning of each failure.

Typed Payment Configuration

A hardcoded URL would tie one artifact to one environment, while the same build runs against a stub, the sandbox, staging and production; a hardcoded API key is worse, since credentials come from the environment or a secrets mechanism (module 3), never from source, a committed application.yml or test fixtures. Module 3 also sketched a PaymentProperties record with a base URL and a timeout; now that the integration is real, it moves into the provider package, under the prefix payment.provider:

java
package com.tarkev.ordermanagement.payment.integration.provider;

import org.springframework.boot.context.properties.ConfigurationProperties;

import java.net.URI;
import java.time.Duration;
import java.util.Objects;

@ConfigurationProperties(prefix = "payment.provider")
public record ProviderPaymentProperties(URI baseUrl, Duration timeout) {

    public ProviderPaymentProperties {
        Objects.requireNonNull(baseUrl, "payment.provider.base-url is required");
        Objects.requireNonNull(timeout, "payment.provider.timeout is required");
        if (timeout.isZero() || timeout.isNegative()) {
            throw new IllegalArgumentException("payment.provider.timeout must be positive");
        }
    }
}

URI and Duration say "a location" and "a time", unlike a long timeout that could be milliseconds or minutes. A credential property is left out until the provider's authentication contract is defined. The compact constructor makes required configuration fail fast: a missing base URL should stop startup, not surface at the first customer payment. With payment.provider.base-url unset:

text
***************************
APPLICATION FAILED TO START
***************************

Description:

Failed to bind properties under 'payment.provider' to com.tarkev.ordermanagement.payment.integration.provider.ProviderPaymentProperties:

    Reason: java.lang.NullPointerException: payment.provider.base-url is required

Action:

Update your application's configuration

A zero or negative timeout fails the same way. The record invents no default either: timeouts are a provider integration decision, and "missing URL, so use production" would hide required setup. Locally:

yaml
payment:
  provider:
    base-url: http://localhost:9001
    timeout: 5s

The Client: One Call, Translated Failures

The client sends the request and turns every transport-level problem into one of our two failure types, choosing the cautious one whenever it cannot prove more:

java
package com.tarkev.ordermanagement.payment.integration.provider;

import com.tarkev.ordermanagement.payment.application.PaymentOutcomeUnknown;
import com.tarkev.ordermanagement.payment.application.PaymentProviderUnavailable;
import org.springframework.http.MediaType;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestClientResponseException;

public class ProviderPaymentClient {

    private final RestClient restClient;

    public ProviderPaymentClient(RestClient restClient) {
        this.restClient = restClient;
    }

    ProviderPaymentResponse pay(ProviderPaymentRequest request) {
        ProviderPaymentResponse response;
        try {
            response = restClient.post()
                    .uri("/payments")
                    .contentType(MediaType.APPLICATION_JSON)
                    .body(request)
                    .retrieve()
                    .body(ProviderPaymentResponse.class);
        } catch (RestClientResponseException exception) {
            if (exception.getStatusCode().is4xxClientError()) {
                throw new PaymentProviderUnavailable(
                        "Provider answered " + exception.getStatusCode(), exception);
            }
            throw new PaymentOutcomeUnknown(
                    "Provider answered " + exception.getStatusCode(), exception);
        } catch (RestClientException exception) {
            throw new PaymentOutcomeUnknown(
                    "No usable answer from provider", exception);
        }
        if (response == null) {
            throw new PaymentOutcomeUnknown("Provider sent no body", null);
        }
        return response;
    }
}

retrieve() throws a RestClientResponseException for any 4xx or 5xx answer. A 4xx says the provider refused the request, so nothing was charged. A 5xx only says something failed on its side, possibly after the charge was booked, so it leaves the outcome unknown. Every other failure is a RestClientException: a read timeout (ResourceAccessException caused by the JDK client's HttpTimeoutException: Request cancelled), an I/O error or an unparseable body. From those we cannot tell whether the provider acted, so they become "outcome unknown". A refused connection lands there too for now, although it proves nothing was sent; the next lesson teaches the client to recognize such cases. The path /payments is a placeholder, like the DTO fields. The two failure types are small application classes:

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

public class PaymentOutcomeUnknown extends RuntimeException {

    public PaymentOutcomeUnknown(String message, Throwable cause) {
        super(message, cause);
    }
}
java
package com.tarkev.ordermanagement.payment.application;

public class PaymentProviderUnavailable extends RuntimeException {

    public PaymentProviderUnavailable(String message, Throwable cause) {
        super(message, cause);
    }
}

Their messages never contain the API key or provider payloads.

Authentication Is Not Guessed

We do not know whether the provider uses a bearer token, Basic authentication, an API key header, signed requests or OAuth client credentials, so the client sends none yet. Once the contract says, ProviderPaymentClient owns it, typically as a defaultHeader fed from ProviderPaymentProperties; writing Authorization: Bearer ... now would be a guess.

Mapping the Provider Response

ProviderPaymentService maps provider meaning to application meaning, and it recognizes success explicitly:

java
package com.tarkev.ordermanagement.payment.integration.provider;

import com.tarkev.ordermanagement.payment.application.PaymentOutcomeUnknown;
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 {

    private final ProviderPaymentClient client;

    public ProviderPaymentService(ProviderPaymentClient client) {
        this.client = client;
    }

    @Override
    public PaymentResult pay(PaymentRequest request) {
        ProviderPaymentResponse response = client.pay(new ProviderPaymentRequest(
                request.orderId().value().toString(), request.amountCents()));

        if ("approved".equals(response.status())) {
            return PaymentResult.succeeded(response.reference());
        }
        if ("declined".equals(response.status())) {
            return PaymentResult.declined("Declined by payment provider");
        }
        throw new PaymentOutcomeUnknown(
                "Unknown provider status: " + response.status(), null);
    }
}

The safe principle: known confirmed success becomes SUCCEEDED; everything else is deliberately classified as a confirmed decline or a failure with an uncertain outcome. Nothing reaches success through a default value, loose parsing or an unknown status, and an unknown status is not a decline either: assuming DECLINED could hide an incompatibility while the customer was in fact charged. HTTP transport success is not payment success, because a provider can answer 200 OK with a declined payment. Provider codes such as X42 ("rejected") are mapped here and never appear in PayOrderUseCase.

Wiring the Integration

A configuration class next to the integration builds the client and the service:

java
package com.tarkev.ordermanagement.payment.integration.provider;

import com.tarkev.ordermanagement.payment.application.PaymentService;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder;
import org.springframework.boot.http.client.HttpClientSettings;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;

@Configuration
@EnableConfigurationProperties(ProviderPaymentProperties.class)
public class ProviderPaymentConfiguration {

    @Bean
    ProviderPaymentClient providerPaymentClient(
            RestClient.Builder builder,
            ClientHttpRequestFactoryBuilder<?> requestFactoryBuilder,
            ProviderPaymentProperties properties) {
        HttpClientSettings settings = HttpClientSettings.defaults()
                .withReadTimeout(properties.timeout());
        RestClient restClient = builder
                .baseUrl(properties.baseUrl())
                .requestFactory(requestFactoryBuilder.build(settings))
                .build();
        return new ProviderPaymentClient(restClient);
    }

    @Bean
    PaymentService paymentService(ProviderPaymentClient client) {
        return new ProviderPaymentService(client);
    }
}

Boot provides both builders. RestClient.Builder is a fresh builder per injection point, so configuring it here affects no other client; ClientHttpRequestFactoryBuilder creates the request factory for the detected library, and HttpClientSettings carries our timeout into it as the read timeout, the longest we wait for an answer. Boot also has global properties, spring.http.clients.connect-timeout and spring.http.clients.read-timeout, which apply to every auto-configured client (the older spring.http.client.* names are deprecated since Boot 4.0). We set the timeout per client instead, because it is a decision about this provider, not about every HTTP call the application makes.

PayOrderUseCase knows no property name, URL or timeout.

Confirmed Rejection vs Technical Failure

A confirmed rejection is not a system failure: the provider answered normally and did not accept the payment. A technical failure means we obtained no normal answer at all. Each case lands in exactly one place:

What happenedResultWhat we know
approvedPaymentResult.succeeded(reference)The customer was charged
declinedPaymentResult.declined(reason)The customer was not charged
4xx answerPaymentProviderUnavailableThe provider refused the request (to be confirmed by the contract)
5xx answerPaymentOutcomeUnknownThe provider failed, perhaps after charging
Timeout, I/O errorPaymentOutcomeUnknownThe request may have been processed; the outcome is unknown
Unreadable body, unknown statusPaymentOutcomeUnknownThe provider answered something we cannot interpret; the outcome is unknown

PaymentOutcomeUnknown stands for "outcome unknown"; its most common cause gives it its name. PaymentProviderUnavailable claims nothing was charged, so it may only be used where that is true, and even a 4xx needs the provider's specification. Some 4xx answers mean our request was invalid (our bug, wrong credentials), while some providers express a business rejection as a 4xx, which would map to declined. The table records our starting assumption and the place the contract will refine.

Timeouts

BACKEND-120 requires explicit timeout behaviour, which payment.provider.timeout now bounds. When it expires, the call surfaces as PaymentOutcomeUnknown, never as PaymentResult.declined("timeout"): the provider may have charged the customer and only the response was lost. The client hides no retry loop either, because a blind retry after a lost success charges twice (lesson 4). The next lesson looks at timeouts closely and separates connecting from waiting for an answer.

What Is Still Blocked

Payment-reference semantics are unresolved too: we do not know whether a stable provider reference exists, when it is returned or whether we must persist it. So PaymentResult carries the provider's payment ID when we get one, and we invent no payment table or mandatory providerPaymentId column yet. The provider contract still has to answer:

  • Which endpoint, HTTP method, authentication and request schema?
  • What represents confirmed success and rejection, and what does each 4xx and 5xx mean?
  • What does a timeout mean operationally? Does the provider ever complete asynchronously?
  • Does it support idempotency keys, who generates them, and does a retry reuse the same payment attempt?
  • Can payment status be queried later? Which stable reference exists, and when is it returned?

What we could establish safely, we built; the design review's gate stays until these semantics are confirmed.

No Fake Provider Contract in Production Code

Our placeholder endpoint and DTO fields exist so the client can be built and tested; they must not quietly become "the provider API" the rest of the architecture grows around. A fake provider is useful for tests and local simulation when clearly identified as a test double, and each placeholder lives in one class, so the real contract replaces it in one place. Locally, payment.provider.base-url points at a stub or the provider's sandbox, because:

Local and automated development must not unintentionally perform real production payments.

Testing Against a Stub Provider

Automated tests must not depend on internet access, the sandbox's availability or real credentials. The adapter runs against a controlled HTTP stub instead, so tests verify real serialization, response mapping and timeouts. The JDK's small com.sun.net.httpserver.HttpServer is enough:

java
package com.tarkev.ordermanagement.payment.integration.provider;

import com.sun.net.httpserver.HttpServer;
import com.tarkev.ordermanagement.order.domain.OrderId;
import com.tarkev.ordermanagement.payment.application.PaymentOutcomeUnknown;
import com.tarkev.ordermanagement.payment.application.PaymentProviderUnavailable;
import com.tarkev.ordermanagement.payment.application.PaymentStatus;
import com.tarkev.ordermanagement.payment.application.PaymentRequest;
import com.tarkev.ordermanagement.payment.application.PaymentResult;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder;
import org.springframework.boot.http.client.HttpClientSettings;
import org.springframework.web.client.RestClient;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.Executors;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class ProviderPaymentServiceTest {

    private final PaymentRequest request =
            new PaymentRequest(new OrderId(UUID.randomUUID()), 14_999);

    private HttpServer provider;
    private ProviderPaymentService paymentService;

    @BeforeEach
    void startStubProvider() throws IOException {
        provider = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
        provider.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
        provider.start();

        RestClient restClient = RestClient.builder()
                .baseUrl("http://localhost:" + provider.getAddress().getPort())
                .requestFactory(ClientHttpRequestFactoryBuilder.detect().build(
                        HttpClientSettings.defaults().withReadTimeout(Duration.ofMillis(500))))
                .build();
        paymentService = new ProviderPaymentService(new ProviderPaymentClient(restClient));
    }

    @AfterEach
    void stopStubProvider() {
        provider.stop(0);
    }

    @Test
    void approvedPaymentSucceeds() {
        providerAnswers(200, """
                {"status": "approved", "reference": "pay_982173"}""", Duration.ZERO);

        PaymentResult result = paymentService.pay(request);

        assertThat(result.isSuccessful()).isTrue();
        assertThat(result.providerPaymentId()).isEqualTo("pay_982173");
    }

    @Test
    void declinedPaymentFails() {
        providerAnswers(200, """
                {"status": "declined"}""", Duration.ZERO);

        assertThat(paymentService.pay(request).status()).isEqualTo(PaymentStatus.DECLINED);
    }

    @Test
    void refusedRequestIsUnavailable() {
        providerAnswers(400, "{}", Duration.ZERO);

        assertThatThrownBy(() -> paymentService.pay(request))
                .isInstanceOf(PaymentProviderUnavailable.class);
    }

    @Test
    void serverErrorLeavesTheOutcomeUnknown() {
        providerAnswers(503, "{}", Duration.ZERO);

        assertThatThrownBy(() -> paymentService.pay(request))
                .isInstanceOf(PaymentOutcomeUnknown.class);
    }

    @Test
    void slowProviderIsATimeoutNotARejection() {
        providerAnswers(200, """
                {"status": "approved"}""", Duration.ofSeconds(2));

        assertThatThrownBy(() -> paymentService.pay(request))
                .isInstanceOf(PaymentOutcomeUnknown.class);
    }

    private void providerAnswers(int status, String json, Duration delay) {
        provider.createContext("/payments", exchange -> {
            try {
                Thread.sleep(delay);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            byte[] body = json.getBytes(StandardCharsets.UTF_8);
            exchange.getResponseHeaders().add("Content-Type", "application/json");
            exchange.sendResponseHeaders(status, body.length);
            exchange.getResponseBody().write(body);
            exchange.close();
        });
    }
}

The slow stub would answer after two seconds, but the 500 ms read timeout ends the call first. The client is built like the configuration builds it, with a local URL and a short timeout, so production code has no test-only paths. Once the real protocol is known, the stub answers with the provider's documented JSON and the tests also check the request we send.

Logging, Secrets and What Customers See

The client never logs the API secret, the Authorization header, raw card data or other credentials, even when debugging, nor whole provider payloads, which may carry sensitive data; logging is deliberate observability design. Customers never see provider internals such as PAYMENT_GATEWAY_X_ERROR_981, which can change and leak implementation detail; our API maps outcomes and failures into its stable Problem Details contract.

Responsibilities: What the Provider Integration Is Not

The integration owns everything provider-specific and nothing about Orders, customers or REST responses:

  • Not a repository. A PaymentRepository represents application-owned persistent state; the provider is an external capability.
  • Not a use case. Calling the provider is one external step inside PayOrderUseCase, not a CallPaymentProviderUseCase.
  • No Order access. ProviderPaymentService holds no OrderRepository and never calls order.markPaid(); it receives a payment request and returns a PaymentResult.

Those steps belong to PayOrderUseCase (BACKEND-121, later in this module), along with ownership, eligibility and the cross-system consistency strategy. Configuration ownership mirrors capability ownership too: the provider's properties, configuration and client stay together, not in one giant ApplicationProperties.

What We Deliberately Do Not Add

  • Retry configuration. No payment.retry-count: 3 because retries are common; retry policy follows the provider's idempotency contract.
  • Circuit breaker, bulkhead. Useful, but each adds behaviour and operational complexity; correct failure semantics come first.
  • Fallback. Payment cannot fall back to another provider (outside v1), and it never pretends a payment succeeded: no catch (Exception e) { return PaymentResult.succeeded(null); }, and no catch-all declined either, hence the client's specific catch clauses.

Definition of Done and Review

Once the real provider contract is available, BACKEND-120 is Done when:

markdown
- [ ] The configured endpoint is called; URL and credentials are external and never logged.
- [ ] The request matches the provider contract; provider DTOs stay internal.
- [ ] Confirmed success and confirmed rejection map correctly.
- [ ] Technical failures stay technical; a timeout is explicit and never a decline.
- [ ] Stub-based integration tests cover every supported provider response.

Practice Exercise 1: Malformed and Unknown Answers

Solutions: lesson 9.12.

Next to ProviderPaymentServiceTest, write a stub-server test class M9ProviderAnswerTest for two answers the lesson's tests leave out: 200 with a malformed body, {"status": "approved", "reference": }, and 200 with {"status": "review"}. Both must end in PaymentOutcomeUnknown, never in a PaymentResult.

Practice Exercise 2: Predict the Classification

With this lesson's client, predict what paymentService.pay(...) returns or throws when the provider answers (a) 200 with {"status": "APPROVED"}, (b) 200 with {"result": "approved"}, (c) 402, (d) 502 with an HTML page, (e) nothing, because the connection is refused.

Knowledge Check

Question 1

Why does a missing payment.provider.base-url stop startup instead of using a default?

Question 2

Why does the payment client get its own read timeout instead of spring.http.clients.read-timeout?

Question 3

Why is "a 4xx means nothing was charged" only a starting assumption?

Knowledge Check Answers

Answer 1

A missing URL is a setup error. A default could send payments to the wrong provider, and the mistake would surface at the first customer payment instead of at deployment.

Answer 2

The global property applies to every auto-configured client, while this timeout is a decision about one provider.

Answer 3

Only the provider's contract can confirm it: some providers express a decline as a 4xx, and others may use one for a request they are still processing.

Engineering Principle

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.

The translation is honest: a confirmed rejection and an outcome we could not determine stay different results, even when collapsing them would simplify the interface.

Summary

  • ProviderPaymentService implements PaymentService and maps between application and provider types; ProviderPaymentClient owns HTTP; provider DTOs stay package-private in payment/integration/provider/.
  • The client uses RestClient from spring-boot-starter-restclient, built from Boot's RestClient.Builder with a per-client timeout through HttpClientSettings.
  • ProviderPaymentProperties (payment.provider.*) uses URI and Duration, invents no defaults and stops startup on missing or invalid values; credentials stay external and out of logs.
  • Success is recognized explicitly; HTTP success is not payment success, and unknown statuses are neither success nor decline.
  • PaymentProviderUnavailable means nothing was charged (a 4xx), PaymentOutcomeUnknown means the outcome is unknown (a 5xx, a timeout); the provider contract may refine both.
  • No retry, circuit breaker or fallback before idempotency semantics are known; stub-server tests cover success, rejection, 4xx, 5xx and timeout.
  • The integration never touches Orders, and production completion stays gated on the provider's idempotency, timeout and payment-reference semantics.

In the next lesson, Handling Timeouts and External Failures, we look closer at why these failures cannot be treated alike, especially when the provider may have charged the customer without our backend hearing about it.

Think first

Coming soon

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