Series MapLesson 81 / 112
Focus mode active/Press Alt+Shift+R to toggle/Esc to exit
Deepen PracticeOrdered learning track

Retrofit and OpenFeign

Declarative HTTP client patterns for Java enterprise services, including proxy lifecycle, encoder/decoder behavior, error mapping, generated clients, observability, and production trade-offs

11 min read2185 words
PrevNext
Lesson 81112 lesson track62–92 Deepen Practice
#retrofit#openfeign#http-client#declarative-client+3 more

Part 081 — Retrofit and OpenFeign

Fokus part ini: memahami declarative HTTP client sebagai integration boundary, bukan sekadar interface Java yang diberi annotation.

Di part sebelumnya, outbound HTTP dilihat dari level rendah: client lifecycle, connection pool, timeout, response closing, entity handling, dan Jersey Client.

Part ini naik satu level:

Business code
  -> Java interface
  -> generated/proxy HTTP client
  -> encoder/decoder
  -> resilience/auth/logging layer
  -> downstream service

Retrofit dan OpenFeign sama-sama membuat outbound HTTP terasa seperti pemanggilan method Java.

Itu nyaman.

Tetapi juga berbahaya kalau abstraction ini menyembunyikan:

  • network failure
  • latency
  • retry behavior
  • body buffering
  • error mapping
  • serialization compatibility
  • timeout hierarchy
  • connection pool behavior
  • observability propagation
  • authentication/signing
  • generated code drift
  • downstream contract drift

Senior engineer harus bisa melihat melewati interface.


1. Core Mental Model

Declarative HTTP client mengubah interface Java menjadi request HTTP.

Contoh mental model:

interface PricingClient {
    PriceResponse quotePrice(PriceRequest request);
}

Runtime sebenarnya:

PricingClient proxy
  -> method metadata
  -> HTTP method/path template
  -> parameter expansion
  -> request body encoder
  -> header injection
  -> HTTP transport
  -> response status handling
  -> response body decoder
  -> exception/error mapping

Jangan tertipu oleh bentuk method call.

Ini tetap remote call.

Local method call semantics != remote HTTP call semantics

Remote call memiliki:

  • partial failure
  • timeout
  • duplicate delivery risk
  • authentication expiry
  • unknown result after timeout
  • compatibility issue
  • downstream overload
  • network partition

2. Why Declarative Clients Exist

Declarative clients dibuat untuk mengurangi boilerplate:

client.target(baseUrl)
      .path("/prices")
      .request(APPLICATION_JSON_TYPE)
      .post(Entity.json(request));

menjadi:

pricingClient.createPrice(request);

Benefit:

  • interface lebih mudah dibaca
  • API contract terlihat di satu tempat
  • test stubbing lebih mudah
  • request building lebih konsisten
  • generated client bisa disinkronkan dengan OpenAPI
  • integration adapter lebih tipis

Trade-off:

  • failure mode bisa tersembunyi
  • annotation bisa menjadi mini-framework
  • exception taxonomy bisa tidak jelas
  • timeout/retry bisa tersebar
  • behavior generated code kadang sulit direview
  • debugging membutuhkan pengetahuan framework

Prinsipnya:

Declarative client is acceptable only if the hidden runtime behavior is standardized and observable.

3. Retrofit Mental Model

Retrofit populer karena interface-based dan kuat dalam mapping request/response.

Typical shape:

public interface ProductCatalogApi {
    @POST("/catalog/products/search")
    Call<ProductSearchResponse> search(@Body ProductSearchRequest request);
}

Atau dengan return type lain tergantung adapter:

CompletableFuture<ProductSearchResponse>

Retrofit biasanya terdiri dari:

Retrofit instance
  -> baseUrl
  -> converter factory
  -> call adapter factory
  -> underlying HTTP client
  -> service interface proxy

Hal penting:

  • baseUrl harus stable dan environment-specific
  • converter menentukan JSON/XML behavior
  • call adapter menentukan sync/async/reactive behavior
  • underlying client menentukan pool, timeout, TLS, proxy, DNS
  • annotation menentukan method/path/query/header/body

Retrofit bukan transport murni.

