Build CoreOrdered learning track

Designing Client Abstraction Boundaries

Learn Java Microservices Communication - Part 024

Production-grade guide to designing client abstraction boundaries for Java microservices communication.

13 min read2438 words
PrevNext
Lesson 2496 lesson track18–52 Build Core
#java#microservices#http-client#architecture+6 more

Part 024 — Designing Client Abstraction Boundaries

Every HTTP client library asks the wrong first question.

  • Should we use HttpClient?
  • Should we use RestClient?
  • Should we use WebClient?
  • Should we use Feign?
  • Should we use MicroProfile Rest Client?

Those are implementation questions.

The architecture question is:

Where is the boundary between local business behavior and remote communication behavior?

If that boundary is weak, every client library becomes dangerous.

A remote call will leak into business logic as:

  • framework exceptions
  • status codes
  • DTOs
  • raw headers
  • timeouts
  • retry assumptions
  • nullability surprises
  • hidden coupling
  • unclear ownership

A strong client abstraction boundary does not hide the fact that the network exists. It contains it.

This part explains how to design that boundary.


1. The Core Problem

A microservice often starts with code like this:

CustomerResponse customer = customerClient.getCustomer(customerId);
if (customer.status().equals("SUSPENDED")) {
    rejectPayment();
}

It looks clean.

But several questions are hidden:

  • What if the remote service times out?
  • What if the customer service returns 404?
  • Is 404 a domain fact or a communication problem?
  • Is the request safe to retry?
  • How long can this call consume from the parent request budget?
  • What if the downstream is overloaded?
  • What if the response schema changed?
  • What if the remote service returns stale data?
  • Should this call fail open or fail closed?
  • What metric tells us this dependency is unhealthy?

When those questions are answered at random call sites, the system becomes fragile.

The boundary must make those decisions explicit.


2. The Boundary Model

Use this mental model:

The layers have different jobs.

LayerOwnsMust not own
Domain/application codebusiness decisionHTTP status, headers, retry, transport exception
Domain portcapability needed by local serviceremote URL, framework types
Remote gatewayoperation semantics and mappingsocket details
Client policytimeout, retry, breaker, bulkhead, metricsbusiness rules
Concrete clientHTTP request/response mechanicsdomain meaning
Remote serviceremote behaviorcaller's local business workflow

The client library should be the smallest part of the design.


3. Three Boundaries, Not One

Most teams say “we wrapped the client”, but they usually wrap only the HTTP library.

You need three boundaries:

3.1 Domain capability boundary

This says what the local service needs.

public interface CustomerRiskPort {
    RiskProfile loadRiskProfile(CustomerId customerId);
}

This interface should not expose HTTP concepts.

Bad:

public interface CustomerRiskPort {
    Response getRiskProfile(String customerId);
}

Better:

public interface CustomerRiskPort {
    RiskProfile loadRiskProfile(CustomerId customerId);
}

3.2 Operation boundary

This is the gateway implementation.

@ApplicationScoped
public class RemoteCustomerRiskGateway implements CustomerRiskPort {

    private final CustomerRiskHttpClient client;
    private final RemoteCallPolicy policy;

    @Override
    public RiskProfile loadRiskProfile(CustomerId customerId) {
        return policy.execute("customer-risk.load", () -> {
            try {
                CustomerRiskResponse dto = client.getRiskProfile(customerId.value());
                return RiskProfile.from(dto);
            } catch (RemoteNotFoundException e) {
                throw new CustomerNotFound(customerId);
            } catch (RemoteUnavailableException e) {
                throw new CustomerRiskUnavailable(customerId, e);
            }
        });
    }
}

This layer knows the operation and the domain meaning of remote outcomes.

3.3 Transport boundary

This is the concrete client.

public interface CustomerRiskHttpClient {
    CustomerRiskResponse getRiskProfile(String customerId);
}

This may be implemented using JDK HttpClient, Spring RestClient, WebClient, Feign, or MicroProfile Rest Client.

The application service should not care.


4. The Anti-Pattern: Framework Client Everywhere

The common anti-pattern:

@Service
public class PaymentService {

