P5 — BFF Observability & Testing
P5 — BFF Observability & Testing
Related phases: P1-BFF-Foundations | P2-Spring-Boot-BFF-Stack | P3-BFF-Implementation-Patterns | P4-BFF-Security
Topics produced: Distributed-Tracing | Micrometer | Consumer-Driven-Contract-Testing | WireMock
OBS-01: Distributed Tracing with Micrometer Tracing
Spring Cloud Sleuth Is Dead
Critical: Spring Cloud Sleuth was the tracing solution for Spring Boot 2.x. It is not compatible with Spring Boot 3.x and has been discontinued. In Boot 3.x, the replacement is Micrometer Tracing, which provides a vendor-neutral tracing facade, analogous to what SLF4J does for logging.
Micrometer Tracing ships as part of the broader Micrometer observability stack and integrates directly with Spring Boot 3's auto-configuration. Do not add spring-cloud-sleuth to any Boot 3.x project — the dependency will not resolve from current Spring Cloud BOMs.
See Micrometer for the full concept note.
Dependencies (build.gradle)
dependencies {
// Spring Boot core
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
// Micrometer Tracing — OTel bridge (sends traces as OpenTelemetry)
implementation 'io.micrometer:micrometer-tracing-bridge-otel'
// OpenTelemetry exporter — Zipkin
implementation 'io.opentelemetry:opentelemetry-exporter-zipkin'
// Alternative: Jaeger via OTLP exporter
// implementation 'io.opentelemetry:opentelemetry-exporter-otlp'
// Reactor instrumentation — required for reactive trace propagation
implementation 'io.micrometer:micrometer-tracing'
implementation 'net.ttddyy.observation:datasource-micrometer-spring-boot:1.0.5'
// Metrics
implementation 'io.micrometer:micrometer-registry-prometheus'
// Spring Cloud Gateway (already pulls in reactor-netty)
implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
}application.yml — Complete Tracing Configuration
management:
tracing:
sampling:
probability: 1.0 # 100% sampling in dev; set 0.1 (10%) in prod
zipkin:
tracing:
endpoint: http://localhost:9411/api/v2/spans
endpoints:
web:
exposure:
include: health, info, prometheus, metrics, traces
metrics:
tags:
application: ${spring.application.name}
spring:
application:
name: bff-gatewayFor Jaeger via OTLP, replace the zipkin exporter dependency and set:
management:
otlp:
tracing:
endpoint: http://localhost:4318/v1/tracesAuto-Configuration Behaviour
When micrometer-tracing-bridge-otel is on the classpath, Spring Boot 3 auto-configures:
- A
Tracerbean (Micrometer facade) backed by the OTel SDK - An
ObservationRegistrybean that instrumentsWebClient,WebTestClient, Spring MVC/WebFlux server side - Automatic
traceIdandspanIdinjection into log output (via MDC where possible, via Reactor context in WebFlux — see OBS-04) - HTTP server observations: every incoming request becomes a root span
- HTTP client observations: every
WebClientcall becomes a child span automatically
The BFF does not need manual instrumentation for the happy path — the gateway request and every downstream WebClient call are traced without any application code.
Manual Span Creation
For custom business logic (e.g., aggregation steps) inject the Tracer bean:
import io.micrometer.tracing.Span;
import io.micrometer.tracing.Tracer;
import reactor.core.publisher.Mono;
@Service
public class DashboardAggregationService {
private final Tracer tracer;
private final UserServiceClient userClient;
private final OrderServiceClient orderClient;
public DashboardAggregationService(Tracer tracer,
UserServiceClient userClient,
OrderServiceClient orderClient) {
this.tracer = tracer;
this.userClient = userClient;
this.orderClient = orderClient;
}
public Mono<DashboardResponse> getDashboard(String userId) {
Span aggregationSpan = tracer.nextSpan()
.name("bff.dashboard.aggregation")
.tag("userId", userId)
.start();
return Mono.zip(
userClient.getUser(userId),
orderClient.getOrders(userId)
)
.map(tuple -> new DashboardResponse(tuple.getT1(), tuple.getT2()))
.doFinally(signal -> aggregationSpan.end())
.contextWrite(context ->
// Propagate the span into the Reactor context
context.put(Span.class, aggregationSpan)
);
}
}W3C Trace Context Headers
The W3C Trace Context specification defines two headers:
traceparent:00-<traceId>-<parentSpanId>-<flags>(e.g.,00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01)tracestate: vendor-specific additional state (optional, often empty)
When an Angular SPA sends a request to the BFF with a traceparent header, Spring Cloud Gateway (with Micrometer Tracing on the classpath) automatically:
- Reads the incoming
traceparentand extracts the parent trace/span IDs - Creates a child span belonging to that same trace
- Forwards a new
traceparent(with the BFF's span as parent) to downstream services
This means an Angular-originated trace can span: Angular -> BFF -> User Service -> Order Service, all under a single traceId, visible as one waterfall in Zipkin/Jaeger.
OBS-02: Trace Context Propagation from Angular
Angular HttpInterceptor for traceparent
The BFF relies on Angular adding a traceparent header so that end-to-end traces start from the browser. Install the W3C Trace Context interceptor:
// trace-context.interceptor.ts
import { Injectable } from '@angular/core';
import {
HttpRequest,
HttpHandler,
HttpEvent,
HttpInterceptor
} from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable()
export class TraceContextInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
// Generate W3C traceparent: 00-<32 hex traceId>-<16 hex parentId>-01
const traceId = this.generateHex(32);
const spanId = this.generateHex(16);
const traceparent = `00-${traceId}-${spanId}-01`;
const cloned = req.clone({
headers: req.headers.set('traceparent', traceparent)
});
return next.handle(cloned);
}
private generateHex(length: number): string {
return Array.from(crypto.getRandomValues(new Uint8Array(length / 2)))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
}Register in app.module.ts (or providers array in standalone apps):
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: TraceContextInterceptor, multi: true }
]Correlation ID GlobalFilter
The correlation ID is a simpler application-level identifier (UUID) complementing the distributed trace ID. While traceId is managed by the tracing infrastructure, X-Correlation-ID is an explicit header that non-tracing-aware services and logs can use for request correlation.
package com.example.bff.filter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.UUID;
@Component
public class CorrelationIdFilter implements GlobalFilter, Ordered {
public static final String CORRELATION_ID_HEADER = "X-Correlation-ID";
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String correlationId = exchange.getRequest().getHeaders()
.getFirst(CORRELATION_ID_HEADER);
if (correlationId == null || correlationId.isBlank()) {
correlationId = UUID.randomUUID().toString();
}
final String finalCorrelationId = correlationId;
ServerWebExchange mutatedExchange = exchange.mutate()
.request(r -> r.header(CORRELATION_ID_HEADER, finalCorrelationId))
// Add to response so the Angular client can log it
.build();
// Add to response headers after chain completes
mutatedExchange.getResponse().getHeaders()
.add(CORRELATION_ID_HEADER, finalCorrelationId);
// Store in Reactor context for MDC propagation (see OBS-04)
return chain.filter(mutatedExchange)
.contextWrite(ctx -> ctx.put(CORRELATION_ID_HEADER, finalCorrelationId));
}
@Override
public int getOrder() {
// Run before all other filters — negative order means early
return -1;
}
}How Gateway Propagates Trace Headers
Spring Cloud Gateway, when Micrometer Tracing is present, uses HttpClientObservationConvention and the OTel W3CPropagator to inject traceparent and tracestate into every proxied request automatically. No manual header copying is required for the W3C headers — only the application-level X-Correlation-ID needs the GlobalFilter.
OBS-03: Metrics with Micrometer + Prometheus
What to Measure in a BFF
A BFF has distinct metric categories not present in a typical microservice:
| Metric | Type | Description |
|---|---|---|
http.server.requests | Timer | Auto: per-route latency, status codes, throughput |
spring.cloud.gateway.requests | Timer | SCG: route-level metrics (route ID tag) |
resilience4j.circuitbreaker.calls | Counter | CB state transitions and call outcomes |
resilience4j.circuitbreaker.state | Gauge | Current CB state (0=CLOSED, 1=OPEN, 2=HALF_OPEN) |
bff.aggregation.calls | Counter | Custom: how often each aggregation runs |
bff.aggregation.latency | Timer | Custom: end-to-end aggregation time |
bff.downstream.errors | Counter | Custom: downstream service error rate by service |
Actuator Configuration for Prometheus
management:
endpoints:
web:
exposure:
include: health, info, prometheus, metrics
endpoint:
health:
show-details: when-authorized
probes:
enabled: true # /actuator/health/liveness and /readiness
metrics:
export:
prometheus:
enabled: true
tags:
application: ${spring.application.name}
environment: ${spring.profiles.active:default}The Prometheus endpoint is available at /actuator/prometheus. Point a Prometheus scrape job at it:
# prometheus.yml scrape config
scrape_configs:
- job_name: 'bff-gateway'
metrics_path: '/actuator/prometheus'
static_configs:
- targets: ['bff-host:8080']
scrape_interval: 15sCustom Metrics with MeterRegistry
package com.example.bff.service;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
@Service
public class DashboardAggregationService {
private final Counter aggregationCalls;
private final Timer aggregationTimer;
private final Counter downstreamErrors;
public DashboardAggregationService(MeterRegistry registry) {
this.aggregationCalls = registry.counter(
"bff.aggregation.calls",
"type", "dashboard"
);
this.aggregationTimer = Timer.builder("bff.aggregation.latency")
.tag("type", "dashboard")
.description("End-to-end latency for dashboard aggregation")
.publishPercentiles(0.5, 0.95, 0.99)
.register(registry);
this.downstreamErrors = registry.counter(
"bff.downstream.errors",
"service", "dashboard"
);
}
public Mono<DashboardResponse> getDashboard(String userId) {
aggregationCalls.increment();
long start = System.nanoTime();
return doAggregate(userId)
.doOnSuccess(r -> aggregationTimer.record(
Duration.ofNanos(System.nanoTime() - start)
))
.doOnError(e -> {
downstreamErrors.increment();
aggregationTimer.record(
Duration.ofNanos(System.nanoTime() - start)
);
});
}
private Mono<DashboardResponse> doAggregate(String userId) {
// actual aggregation logic
return Mono.empty();
}
}Grafana Dashboard — Key Panels
A useful BFF Grafana dashboard has these panels:
- Request Rate —
rate(http_server_requests_seconds_count[1m])grouped byuri - Error Rate —
rate(http_server_requests_seconds_count{status=~"5.."}[1m])/ total - Latency P95 —
histogram_quantile(0.95, rate(http_server_requests_seconds_bucket[5m])) - Gateway Route Latency —
rate(spring_cloud_gateway_requests_seconds_sum[1m])byrouteId - Circuit Breaker State —
resilience4j_circuitbreaker_state(stat panel showing OPEN/CLOSED) - Aggregation Latency P99 —
histogram_quantile(0.99, rate(bff_aggregation_latency_seconds_bucket[5m])) - Downstream Error Rate —
rate(bff_downstream_errors_total[5m])byservice - JVM Heap —
jvm_memory_used_bytes{area="heap"}(standard)
OBS-04: Structured Logging
The WebFlux MDC Problem
In a traditional thread-per-request model (Spring MVC), MDC works by storing values in a ThreadLocal. Every log statement on that thread automatically includes the stored traceId, correlationId, etc.
WebFlux uses a small thread pool and multiple requests share threads. A reactive operator like .flatMap() may execute on a different thread than where the request started. This means ThreadLocal-based MDC values are lost at thread hops.
Solution: Store logging context in the Reactor Context (which travels with the reactive chain) and extract it at each log point using Hooks.onEachOperator to copy Reactor context values into MDC before each operator executes.
Dependencies
// Structured JSON logging
implementation 'net.logstash.logback:logstash-logback-encoder:7.4'logback-spring.xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<springProfile name="!local">
<!-- JSON output for production / staging -->
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>traceId</includeMdcKeyName>
<includeMdcKeyName>spanId</includeMdcKeyName>
<includeMdcKeyName>correlationId</includeMdcKeyName>
<customFields>{"application":"bff-gateway"}</customFields>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="JSON" />
</root>
</springProfile>
<springProfile name="local">
<!-- Human-readable for local dev -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss} [%thread] %highlight(%-5level) %cyan(%logger{36}) [%X{traceId}/%X{correlationId}] - %msg%n</pattern>
</encoder>
</appender>
<root level="DEBUG">
<appender-ref ref="CONSOLE" />
</root>
</springProfile>
</configuration>This produces JSON like:
{
"timestamp": "2026-03-07T14:23:01.234Z",
"level": "INFO",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"spanId": "00f067aa0ba902b7",
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"logger": "c.e.bff.service.DashboardAggregationService",
"message": "Aggregating dashboard for userId=42"
}MDC Propagation in Reactive Pipelines
The MdcContextLifter pattern uses Hooks.onEachOperator to push Reactor context values into MDC before each operator runs, then clean up after:
package com.example.bff.logging;
import org.reactivestreams.Subscription;
import org.slf4j.MDC;
import reactor.core.CoreSubscriber;
import reactor.core.publisher.Hooks;
import reactor.core.publisher.Operators;
import reactor.util.context.Context;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Propagates Reactor Context entries with key prefix "mdc." into SLF4J MDC
* so that structured log output includes traceId, spanId, correlationId.
*
* Register by calling MdcContextLifter.init() from a @PostConstruct or
* ApplicationRunner bean.
*/
public class MdcContextLifter<T> implements CoreSubscriber<T> {
public static final String MDC_CONTEXT_REACTOR_KEY = MdcContextLifter.class.getName();
private final CoreSubscriber<T> coreSubscriber;
public MdcContextLifter(CoreSubscriber<T> coreSubscriber) {
this.coreSubscriber = coreSubscriber;
}
@Override
public Context currentContext() {
return coreSubscriber.currentContext();
}
@Override
public void onSubscribe(Subscription s) {
coreSubscriber.onSubscribe(s);
}
@Override
public void onNext(T t) {
copyToMdc(coreSubscriber.currentContext());
try {
coreSubscriber.onNext(t);
} finally {
MDC.clear();
}
}
@Override
public void onError(Throwable t) {
copyToMdc(coreSubscriber.currentContext());
try {
coreSubscriber.onError(t);
} finally {
MDC.clear();
}
}
@Override
public void onComplete() {
copyToMdc(coreSubscriber.currentContext());
try {
coreSubscriber.onComplete();
} finally {
MDC.clear();
}
}
private void copyToMdc(Context context) {
if (context.isEmpty()) return;
Map<String, String> mdcEntries = context.stream()
.filter(e -> e.getKey() instanceof String s && s.startsWith("mdc."))
.collect(Collectors.toMap(
e -> ((String) e.getKey()).substring(4), // strip "mdc." prefix
e -> String.valueOf(e.getValue())
));
MDC.setContextMap(mdcEntries);
}
public static void init() {
Hooks.onEachOperator(
MDC_CONTEXT_REACTOR_KEY,
Operators.lift((scannable, subscriber) ->
new MdcContextLifter<>(subscriber)
)
);
}
public static void reset() {
Hooks.resetOnEachOperator(MDC_CONTEXT_REACTOR_KEY);
}
}Register it in a @Configuration class:
package com.example.bff.config;
import com.example.bff.logging.MdcContextLifter;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ReactorMdcConfig {
@PostConstruct
public void init() {
MdcContextLifter.init();
}
@PreDestroy
public void cleanup() {
MdcContextLifter.reset();
}
}Then in filters, write into Reactor context with the mdc. prefix:
return chain.filter(mutatedExchange)
.contextWrite(ctx -> ctx
.put("mdc.correlationId", finalCorrelationId)
.put("mdc.userId", resolvedUserId)
);Micrometer Tracing automatically writes mdc.traceId and mdc.spanId into the Reactor context when using the OTel bridge, so those appear in logs without extra wiring.
TEST-01: Testing Strategy for a BFF
A BFF has three distinct testing layers, each testing different concerns.
Layer 1 — Unit Tests (Isolated Filter and Service Logic)
Unit tests validate individual components in complete isolation. The key challenge is testing reactive code — use StepVerifier from reactor-test.
Testing a GlobalFilter:
package com.example.bff.filter;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class CorrelationIdFilterTest {
private final CorrelationIdFilter filter = new CorrelationIdFilter();
@Test
void shouldGenerateCorrelationIdWhenAbsent() {
MockServerHttpRequest request = MockServerHttpRequest
.get("/api/dashboard/42")
.build();
MockServerWebExchange exchange = MockServerWebExchange.from(request);
GatewayFilterChain chain = mock(GatewayFilterChain.class);
when(chain.filter(any())).thenReturn(Mono.empty());
StepVerifier.create(filter.filter(exchange, chain))
.verifyComplete();
// The mutated exchange passed to chain.filter() should have the header
// Verify via ArgumentCaptor
}
@Test
void shouldPreserveExistingCorrelationId() {
String existingId = "existing-correlation-id";
MockServerHttpRequest request = MockServerHttpRequest
.get("/api/dashboard/42")
.header(CorrelationIdFilter.CORRELATION_ID_HEADER, existingId)
.build();
MockServerWebExchange exchange = MockServerWebExchange.from(request);
GatewayFilterChain chain = mock(GatewayFilterChain.class);
when(chain.filter(any())).thenAnswer(inv -> {
ServerWebExchange mutated = inv.getArgument(0);
String id = mutated.getRequest().getHeaders()
.getFirst(CorrelationIdFilter.CORRELATION_ID_HEADER);
assertThat(id).isEqualTo(existingId);
return Mono.empty();
});
StepVerifier.create(filter.filter(exchange, chain))
.verifyComplete();
}
}Testing reactive aggregation with StepVerifier:
class DashboardAggregationServiceTest {
private final UserServiceClient userClient = mock(UserServiceClient.class);
private final OrderServiceClient orderClient = mock(OrderServiceClient.class);
private final MeterRegistry registry = new SimpleMeterRegistry();
private final Tracer tracer = mock(Tracer.class);
private final DashboardAggregationService service =
new DashboardAggregationService(tracer, userClient, orderClient, registry);
@Test
void shouldAggregateUserAndOrders() {
UserResponse user = new UserResponse(42L, "Jane Doe", "jane@example.com");
List<OrderResponse> orders = List.of(new OrderResponse("ord-1", 99.99));
when(userClient.getUser("42")).thenReturn(Mono.just(user));
when(orderClient.getOrders("42")).thenReturn(Flux.fromIterable(orders));
StepVerifier.create(service.getDashboard("42"))
.assertNext(dashboard -> {
assertThat(dashboard.user().name()).isEqualTo("Jane Doe");
assertThat(dashboard.orders()).hasSize(1);
assertThat(dashboard.orders().get(0).id()).isEqualTo("ord-1");
})
.verifyComplete();
}
@Test
void shouldPropagateErrorWhenUserServiceFails() {
when(userClient.getUser("42")).thenReturn(
Mono.error(new RuntimeException("User service unavailable"))
);
StepVerifier.create(service.getDashboard("42"))
.verifyError(RuntimeException.class);
}
}Layer 2 — Integration Tests (Full BFF Stack with WireMock)
Integration tests spin up the full BFF application context against WireMock stubs for all downstream services. See TEST-02 for full WireMock examples.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test") // disables Redis session, relaxes security
@WireMockTest(httpPort = 8090)
class DashboardIntegrationTest {
@Autowired
private WebTestClient webTestClient;
// test methods — see TEST-02
}The test profile sets downstream base URLs to http://localhost:8090 (WireMock port) via application-test.yml:
# src/test/resources/application-test.yml
services:
user-service:
base-url: http://localhost:8090
order-service:
base-url: http://localhost:8090
spring:
session:
store-type: none # disable Redis in tests
security:
oauth2:
client:
registration: {} # disable OAuth2 for basic testsLayer 3 — Contract Tests
Consumer-driven contract tests (Spring Cloud Contract) provide a formal API agreement between the BFF (consumer) and each downstream service (provider). See TEST-03 for full examples.
TEST-02: WireMock for Downstream Stubbing
Library Choice for Spring Boot 3
Use wiremock-spring-boot (from the WireMock project) — not the legacy WireMockRule (JUnit 4) or WireMockServer manual lifecycle management. The @WireMockTest annotation handles lifecycle automatically.
// build.gradle
testImplementation 'org.wiremock.integrations:wiremock-spring-boot:3.5.4'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.projectreactor:reactor-test'Complete Integration Test
package com.example.bff.integration;
import com.github.tomakehurst.wiremock.client.WireMock;
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.reactive.server.WebTestClient;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
@ActiveProfiles("test")
@WireMockTest(httpPort = 8090)
class DashboardAggregationIntegrationTest {
@Autowired
private WebTestClient webTestClient;
@Test
void shouldAggregateUserAndOrders(WireMockRuntimeInfo wmRuntimeInfo) {
// Arrange — stub downstream services
stubFor(get(urlEqualTo("/users/42"))
.willReturn(okJson("""
{
"id": 42,
"name": "Jane Doe",
"email": "jane@example.com"
}
""")));
stubFor(get(urlPathEqualTo("/orders"))
.withQueryParam("userId", equalTo("42"))
.willReturn(okJson("""
[
{"id": "ord-1", "total": 99.99, "status": "DELIVERED"},
{"id": "ord-2", "total": 49.50, "status": "PROCESSING"}
]
""")));
// Act + Assert
webTestClient.get()
.uri("/api/dashboard/42")
.cookie("SESSION", "test-session-id")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.user.name").isEqualTo("Jane Doe")
.jsonPath("$.user.email").isEqualTo("jane@example.com")
.jsonPath("$.orders").isArray()
.jsonPath("$.orders[0].id").isEqualTo("ord-1")
.jsonPath("$.orders[1].id").isEqualTo("ord-2");
}
@Test
void shouldReturnCorrelationIdInResponseHeader() {
stubFor(get(urlEqualTo("/users/99")).willReturn(okJson("""
{"id": 99, "name": "John", "email": "john@example.com"}
""")));
stubFor(get(urlPathEqualTo("/orders"))
.withQueryParam("userId", equalTo("99"))
.willReturn(okJson("[]")));
webTestClient.get()
.uri("/api/dashboard/99")
.header("X-Correlation-ID", "my-custom-correlation-id")
.exchange()
.expectStatus().isOk()
.expectHeader().valueEquals("X-Correlation-ID", "my-custom-correlation-id");
}
@Test
void shouldHandleDownstreamUserServiceFailure() {
stubFor(get(urlEqualTo("/users/404"))
.willReturn(notFound().withBody("""
{"error": "User not found", "id": 404}
""")));
webTestClient.get()
.uri("/api/dashboard/404")
.exchange()
.expectStatus().isNotFound();
}
@Test
void shouldTriggerCircuitBreakerAfterRepeatedFailures() {
// Stub to always fail
stubFor(get(urlEqualTo("/users/500"))
.willReturn(serverError()));
// First few calls should fail with 502/503
for (int i = 0; i < 5; i++) {
webTestClient.get()
.uri("/api/dashboard/500")
.exchange()
.expectStatus().is5xxServerError();
}
// After circuit opens, subsequent calls return fallback (if configured)
webTestClient.get()
.uri("/api/dashboard/500")
.exchange()
.expectStatus().isEqualTo(503);
}
}Verifying Downstream Was Called Correctly
WireMock's verify API confirms the BFF forwarded required headers downstream:
@Test
void shouldForwardAuthorizationHeaderToDownstream() {
stubFor(get(urlEqualTo("/users/42"))
.willReturn(okJson("""{"id": 42, "name": "Jane"}""")));
stubFor(get(urlPathEqualTo("/orders"))
.withQueryParam("userId", equalTo("42"))
.willReturn(okJson("[]")));
webTestClient.get()
.uri("/api/dashboard/42")
.header("Authorization", "Bearer eyJhbGciOiJSUzI1NiJ9.test.token")
.exchange()
.expectStatus().isOk();
// Verify downstream received the Authorization header via Token Relay
verify(getRequestedFor(urlEqualTo("/users/42"))
.withHeader("Authorization", containing("Bearer")));
// Verify correlation ID was forwarded
verify(getRequestedFor(urlEqualTo("/users/42"))
.withHeader("X-Correlation-ID", matching(".+")));
}Simulating Slow Downstreams (Latency Testing)
@Test
void shouldTimeoutWhenDownstreamIsSlow() {
stubFor(get(urlEqualTo("/users/42"))
.willReturn(okJson("""{"id": 42}""")
.withFixedDelay(6000) // 6 seconds — beyond the 5s timeout
));
webTestClient.get()
.uri("/api/dashboard/42")
.exchange()
.expectStatus().isEqualTo(504); // Gateway Timeout
}TEST-03: Spring Cloud Contract — Consumer-Driven
Concept
Consumer-driven contract testing (see Consumer-Driven-Contract-Testing) works as follows:
- The BFF team (consumer) writes contracts describing what it expects from downstream APIs
- Contracts are stored in the BFF's source repo under
src/test/resources/contracts/ - Spring Cloud Contract generates:
- WireMock stubs from contracts (used in BFF integration tests)
- Provider verification tests that the downstream team runs against their service
- If the downstream changes its API in a breaking way, the provider verification tests fail
This inverts the traditional "provider publishes API docs" workflow — the consumer specifies what it needs.
BFF as Consumer — Dependencies
// BFF build.gradle
testImplementation 'org.springframework.cloud:spring-cloud-starter-contract-stub-runner'
// On the downstream provider service:
// testImplementation 'org.springframework.cloud:spring-cloud-starter-contract-verifier'
// testImplementation 'org.springframework.cloud:spring-cloud-contract-stub-runner'Writing Contracts (Groovy DSL)
Contracts live in src/test/resources/contracts/<service-name>/:
src/test/resources/contracts/user-service/get-user-by-id.groovy:
import org.springframework.cloud.contract.spec.Contract
Contract.make {
description "BFF gets user by ID — returns 200 with user object"
request {
method GET()
url "/users/42"
headers {
header("Authorization", matching("Bearer .+"))
header("Accept", "application/json")
}
}
response {
status OK()
headers {
contentType applicationJson()
}
body([
id : 42,
name : "Jane Doe",
email: "jane@example.com"
])
bodyMatchers {
jsonPath('$.id', byEquality())
jsonPath('$.name', byRegex('[A-Za-z ]+'))
jsonPath('$.email', byRegex('.+@.+\\..+'))
}
}
}src/test/resources/contracts/user-service/get-user-not-found.groovy:
import org.springframework.cloud.contract.spec.Contract
Contract.make {
description "BFF requests non-existent user — provider returns 404"
request {
method GET()
url "/users/99999"
headers {
header("Authorization", matching("Bearer .+"))
}
}
response {
status NOT_FOUND()
headers {
contentType applicationJson()
}
body([
error: "User not found",
id : 99999
])
}
}src/test/resources/contracts/order-service/get-orders-for-user.groovy:
import org.springframework.cloud.contract.spec.Contract
Contract.make {
description "BFF gets orders for a user"
request {
method GET()
urlPath("/orders") {
queryParameters {
parameter("userId", "42")
}
}
headers {
header("Authorization", matching("Bearer .+"))
}
}
response {
status OK()
headers {
contentType applicationJson()
}
body([
[
id : "ord-1",
total : 99.99,
status: "DELIVERED"
]
])
bodyMatchers {
jsonPath('$[0].id', byRegex('[a-z0-9-]+'))
jsonPath('$[0].total', byRegex('[0-9]+\\.[0-9]+'))
jsonPath('$[0].status', byRegex('DELIVERED|PROCESSING|CANCELLED'))
}
}
}Using Contract-Generated Stubs in BFF Tests
Spring Cloud Contract generates WireMock stub JARs that the BFF can load via @AutoConfigureStubRunner:
package com.example.bff.contract;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cloud.contract.stubrunner.spring.AutoConfigureStubRunner;
import org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.reactive.server.WebTestClient;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
@ActiveProfiles("test")
@AutoConfigureStubRunner(
// Pull stubs from local Maven repo (after downstream builds and publishes stub JAR)
stubsMode = StubRunnerProperties.StubsMode.LOCAL,
ids = {
"com.example:user-service:+:stubs:8091",
"com.example:order-service:+:stubs:8092"
}
)
class DashboardContractTest {
@Autowired
private WebTestClient webTestClient;
@Test
void shouldAggregateUsingContractStubs() {
// The stub runner starts WireMock on 8091/8092 with the contract-generated stubs.
// application-test.yml must point user-service to localhost:8091,
// order-service to localhost:8092.
webTestClient.get()
.uri("/api/dashboard/42")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.user.name").isEqualTo("Jane Doe")
.jsonPath("$.orders[0].id").isEqualTo("ord-1");
}
}Provider Verification (Downstream Side)
The downstream user-service adds this to its test suite — Spring Cloud Contract auto-generates the test class from the consumer's contract:
// Generated by Spring Cloud Contract — do not edit
// user-service/src/test/java/com/example/user/contract/UserContractVerificationTest.java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class UserContractVerificationTest extends ContractVerifierBase {
// Spring Cloud Contract generates test methods like:
// void validate_get_user_by_id() — sends GET /users/42 and verifies the response
// void validate_get_user_not_found() — sends GET /users/99999 and verifies 404
}The base class ContractVerifierBase sets up RestAssured (or WebTestClient for reactive providers) against the running application.
Contract Workflow in CI
BFF Repo User Service Repo
─────────────────────────────── ─────────────────────────────────
1. BFF writes contracts 3. Stubs published to artifact
in src/test/resources/ registry after user-service CI
contracts/user-service/
4. Provider verification tests run
2. BFF CI generates stubs, (contract sent from BFF repo
runs BFF integration tests or artifact registry)
using those stubs
5. If verification fails → CI blocks
user-service deploy
Summary Table
| Area | Tool / Approach | Spring Boot 3 Note |
|---|---|---|
| Distributed tracing | Micrometer Tracing + OTel | Replaces Spring Cloud Sleuth (Boot 2) |
| Trace export | Zipkin or Jaeger via OTLP | Both supported via OTel exporters |
| Trace propagation | W3C Trace Context (traceparent) | Auto-propagated by SCG + Micrometer |
| Metrics | Micrometer + Prometheus | micrometer-registry-prometheus |
| Metrics dashboard | Grafana | Query via PromQL |
| Structured logging | Logback + logstash-logback-encoder | JSON output with MDC fields |
| WebFlux MDC | MdcContextLifter + Hooks.onEachOperator | ThreadLocal MDC does not work in WebFlux |
| Unit testing | JUnit 5 + Mockito + StepVerifier | reactor-test for reactive assertions |
| Integration testing | WireMock (wiremock-spring-boot) + @WireMockTest | Not WireMockRule (JUnit 4) |
| Contract testing | Spring Cloud Contract | Consumer (BFF) writes contracts |
Cross-Links
- BFF-Pattern — architectural foundation
- Spring-Cloud-Gateway — the reactive gateway layer being observed
- Project-Reactor — reactive runtime; MDC propagation challenge originates here
- Distributed-Tracing — concept note
- Micrometer — metrics and tracing facade
- Consumer-Driven-Contract-Testing — contract testing pattern
- WireMock — HTTP stubbing tool
- Resilience4j — circuit breaker metrics appear in Prometheus
- Circuit-Breaker-Pattern — the pattern whose state we expose via metrics