Transport biasanya disediakan oleh client bawahnya, misalnya OkHttp.


4. OpenFeign Mental Model

OpenFeign juga interface-based.

Typical shape:

public interface OrderClient {
    @RequestLine("GET /orders/{id}")
    OrderResponse getOrder(@Param("id") String id);
}

Atau dengan contract style tertentu:

interface OrderClient {
    @GetMapping("/orders/{id}")
    OrderResponse getOrder(@PathVariable("id") String id);
}

Tergantung integrasi dan contract yang dipakai.

OpenFeign biasanya terdiri dari:

Feign builder
  -> contract parser
  -> encoder
  -> decoder
  -> error decoder
  -> request interceptor
  -> retryer
  -> logger
  -> client transport
  -> dynamic proxy

Key idea:

Feign is a proxy factory plus pluggable HTTP semantics.

Yang harus direview bukan hanya interface, tetapi builder/config yang membentuk proxy.


5. Interface Is a Contract Boundary

Declarative interface harus dianggap sebagai boundary kontrak.

Bad:

interface DownstreamClient {
    Object call(Object request);
}

Better:

interface PricingApiClient {
    PriceCalculationResponse calculatePrice(PriceCalculationRequest request);
}

Even better in application architecture:

Application Service
  -> Domain Port
  -> Integration Adapter
  -> Retrofit/Feign Client Interface

Example:

public interface PricingPort {
    PriceResult calculate(PriceCommand command);
}

final class HttpPricingAdapter implements PricingPort {
    private final PricingHttpClient client;

    @Override
    public PriceResult calculate(PriceCommand command) {
        PriceRequest request = mapper.toRequest(command);
        PriceResponse response = client.calculate(request);
        return mapper.toDomain(response);
    }
}

The domain/application layer should not depend directly on Retrofit/Feign annotations.

Why:

  • easier to test
  • easier to replace client implementation
  • keeps HTTP exception mapping out of business logic
  • prevents transport DTO from leaking into domain
  • centralizes observability and resilience

6. Annotation Mapping Risks

Declarative client annotation looks simple.

But small mistakes become production bugs.

Examples:

Wrong path variable name
  -> runtime failure or wrong URL

Wrong query encoding
  -> downstream filtering bug

Wrong content type
  -> 415 Unsupported Media Type

Wrong accept header
  -> 406 Not Acceptable or wrong representation

Wrong body annotation
  -> empty request body

Wrong collection expansion
  -> query compatibility bug

Senior review should check annotation mapping as carefully as endpoint implementation.

Checklist:

  • Does HTTP method match operation semantics?
  • Is path versioned consistently?
  • Are query params encoded correctly?
  • Are optional params omitted or sent as empty string?
  • Are repeated query params supported by downstream?
  • Are headers explicit?
  • Is content type explicit?
  • Is response type correct?
  • Are 204/empty body responses handled?
  • Are redirects allowed or forbidden?

7. Encoder and Decoder Behavior

Encoder converts Java request into HTTP body.

Decoder converts HTTP body into Java response.

Request DTO -> encoder -> bytes on wire
bytes on wire -> decoder -> Response DTO

Failure modes:

  • unknown JSON field rejected unexpectedly
  • enum value not recognized
  • date format mismatch
  • BigDecimal precision loss
  • null handling mismatch
  • polymorphic type security issue
  • large body fully buffered
  • empty response body decoded as error
  • downstream error body decoded as success DTO

For enterprise services, encoder/decoder must be centrally configured.

Bad:

Each client creates its own ObjectMapper.

Better:

Shared ObjectMapper policy
  -> date/time format
  -> enum handling
  -> null handling
  -> unknown field policy
  -> BigDecimal precision
  -> security settings

Internal verification checklist:

  • Is Jackson, JSON-B, or another mapper used?
  • Is the mapper shared with server-side JAX-RS?
  • Are date/time and BigDecimal policies consistent?
  • Are unknown fields ignored or rejected?
  • Are generated DTOs used?
  • Are error DTOs decoded separately?

8. Error Decoder and Exception Taxonomy

HTTP clients must not leak raw transport exceptions into business logic.