    private final CustomerFeignClient customerClient;
    private final AccountFeignClient accountClient;
    private final FraudFeignClient fraudClient;

    public PaymentDecision authorize(PaymentCommand command) {
        CustomerResponse customer = customerClient.getCustomer(command.customerId());
        AccountResponse account = accountClient.getAccount(command.accountId());
        FraudResponse fraud = fraudClient.score(command.toFraudRequest());

        // business logic mixed with remote DTO and failure modes
    }
}

This looks productive during implementation.

It becomes expensive during incidents.

Problems:

  • PaymentService now knows remote DTO shapes
  • status semantics are interpreted inside business logic
  • retry/circuit policies are disconnected from domain criticality
  • fallback decisions become ad hoc
  • tests require too many remote mocks
  • framework migration touches business logic
  • failures are not classified consistently

The better shape:

@Service
public class PaymentService {

    private final CustomerRiskPort customerRisk;
    private final AccountLimitPort accountLimit;
    private final FraudScoringPort fraudScoring;

    public PaymentDecision authorize(PaymentCommand command) {
        RiskProfile risk = customerRisk.loadRiskProfile(command.customerId());
        AccountLimit limit = accountLimit.loadLimit(command.accountId());
        FraudScore score = fraudScoring.score(command.toFraudInput());

        return decisionEngine.decide(command, risk, limit, score);
    }
}

Now the service depends on capabilities, not clients.


5. Naming the Boundary

Names shape thinking.

Avoid names that imply locality:

CustomerService customerService;
AccountService accountService;
FraudService fraudService;

Those names hide the remote nature.

Use names that encode the role:

NameMeaning
CustomerRiskPortlocal capability needed by application
RemoteCustomerRiskGatewayremote implementation of that capability
CustomerRiskHttpClienttransport-specific client
CustomerRiskResponsewire DTO
CustomerRiskUnavailabledomain/application outcome from dependency failure

A top engineer is obsessive about this because ambiguous names create ambiguous boundaries.


6. DTO Boundary

Remote DTOs are not domain objects.

They may look similar today. That is a trap.

Remote DTO:

public record CustomerRiskResponse(
    String customerId,
    String riskTier,
    boolean pep,
    boolean sanctionsRequired,
    String evaluatedAt
) {}

Domain model:

public record RiskProfile(
    CustomerId customerId,
    RiskTier riskTier,
    boolean politicallyExposed,
    boolean sanctionsScreeningRequired,
    Instant evaluatedAt
) {
    public static RiskProfile from(CustomerRiskResponse dto) {
        return new RiskProfile(
            CustomerId.of(dto.customerId()),
            RiskTier.parse(dto.riskTier()),
            dto.pep(),
            dto.sanctionsRequired(),
            Instant.parse(dto.evaluatedAt())
        );
    }
}

Why map if it feels repetitive?

Because mapping is where you enforce invariants:

  • required fields
  • enum compatibility
  • timestamp parsing
  • default policy
  • unknown values
  • semantic conversion
  • backward-compatible fallback

Do not let wire data enter your domain unvalidated.


7. Exception Boundary

Transport exceptions should not leak upward.

Bad:

catch (WebClientResponseException.NotFound e) {
    // business logic
}

Bad:

catch (FeignException.ServiceUnavailable e) {
    // business logic
}

Bad:

catch (ProcessingException e) {
    // business logic
}

A gateway should convert from concrete client exceptions into stable application exceptions.

Example:

public RiskProfile loadRiskProfile(CustomerId customerId) {
    try {
        return RiskProfile.from(client.getRiskProfile(customerId.value()));
    } catch (RemoteNotFoundException e) {
        throw new CustomerNotFound(customerId);
    } catch (RemoteUnauthorizedException | RemoteForbiddenException e) {
        throw new CustomerRiskAccessDenied(customerId, e);
    } catch (RemoteOverloadedException | RemoteUnavailableException e) {
        throw new CustomerRiskTemporarilyUnavailable(customerId, e);
    } catch (RemoteUnexpectedStatusException e) {
        throw new CustomerRiskProtocolViolation(customerId, e);
    }
}

The gateway decides what remote outcomes mean locally.

That decision must not be scattered.


8. Status Code Boundary

