Microservices Architecture & Design

Cross-Cutting Concerns

18 min Lesson 8 of 12

Cross-Cutting Concerns

In a monolith you can solve problems like centralised configuration, request tracing and audit logging once and have every part of the application benefit automatically. In a microservices system you have tens or hundreds of independently deployed processes, each written by a different team. Without deliberate design, each service reinvents its own wheel — or, worse, silently omits security controls, produces logs that cannot be correlated, or exposes configuration secrets inside container images. Cross-cutting concerns are the capabilities that every service needs but that belong to no single service. This lesson covers the four that bite production systems hardest: centralised configuration, structured distributed logging, propagated security context, and end-to-end distributed tracing.

Centralised Configuration with Spring Cloud Config

Hard-coding environment-specific values inside a service binary violates the Twelve-Factor App principle "store config in the environment." Spring Cloud Config Server externalises configuration into a Git repository (or Vault, S3, etc.) and serves it over HTTP to every client service at startup and — critically — at runtime via a refresh signal.

Add the server dependency to a dedicated config-server Spring Boot application:

<!-- pom.xml of the config-server app --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-config-server</artifactId> </dependency>
// ConfigServerApplication.java @SpringBootApplication @EnableConfigServer public class ConfigServerApplication { public static void main(String[] args) { SpringApplication.run(ConfigServerApplication.class, args); } }
# application.yml of the config-server server: port: 8888 spring: cloud: config: server: git: uri: https://github.com/acme/microservices-config default-label: main clone-on-start: true

Client services declare their identity and the config-server location in bootstrap.yml (or via environment variables for containers). The server resolves {application}/{profile}/{label} to a file in the Git repo:

# bootstrap.yml of order-service spring: application: name: order-service # resolves order-service.yml in the repo config: import: "configserver:http://config-server:8888" cloud: config: fail-fast: true # crash at startup if config is unavailable retry: max-attempts: 6 initial-interval: 2000
Never store plain-text secrets in Git. Use Spring Cloud Config encryption (encrypt.key) to store cipher text in the repo, or back the Config Server with HashiCorp Vault (spring.cloud.config.server.vault.*). A leaked Git history exposes every secret to anyone who clones the repository.

To push a config change to running services without a restart, POST to the /actuator/refresh endpoint of each client service (or use Spring Cloud Bus with a message broker to broadcast a single signal to all instances).

Structured Distributed Logging

A request that touches five services produces five separate log lines scattered across five log files. Without a correlation ID and structured fields you cannot join them. The industry solution is: emit JSON logs with a shared traceId field and ship them to a centralised store (ELK, Loki, Datadog, etc.).

Spring Boot 3 ships with Logback. Configure it to produce JSON and include Micrometer Tracing's MDC fields automatically:

<!-- pom.xml --> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-tracing-bridge-brave</artifactId> </dependency> <dependency> <groupId>net.logstash.logback</groupId> <artifactId>logstash-logback-encoder</artifactId> <version>7.4</version> </dependency>
<!-- logback-spring.xml --> <configuration> <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender"> <encoder class="net.logstash.logback.encoder.LogstashEncoder"> <!-- Micrometer Tracing writes traceId/spanId into MDC automatically --> <includeMdcKeyName>traceId</includeMdcKeyName> <includeMdcKeyName>spanId</includeMdcKeyName> </encoder> </appender> <root level="INFO"> <appender-ref ref="JSON"/> </root> </configuration>

Each log line now looks like:

{ "@timestamp": "2026-06-08T14:22:01.123Z", "level": "INFO", "logger_name": "com.acme.order.OrderService", "message": "Order 99 created for customer 42", "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", "spanId": "00f067aa0ba902b7", "service": "order-service" }
Add business context to MDC at service boundaries. A filter or Spring AOP advice can push userId, tenantId, and requestId into MDC.put() at the start of each request so every log line within that request carries them automatically. Clear MDC in a finally block to avoid leaking context across thread-pool reuse.

Propagating Security Context Across Services

When service A calls service B, B needs to know who the original user is so it can apply its own authorisation rules and produce correct audit logs. The standard mechanism in REST-based microservices is to pass a signed JWT (JSON Web Token) in the Authorization: Bearer <token> header of every inter-service HTTP call.