Bad:

try {
    return client.calculate(request);
} catch (Exception e) {
    throw new RuntimeException(e);
}

Better:

HTTP 400 -> downstream validation/contract error
HTTP 401/403 -> auth/config/security error
HTTP 404 -> not found or stale reference, depending on domain
HTTP 409 -> conflict/concurrency/business state issue
HTTP 429 -> throttling/rate limit
HTTP 500 -> downstream technical error
HTTP 503 -> downstream unavailable
Timeout -> unknown outcome
Connection refused -> dependency unavailable
TLS failure -> platform/security/config failure

Declarative clients need an explicit error decoder.

Error decoder should produce application-level exception categories:

DownstreamBadRequestException
DownstreamUnauthorizedException
DownstreamForbiddenException
DownstreamNotFoundException
DownstreamConflictException
DownstreamRateLimitedException
DownstreamUnavailableException
DownstreamTimeoutException
DownstreamProtocolException

Do not map everything to one exception.

Why:

  • retry decision depends on category
  • alert severity depends on category
  • client response mapping depends on category
  • RCA depends on category
  • fallback policy depends on category

9. Timeout Must Be Visible at Client Boundary

Declarative clients often hide timeout config.

That is dangerous.

Every outbound client needs explicit timeout policy:

connect timeout
read timeout
write timeout
call timeout / total deadline
pool acquisition timeout
DNS timeout if configurable
TLS handshake timeout if configurable

Timeout should align with inbound deadline.

Example:

Inbound API budget: 2000 ms
  auth/filter/logging: 100 ms
  business logic: 200 ms
  downstream A: 500 ms
  downstream B: 700 ms
  buffer/error handling: 200 ms

Do not let downstream call timeout exceed inbound request timeout.

Bad:

API gateway timeout: 30s
JAX-RS service timeout: none
HTTP client read timeout: 60s
Kafka publish timeout: 120s

This creates zombie work after caller is gone.

Internal verification checklist:

  • Where are timeouts configured?
  • Are they per-client or global?
  • Are defaults safe?
  • Are timeouts lower than upstream/gateway timeout?
  • Are timeout exceptions categorized separately?
  • Are timeout metrics emitted per downstream?

10. Retry Must Not Be Hidden in the Client Library

Some HTTP client stacks include retry behavior.

Hidden retry is dangerous because it can multiply load.

Application retry
  x HTTP client retry
  x service mesh retry
  x gateway retry
  x SDK retry
  = retry amplification

For declarative clients, retry must be explicitly configured and documented.

Retry is usually safer for:

  • transient connection failure before request is sent
  • 503 with Retry-After
  • 429 with backoff
  • idempotent GET/PUT/DELETE where semantics are understood
  • POST only with idempotency key or explicit duplicate safety

Retry is risky for:

  • non-idempotent POST
  • payment/order submission
  • quote finalization
  • downstream operations with unknown outcome after timeout
  • large file upload
  • side-effecting workflow transition

Senior rule:

Retry policy belongs to the integration adapter, not hidden inside generated proxy behavior.

11. Request Interceptors

Declarative clients often support request interceptors.

Common interceptor responsibilities:

  • add authorization token
  • add service identity header
  • add correlation ID
  • add trace context
  • add tenant ID
  • add idempotency key
  • add user-agent/client version
  • add request signature
  • add feature flag context if allowed

Risks:

  • duplicate header injection
  • stale token
  • missing tenant context
  • correlation ID generated too late
  • secret logged accidentally
  • request signing done before body finalized
  • interceptor order wrong

Interceptors must be deterministic and testable.

Bad:

Every client manually adds headers.

Better:

Standard interceptor stack
  -> identity
  -> tenant
  -> tracing
  -> correlation
  -> idempotency/signing
  -> logging/redaction

Internal verification checklist:

  • Where are interceptors registered?
  • Are they global or per-client?
  • Are tenant/security headers mandatory?
  • Are sensitive headers redacted?
  • Is trace context propagated?
  • Is interceptor order documented?

12. Generated Client vs Handwritten Client

Generated clients are useful when contract is stable and generation is governed.