HTTP status codes are not domain language.

They are protocol-level outcome classes.

HTTP outcomeCommunication meaningPossible local meaning
404remote resource not foundcustomer missing, account missing, feature disabled, stale reference
409conflictworkflow conflict, duplicate command, state transition rejected
422semantic validation failedlocal bug, user input issue, contract mismatch
429downstream throttlingretry later, degrade, shed load
500remote defectfail operation, fallback, alert
503remote unavailableretry/breaker/fallback
timeoutunknown remote outcomedo not assume failure or success

A 404 from GET /customers/{id} is different from a 404 from GET /feature-flags/{key}.

Only the operation boundary can interpret that safely.


9. Return Type Boundary

Return types communicate semantics.

Bad:

Customer getCustomer(CustomerId id);

What if missing? What if unavailable? What if forbidden?

Better options:

9.1 Required remote data

RiskProfile loadRiskProfile(CustomerId id)
    throws CustomerNotFound, CustomerRiskTemporarilyUnavailable;

Use when the caller cannot continue without the data.

9.2 Optional remote data

Optional<CustomerProfile> findCustomer(CustomerId id)
    throws CustomerLookupUnavailable;

Use when absence is a normal domain outcome.

9.3 Decision result

CustomerEligibilityResult checkEligibility(CustomerId id);

Where:

sealed interface CustomerEligibilityResult {
    record Eligible(CustomerEligibility eligibility) implements CustomerEligibilityResult {}
    record Ineligible(String reasonCode) implements CustomerEligibilityResult {}
    record UnknownDueToDependencyFailure(String dependency) implements CustomerEligibilityResult {}
}

Use when the dependency failure is part of decision logic.

9.4 Snapshot with freshness

CustomerSnapshot loadCustomerSnapshot(CustomerId id);

Where:

public record CustomerSnapshot(
    CustomerId id,
    CustomerStatus status,
    Instant observedAt,
    DataFreshness freshness
) {}

Use when stale data may be acceptable.

A mature boundary encodes uncertainty.


10. Timeout Boundary

Timeouts belong to operations, not just clients.

Bad:

customer-service.read-timeout=5s

Why bad?

Because the customer service has many operations:

OperationSLO impactTimeout
load risk profilehigh300–800 ms
search customersmedium1–2 s
export historylowasync preferred

A better model:

remoteCalls:
  customer-risk.load-profile:
    totalTimeout: 700ms
    connectTimeout: 100ms
    readTimeout: 500ms
    maxAttempts: 1
  customer-search.search:
    totalTimeout: 1800ms
    connectTimeout: 150ms
    readTimeout: 1200ms
    maxAttempts: 1

The boundary should enforce total budget:

public RiskProfile loadRiskProfile(CustomerId id) {
    return policyRegistry.get("customer-risk.load-profile")
        .execute(() -> RiskProfile.from(client.getRiskProfile(id.value())));
}

Static client timeout is a lower-level guard. Operation timeout is the real business budget.


11. Retry Boundary

Retry cannot be purely transport-level.

A retry policy needs operation semantics.

The boundary should know whether the operation is safe to replay.

Examples:

OperationRetry stance
getCustomer(id)usually safe
searchCases(query)safe if read-only and bounded
createPayment(command)only with idempotency key and server dedupe
submitEvidence(file)usually not blindly retryable
cancelOrder(id)retryable only if cancel is idempotent

Never configure “retry all IO exceptions three times” globally.

That creates retry storms and duplicate side effects.


12. Idempotency Boundary

For commands, the gateway should own idempotency metadata.

public PaymentSubmissionResult submitPayment(PaymentCommand command) {
    IdempotencyKey key = idempotencyKeys.forCommand(command.commandId());

    return policy.execute(() -> {
        PaymentSubmitRequest request = PaymentSubmitRequest.from(command);
        PaymentSubmitResponse response = client.submitPayment(key.value(), request);
        return PaymentSubmissionResult.from(response);
    });
}

Transport client:

@PostExchange("/v1/payments")
PaymentSubmitResponse submitPayment(
    @RequestHeader("Idempotency-Key") String idempotencyKey,
    @RequestBody PaymentSubmitRequest request
);