Each service configures Spring Security 6 as a resource server that validates the JWT locally using the Auth Server's public key (or JWKS endpoint):

// SecurityConfig.java (same pattern in every service) @Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { @Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/actuator/health").permitAll() .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt.jwkSetUri( "https://auth-server/.well-known/jwks.json" )) ); return http.build(); } }
# application.yml — resource server JWT config spring: security: oauth2: resourceserver: jwt: jwk-set-uri: https://auth-server/.well-known/jwks.json issuer-uri: https://auth-server

When service A calls service B using RestTemplate or WebClient, it forwards the same JWT from the incoming request:

// ExchangeTokenFilter.java — servlet filter that stores the raw token @Component public class BearerTokenRelay implements ExchangeFilterFunction { @Override public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) { // ReactiveSecurityContextHolder provides the JWT in a reactive stack; // for servlet stacks read it from SecurityContextHolder instead return ReactiveSecurityContextHolder.getContext() .map(ctx -> (JwtAuthenticationToken) ctx.getAuthentication()) .map(auth -> auth.getToken().getTokenValue()) .defaultIfEmpty("") .flatMap(token -> { ClientRequest forwarded = ClientRequest.from(request) .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) .build(); return next.exchange(forwarded); }); } }
Never mint new tokens inside a service to call downstream services. This hides the original user identity from the downstream service's audit log and breaks accountability chains. Forward the original JWT; if its lifetime is too short for long-running workflows, use OAuth 2.0 token exchange (RFC 8693) to obtain a scoped downstream token that still carries the original subject claim.

Distributed Tracing with Micrometer Tracing & Zipkin

Logs tell you what happened in a single service. Distributed tracing tells you how a single user request flowed across every service, with timing at each hop. A trace is the entire journey; a span is one unit of work within that journey. Every service that participates in the same request shares the same traceId but creates its own child spanId.

Spring Boot 3 uses Micrometer Tracing as an abstraction over Brave (Zipkin) or OpenTelemetry:

<!-- pom.xml --> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-tracing-bridge-brave</artifactId> </dependency> <dependency> <groupId>io.zipkin.reporter2</groupId> <artifactId>zipkin-reporter-brave</artifactId> </dependency>
# application.yml — tracing config management: tracing: sampling: probability: 1.0 # 100% in dev; use 0.1 (10%) in production zipkin: tracing: endpoint: http://zipkin:9411/api/v2/spans spring: application: name: order-service # shows up as the service name in Zipkin UI

Spring Boot auto-instruments incoming HTTP requests, outgoing RestTemplate/WebClient calls, and @Async threads. For custom business operations wrap them in a span manually:

@Service public class InventoryClient { private final Tracer tracer; private final WebClient webClient; public InventoryClient(Tracer tracer, WebClient.Builder builder) { this.tracer = tracer; this.webClient = builder.baseUrl("http://inventory-service").build(); } public Mono<Integer> checkStock(Long productId) { Span span = tracer.nextSpan().name("inventory.checkStock").start(); try (Tracer.SpanInScope ws = tracer.withSpan(span)) { span.tag("product.id", String.valueOf(productId)); return webClient.get() .uri("/api/stock/{id}", productId) .retrieve() .bodyToMono(Integer.class) .doOnError(span::error) .doFinally(sig -> span.end()); } } }
Tracing, logging and metrics are complementary, not alternatives. Tracing shows you the topology of a slow or failed request. Metrics (Prometheus/Micrometer) show you aggregate throughput and error rates. Structured logs carry the business context at each node. Production observability needs all three wired together: the same traceId appears in your logs, your trace UI and — with exemplars — in your Prometheus metrics, so you can pivot between them instantly.

Summary

Cross-cutting concerns require deliberate, consistent implementation across every service in the fleet. Spring Cloud Config centralises externalized configuration and enables zero-downtime updates. Structured JSON logging with shared MDC fields makes log correlation possible at scale. JWT forwarding propagates the security context without re-authenticating at each hop, keeping your authorisation rules accurate and your audit trail complete. Micrometer Tracing stitches individual service logs into a single end-to-end trace that reveals latency and failure hotspots across the entire call graph. Together these four pillars are the operational foundation without which a microservices system becomes impossible to secure, debug, or operate reliably.