Benefits:

  • reduces manual mapping errors
  • keeps DTOs aligned with OpenAPI/protobuf
  • faster onboarding
  • consistent serialization shape
  • easier compatibility diff

Risks:

  • generated code is noisy
  • business logic may depend on generated DTOs directly
  • generator version drift changes behavior
  • generated clients may hide retry/timeout defaults
  • generated models may be awkward for domain logic
  • regeneration can create huge PR diffs

Recommended boundary:

Generated client/DTOs stay inside integration adapter.
Domain/application layer uses internal model.

Do not let generated DTOs become your domain model.

Example:

GeneratedPriceResponse
  -> adapter mapper
  -> PriceResult

Internal verification checklist:

  • Are clients generated from OpenAPI?
  • Which generator and version?
  • Is generation deterministic?
  • Are generated sources committed or built?
  • Are generated DTOs leaking into domain layer?
  • Is compatibility checked before regeneration?

13. Sync vs Async Client Calls

Declarative clients can be synchronous or asynchronous.

Synchronous:

thread waits for downstream response

Pros:

  • simpler call flow
  • easier transaction reasoning
  • easier exception handling

Cons:

  • blocks request thread
  • vulnerable to thread exhaustion
  • needs tight timeout

Asynchronous:

call returns future/reactive type

Pros:

  • can reduce blocking
  • can compose concurrent downstream calls
  • better for fan-out if controlled

Cons:

  • context propagation risk
  • cancellation risk
  • harder error handling
  • concurrency amplification
  • more difficult debugging

Senior warning:

Async does not remove the need for timeout, bulkhead, and backpressure.

14. Fan-Out and Aggregation Risk

Declarative clients make remote calls look cheap.

This often causes accidental fan-out:

orders.stream()
      .map(order -> pricingClient.calculate(order))
      .toList();

If orders has 200 items, this may create 200 downstream calls.

Failure modes:

  • downstream overload
  • thread pool exhaustion
  • connection pool exhaustion
  • rate limit violation
  • noisy neighbor effect
  • latency explosion
  • partial result ambiguity

Better:

  • batch API if available
  • bounded concurrency
  • cache stable reference data
  • prefetch with limit
  • async with bulkhead
  • reject overly expensive request
  • expose query cost limit

Review question:

Can this code path multiply one inbound request into many outbound requests?

15. Observability for Declarative Clients

Every outbound client should emit signals by downstream dependency.

Minimum metrics:

http.client.requests.count{client,method,status_class}
http.client.duration{client,method,status_class}
http.client.errors.count{client,error_type}
http.client.timeouts.count{client}
http.client.retries.count{client}
http.client.circuit_breaker.state{client}

Tracing:

span name: HTTP GET /orders/{id}
attributes:
  http.method
  http.route or sanitized path template
  http.status_code
  peer.service / server.address
  retry.count if applicable

Avoid high-cardinality labels:

  • raw URL with IDs
  • customer ID
  • tenant ID unless explicitly approved
  • quote ID
  • order ID
  • full error message

Logs:

  • log one structured event for failed dependency call
  • include correlation ID and downstream client name
  • do not log token, secret, full PII payload
  • sample noisy failures if needed

16. Retrofit vs OpenFeign: Practical Comparison

DimensionRetrofitOpenFeign
Mental modelInterface + Retrofit builderInterface + Feign builder
TransportCommonly OkHttp-backedPluggable client transport
Encoder/decoderConverter factoriesEncoder/decoder components
Error handlingCall/Response handling or adapter-specificErrorDecoder is central concept
Contract styleRetrofit annotationsFeign native or other contracts
Generated clientOften via OpenAPI generator variantsOften via OpenAPI/Spring Cloud ecosystem variants
Good fitExplicit adapter-owned clientsDeclarative service-to-service clients
Main riskHidden body/converter/call adapter behaviorHidden retry/error decoder/client config behavior

The right question is not:

Which one is better?

The better question:

Which one matches the team's contract governance, resilience library, observability standard, and code generation policy?

17. Placement in JAX-RS Service Architecture

Recommended placement:

resource package
  -> inbound HTTP/JAX-RS only