The application service should not manually build headers.

The boundary owns the communication protocol for idempotent commands.


13. Fallback Boundary

Fallback is not a technical feature. It is a product/business decision.

Bad:

@CircuitBreaker(fallbackMethod = "defaultRisk")
RiskProfile getRiskProfile(String customerId) {
    return client.getRiskProfile(customerId);
}

RiskProfile defaultRisk(String customerId, Throwable t) {
    return RiskProfile.lowRisk(customerId);
}

That can be catastrophic in regulated, financial, safety, or enforcement systems.

Fallback must be semantically justified.

DependencyFailure fallbackValid?
risk serviceassume low riskusually no
feature flag serviceuse cached flagmaybe
recommendation servicereturn empty recommendationsyes
sanctions screeningbypass screeningno
pricing serviceuse last known pricemaybe, with freshness cap

A better design:

public RiskAssessmentResult assess(CustomerId customerId) {
    try {
        RiskProfile profile = riskPort.loadRiskProfile(customerId);
        return RiskAssessmentResult.known(profile);
    } catch (CustomerRiskTemporarilyUnavailable e) {
        return RiskAssessmentResult.manualReviewRequired("RISK_SERVICE_UNAVAILABLE");
    }
}

The fallback is expressed as domain outcome, not hidden inside client annotation magic.


14. Observability Boundary

A client abstraction boundary must make observability consistent.

Every remote operation should have a stable operation name:

customer-risk.load-profile
account-limit.load-limit
fraud-score.calculate
case-command.submit-evidence

Metrics:

remote_call_duration_seconds{
  operation="customer-risk.load-profile",
  target_service="customer-service",
  outcome="success",
  status_code="200"
}

remote_call_duration_seconds{
  operation="customer-risk.load-profile",
  target_service="customer-service",
  outcome="timeout"
}

remote_call_attempts_total{
  operation="customer-risk.load-profile",
  attempt="1",
  outcome="remote_unavailable"
}

Logs:

{
  "event": "remote_call_failed",
  "operation": "customer-risk.load-profile",
  "targetService": "customer-service",
  "outcome": "remote_unavailable",
  "httpStatus": 503,
  "correlationId": "9ed6e4d1-81d1-4a81-b5c8-7c8e4cfc3e87",
  "durationMs": 412,
  "attempt": 1
}

Traces:

POST /v1/payments/{id}/authorize
└── customer-risk.load-profile
    └── HTTP GET /v1/customers/{customerId}/risk-profile
└── account-limit.load-limit
    └── HTTP GET /v1/accounts/{accountId}/limit
└── fraud-score.calculate
    └── HTTP POST /v1/fraud/scores

Do not rely on raw client auto-instrumentation alone.

Auto-instrumentation sees HTTP. Your gateway sees business operation.

You need both.


15. Configuration Boundary

Do not put remote call policy directly into business code.

Bad:

private static final Duration TIMEOUT = Duration.ofMillis(700);

Better:

remoteCalls:
  customer-risk.load-profile:
    targetService: customer-service
    method: GET
    route: /v1/customers/{customerId}/risk-profile
    totalTimeout: 700ms
    maxAttempts: 1
    circuitBreaker:
      enabled: true
      failureRateThreshold: 50
      slidingWindowSize: 50
    bulkhead:
      maxConcurrentCalls: 64

Then bind it:

public record RemoteCallConfig(
    String operation,
    String targetService,
    Duration totalTimeout,
    int maxAttempts,
    CircuitBreakerConfig circuitBreaker,
    BulkheadConfig bulkhead
) {}

A mature platform treats remote call policy as first-class configuration.

But beware over-centralization.

Bad platform abstraction:

remoteClient.call("customer-service", "/v1/customers/" + id)

This hides operation semantics.

Better:

customerRiskPort.loadRiskProfile(customerId)

where the port/gateway is configured by named operation.


16. Package Structure

A useful package structure:

payment/
  application/
    PaymentAuthorizationService.java
  domain/
    PaymentDecision.java
    RiskProfile.java
    AccountLimit.java
  ports/
    CustomerRiskPort.java
    AccountLimitPort.java
    FraudScoringPort.java
  adapters/
    customer/
      RemoteCustomerRiskGateway.java
      CustomerRiskHttpClient.java
      CustomerRiskResponse.java
      CustomerRiskErrorMapper.java
    account/
      RemoteAccountLimitGateway.java
      AccountLimitHttpClient.java
      AccountLimitResponse.java
    fraud/
      RemoteFraudScoringGateway.java
      FraudHttpClient.java
      FraudScoreResponse.java
  communication/
    RemoteCallPolicy.java
    RemoteCallPolicyRegistry.java
    RemoteCallMetrics.java
    RemoteExceptions.java

Do not put all clients in a global clients package if it destroys local ownership.

Prefer grouping by adapter/dependency when possible.

The team owning the dependency boundary should own:

  • DTOs
  • mapper
  • gateway
  • tests
  • config
  • runbook notes
  • failure policy

17. Generated Clients Boundary

Generated clients are useful.

They are also dangerous when treated as architecture.

Generated OpenAPI/gRPC clients are transport adapters, not domain ports.

Bad:

public class PaymentService {
    private final CustomerApi generatedCustomerApi;
}

Better:

public class RemoteCustomerRiskGateway implements CustomerRiskPort {
    private final CustomerApi generatedCustomerApi;
}

Why?

Generated code changes when contracts change. Business code should not churn because a schema generator renamed CustomerDto to CustomerResponseDto.

Use generated clients behind stable gateways.


18. Multi-Operation Client Boundary

One remote service often exposes many operations.

Do not automatically create one huge client interface.

Bad:

public interface CustomerServiceClient {
    CustomerResponse getCustomer(String id);
    RiskProfileResponse getRiskProfile(String id);
    CustomerSearchResponse search(CustomerSearchRequest request);
    AddressResponse getAddress(String id);
    MarketingPreferencesResponse getPreferences(String id);
    CustomerHistoryResponse exportHistory(String id);
}

Problems:

  • one policy for unrelated operations
  • huge test surface
  • unclear ownership
  • hard to reason about criticality

Better split by local capability:

public interface CustomerRiskHttpClient {
    RiskProfileResponse getRiskProfile(String id);
}

public interface CustomerProfileHttpClient {
    CustomerResponse getCustomer(String id);
}

public interface CustomerSearchHttpClient {
    CustomerSearchResponse search(CustomerSearchRequest request);
}

This is not about creating more files.

It is about aligning code with operational behavior.


19. The “Shared Client Library” Trap

Many organizations create shared client libraries for every service.

Example:

customer-service-client.jar
case-service-client.jar
billing-service-client.jar

This can help with generated contracts and basic adapters.

But it can also create tight coupling:

  • all consumers upgrade together
  • domain-specific caller semantics cannot be expressed
  • retry/fallback policy becomes one-size-fits-all
  • client library becomes a mini distributed platform
  • transitive dependencies pollute services
  • security patches force large coordinated upgrades

A safer rule:

Share wire contract types and low-level generated clients only when useful. Keep consumer-specific gateway semantics inside the consuming service.

For example:

The same remote 404 can mean different things to payment, case management, and reporting.

Do not centralize that interpretation too early.


20. Testing the Boundary

A strong boundary makes testing easier, not harder.

20.1 Application service test

Mock the domain port, not the HTTP client.

@Test
void rejectsPaymentWhenRiskProfileRequiresReview() {
    when(customerRisk.loadRiskProfile(customerId))
        .thenReturn(RiskProfile.highRisk(customerId));

    PaymentDecision decision = service.authorize(command);

    assertEquals(PaymentDecision.MANUAL_REVIEW, decision);
}

This test does not care whether the client uses Feign, WebClient, or MicroProfile Rest Client.

20.2 Gateway test

Stub remote HTTP behavior and test mapping.

@Test
void mapsRemote404ToCustomerNotFound() {
    stubCustomerRisk404(customerId);

    assertThrows(CustomerNotFound.class,
        () -> gateway.loadRiskProfile(customerId));
}

20.3 Policy test

Simulate timeout, 503, 429, malformed body, slow response.