application/usecase package
  -> orchestration and business use case

domain package
  -> domain model/invariants

integration/client package
  -> Retrofit/Feign interface
  -> adapter
  -> mapper
  -> error decoder
  -> resilience config

Avoid:

Resource method directly calls Feign/Retrofit client.

Why:

  • endpoint becomes coupled to downstream contract
  • difficult to test error mapping
  • retry/fallback policy scattered
  • domain logic becomes transport-aware

18. Failure Modes

Common failure modes:

FailureTypical CauseDetectionMitigation
Wrong URLbad base URL/config404/connection failureconfig validation at startup
Missing timeoutdefault infinite/large timeoutstuck threadsmandatory timeout policy
Connection leakresponse body not consumed/closedpool exhaustionadapter pattern, tests
Wrong decodermapper mismatch500/deserialization errorshared mapper policy
Error body ignoreddecoder only handles success DTOmisleading exceptionserror decoder
Hidden retrylibrary/default retryduplicate loadcentral retry governance
Missing tenant headercontext propagation bugcross-tenant/security failuremandatory interceptor
Missing trace contextinstrumentation gapbroken tracesstandard propagator
Generated DTO leakgenerated model used everywherecouplingadapter mapper
Fan-out explosionloop around client calldownstream overloadbatch/bulkhead/rate limit

19. Debugging Playbook

When outbound declarative client fails, inspect in this order:

  1. What interface method was called?
  2. What base URL was resolved?
  3. What final method/path/query/header/body was sent?
  4. Was the request encoded correctly?
  5. Was auth/tenant/trace/correlation header injected?
  6. Did the request leave the process?
  7. Did DNS/TLS/connection fail before HTTP response?
  8. What status code came back?
  9. Was response body decoded or error-decoded?
  10. Was retry attempted?
  11. Was timeout from client, gateway, mesh, or downstream?
  12. Did circuit breaker/bulkhead/rate limit affect the call?
  13. Is there a downstream trace span?
  14. Is the failure isolated to one tenant/environment?

20. PR Review Checklist

Review declarative HTTP client changes for:

  • interface name and package placement
  • HTTP method semantics
  • path/version compatibility
  • query/header/body annotation correctness
  • content type and accept headers
  • DTO compatibility
  • encoder/decoder configuration
  • error decoder behavior
  • timeout policy
  • retry policy
  • resilience wrapper
  • request interceptors
  • auth/tenant/correlation/trace propagation
  • idempotency key if operation mutates state
  • request signing if required
  • generated client drift
  • observability metrics/tracing/logging
  • tests for success, 4xx, 5xx, timeout, malformed body
  • integration test against mock/downstream contract

21. Internal Verification Checklist

For CSG Quote & Order or any enterprise codebase, verify rather than assume:

  • Is Retrofit used?
  • Is OpenFeign used?
  • Is Jersey Client used instead?
  • Are clients generated from OpenAPI?
  • Which transport is underneath declarative clients?
  • Where are base URLs configured?
  • Are timeouts mandatory?
  • Is there a platform standard for retry/circuit breaker?
  • Is Resilience4j or equivalent used?
  • Are request interceptors standardized?
  • How are tenant headers propagated?
  • How are correlation/trace headers propagated?
  • Is error mapping centralized?
  • Are downstream contracts versioned?
  • Are generated DTOs allowed outside adapter layer?
  • Are outbound client metrics standardized?
  • Are sensitive headers/body fields redacted?
  • Is there a local mock/stub strategy?

22. Senior Takeaway

Retrofit and OpenFeign are not just convenience libraries.

They are abstraction layers over remote failure.

The senior-level concern is not whether an interface looks clean.

The concern is whether the hidden runtime behavior is:

  • explicit
  • testable
  • observable
  • bounded by timeouts
  • protected by resilience policy
  • compatible with contract governance
  • safe for tenant/security context
  • isolated from domain logic
  • easy to debug during incident

A declarative client is production-ready only when the team can answer:

What HTTP request is actually sent?
What happens when it fails?
Who owns the contract?
How is it observed?
How is it safely changed?
Lesson Recap

You just completed lesson 81 in deepen practice. 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.