@Test
void doesNotRetryNonIdempotentCommandWithoutKey() {
    stubPaymentTimeout();

    assertThrows(PaymentSubmissionUnknown.class,
        () -> gateway.submitPayment(commandWithoutIdempotencyKey));

    verifyOnlyOneAttemptWasMade();
}

20.4 Contract fixture test

Assert the generated or declared client emits the expected request shape.

GET /v1/customers/cus_123/risk-profile
Accept: application/json
X-Correlation-Id: <present>
traceparent: <present>

Boundary testing should prove:

  • operation path/method/headers are correct
  • DTO mapping enforces invariants
  • status mapping is stable
  • timeout/retry policy behaves as intended
  • business code is isolated from transport details

21. Migration Boundary

A good abstraction lets you change client libraries without rewriting business behavior.

Example migration:

If PaymentService depends only on CustomerRiskPort, migration is local to the adapter.

If PaymentService depends on FeignException, migration touches business code.

That is the test:

If changing HTTP client libraries changes business services, your boundary is leaking.


22. Boundary Design for Different Call Types

22.1 Read dependency

public interface CustomerProfilePort {
    Optional<CustomerProfile> findCustomer(CustomerId id)
        throws CustomerLookupUnavailable;
}

Policy:

  • short timeout
  • small retry if idempotent
  • cache maybe
  • stale data maybe acceptable

22.2 Critical decision dependency

public interface SanctionsScreeningPort {
    ScreeningDecision screen(Subject subject)
        throws ScreeningUnavailable;
}

Policy:

  • fail closed
  • no unsafe fallback
  • strong audit trail
  • explicit manual review outcome

22.3 Command dependency

public interface LedgerPostingPort {
    PostingResult postLedgerEntry(LedgerCommand command)
        throws LedgerPostingUnknown, LedgerUnavailable;
}

Policy:

  • idempotency key required
  • unknown outcome handling
  • reconciliation path
  • no blind retry without dedupe

22.4 Enrichment dependency

public interface RecommendationPort {
    List<Recommendation> recommendFor(CustomerId id);
}

Policy:

  • fail open
  • empty fallback acceptable
  • aggressive timeout
  • circuit breaker

Same transport. Different boundary semantics.


23. Abstraction Level: Too Thin vs Too Thick

Too thin

public CustomerResponse getCustomer(String id) {
    return client.getCustomer(id);
}

This wrapper adds no semantic value.

It is just pass-through indirection.

Too thick

public PaymentDecision authorizePayment(PaymentCommand command) {
    CustomerResponse customer = customerClient.getCustomer(command.customerId());
    AccountResponse account = accountClient.getAccount(command.accountId());
    FraudResponse fraud = fraudClient.score(command);
    // entire business workflow here
}

Now the gateway became the application service.

Just right

public RiskProfile loadRiskProfile(CustomerId customerId) {
    return policy.execute(() -> {
        CustomerRiskResponse response = client.getRiskProfile(customerId.value());
        return RiskProfile.from(response);
    });
}

A good gateway owns:

  • one remote capability
  • remote operation mapping
  • DTO conversion
  • failure mapping
  • communication policy
  • observability naming

It does not own the whole business workflow.


24. Client Boundary Checklist

For every remote dependency, define:

Identity

  • What is the target service?
  • What is the operation name?
  • Who owns the client boundary?
  • Is the operation read, command, query, or stream?

Contract

  • What is the route/method?
  • What are request/response DTOs?
  • What headers are required?
  • What statuses are expected?
  • What error body format is expected?

Semantics

  • What does 404 mean locally?
  • What does 409 mean locally?
  • What does timeout mean locally?
  • Can caller continue without this data?
  • Is stale data acceptable?

Policy

  • Total timeout?
  • Retry allowed?
  • Idempotency key required?
  • Circuit breaker?
  • Bulkhead?
  • Rate limit?
  • Fallback?

Observability

  • Operation metric name?
  • Low-cardinality labels?
  • Trace span name?
  • Log event names?
  • Alert thresholds?

Testing

  • Mapping test?
  • Error test?
  • Timeout test?
  • Retry test?
  • Contract fixture?
  • Domain service isolation test?

If this checklist feels heavy, that is a signal: remote communication is not free.

The checklist is cheaper than an outage.


25. A Full Example

25.1 Port

public interface AccountLimitPort {
    AccountLimit loadLimit(AccountId accountId)
        throws AccountNotFound, AccountLimitUnavailable;
}

25.2 HTTP client

public interface AccountLimitHttpClient {
    AccountLimitResponse getLimit(String accountId);
}

For Spring RestClient:

@Component
class RestClientAccountLimitHttpClient implements AccountLimitHttpClient {

    private final RestClient restClient;

    RestClientAccountLimitHttpClient(RestClient.Builder builder,
                                     AccountClientProperties properties) {
        this.restClient = builder
            .baseUrl(properties.baseUrl())
            .build();
    }

    @Override
    public AccountLimitResponse getLimit(String accountId) {
        return restClient.get()
            .uri("/v1/accounts/{accountId}/limit", accountId)
            .retrieve()
            .body(AccountLimitResponse.class);
    }
}

For MicroProfile Rest Client:

@Path("/v1/accounts")
@RegisterRestClient(configKey = "account-service")
public interface AccountLimitRestClient extends AccountLimitHttpClient {

    @Override
    @GET
    @Path("/{accountId}/limit")
    AccountLimitResponse getLimit(@PathParam("accountId") String accountId);
}

25.3 Gateway

@ApplicationScoped
public class RemoteAccountLimitGateway implements AccountLimitPort {

    private final AccountLimitHttpClient client;
    private final RemoteCallPolicy policy;

    public RemoteAccountLimitGateway(AccountLimitHttpClient client,
                                     RemoteCallPolicyRegistry policies) {
        this.client = client;
        this.policy = policies.get("account-limit.load");
    }

    @Override
    public AccountLimit loadLimit(AccountId accountId) {
        return policy.execute(() -> {
            try {
                AccountLimitResponse response = client.getLimit(accountId.value());
                return AccountLimit.from(response);
            } catch (RemoteNotFoundException e) {
                throw new AccountNotFound(accountId);
            } catch (RemoteUnavailableException e) {
                throw new AccountLimitUnavailable(accountId, e);
            }
        });
    }
}

25.4 Application service

public class PaymentAuthorizationService {

    private final AccountLimitPort accountLimit;

    public PaymentDecision authorize(PaymentCommand command) {
        AccountLimit limit = accountLimit.loadLimit(command.accountId());
        return limit.canCover(command.amount())
            ? PaymentDecision.approved()
            : PaymentDecision.rejected("LIMIT_EXCEEDED");
    }
}

Application code is now stable even if the transport implementation changes.


26. What Top Engineers Internalize

A client abstraction boundary is not about hiding HTTP.

It is about preventing HTTP from spreading everywhere.

The core invariant:

Business code depends on capabilities.
Gateways translate remote communication into local meaning.
Concrete clients only know transport.
Policies are operation-specific.
DTOs do not become domain objects.
Failures are classified before they reach business logic.

Bad abstraction says:

“I wrapped the client, so we are decoupled.”

Good abstraction says:

“The application depends on local capabilities; the gateway owns the remote operation semantics, policy, observability, and mapping; the concrete client is replaceable.”

That is the level of abstraction that survives incidents, migrations, framework changes, and service evolution.


27. References

  • RFC 9110 — HTTP Semantics
  • RFC 9457 — Problem Details for HTTP APIs
  • W3C Trace Context
  • OpenTelemetry Semantic Conventions for HTTP
  • MicroProfile Rest Client Specification
  • Spring Framework HTTP Interface and RestClient documentation
  • OpenFeign and Spring Cloud OpenFeign documentation
  • gRPC Core Concepts: deadline, cancellation, status model
  • AWS Builders Library: timeout, retry, backoff, jitter
  • Google SRE: overload and cascading failure patterns

28. Next Part

Part 025 moves from abstraction design into client configuration model:

  • connect timeout
  • read timeout
  • write timeout
  • response timeout
  • pool acquisition timeout
  • retry budget
  • circuit breaker configuration
  • bulkhead sizing
  • per-operation policy
  • safe defaults for production Java services
Lesson Recap

You just completed lesson 24 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.