Spring Core with Kotlin: Gold Standard (Phase 1–7)
The Gold Standard curriculum for a Senior Spring Engineer. Phase 1 foundation; Phase 2 web (MVC); Phase 3 data (N+1, propagation, optimistic locking); Phase 4 security (OAuth2, JWT, CORS); Phase 5 async (Coroutines, WebClient, Kafka/Rabbit, Outbox); Phase 6 observability (Actuator, MDC, Micrometer, tracing); Phase 7 architecture—Modular Monolith, packaging by feature, DDD lite, testing pyramid. Architecture is managing complexity and drawing boundaries; know when not to add a library.
Phase 1: DI, Lifecycle, @Configuration vs @Component, Scopes, BeanPostProcessors, Starter. Phase 2: Controller, DTOs, @ControllerAdvice, Filters vs Interceptors, validation, Perfect API. Phase 3: N+1, JOIN FETCH / @EntityGraph, propagation & proxy, @Version, High-Load Optimizer. Phase 4: OAuth2/OIDC, JWT, filter chain, @PreAuthorize, CORS, Secure Vault. Phase 5: Coroutines, WebClient, Kafka/Rabbit, DLQ, Outbox, Reliable Notifier. Phase 6: Actuator, MDC, Micrometer, tracing, Glass Box. Phase 7: Modular Monolith, packaging by feature, DDD lite (rich vs anemic), testing pyramid, Refactor capstone. Course complete.
Topic 1.1: Dependency Injection (The "Constructor Only" Rule)
The rule: never use field injection (@Autowired lateinit var). Why: (1) Immutability — field injection requires var; constructor injection allows val. (2) Null safety — you guarantee the bean exists at creation time. (3) Testing — no ReflectionTestUtils or Spring context; you just new it up.
❌ Junior / Mid (Field Injection)
@Serviceclass UserService {@Autowired // "Magic" injectionlateinit var repo: UserRepository // Mutable, nullable, hard to test}
✅ Senior (Constructor Injection)
@Serviceclass UserService(private val repo: UserRepository // Immutable, non-null, pure Kotlin) {// Spring sees the constructor and injects automatically.// No @Autowired needed in modern Spring.}
Topic 1.2: The Bean Lifecycle (The Pulse of Spring)
To debug complex issues, you must know the order of events:
- Instantiation — Spring calls the constructor.
- Populate properties — Setters/fields are filled.
- BeanPostProcessor (before) — Modify the raw bean instance.
- Initialization —
@PostConstruct,InitializingBean.afterPropertiesSet(). - BeanPostProcessor (after) — Critical: proxies (AOP,
@Transactional) are created here. - Ready — Bean is in the context.
- Destruction —
@PreDestroy.
@Componentclass LifecycleDemo : InitializingBean, DisposableBean {init { println("1. Constructor Running") }@PostConstructfun postConstruct() { println("2. @PostConstruct") }override fun afterPropertiesSet() { println("3. InitializingBean interface") }@PreDestroyfun preDestroy() { println("4. Context Closing / Bean Destroying") }override fun destroy() { println("5. DisposableBean interface") }}
Topic 1.3: The Proxy Trap (@Configuration vs @Component)
Classic "Senior Signal" interview question: Why put @Bean methods inside @Configuration and not just @Component? Answer: inter-bean dependencies.
@Component (Lite Mode): If you call a @Bean method from another @Bean method, you get a new instance. Spring treats it as a normal function call. @Configuration (Full Mode): Spring uses CGLIB to subclass your config. It overrides those methods to check the container first—"Do I already have this bean? Yes? Return the cached one."
@Configurationclass DatabaseConfig {@Beanfun dataSource(): DataSource {return HikariDataSource() // Creates a connection pool}@Beanfun transactionManager(): PlatformTransactionManager {// CALLING THE METHOD ABOVE!// @Configuration: Spring intercepts → returns SAME dataSource.// @Component: you would create TWO connection pools. Disaster.return DataSourceTransactionManager(dataSource())}}
Topic 1.4: Scopes (The Prototype Trap)
Singleton (default): one instance per application. Stateless; thread-safe logic required. Prototype: new instance every time it is injected.
The trap: If you inject a Prototype bean into a Singleton, the prototype behaves like a singleton. The singleton is created once and calls the prototype's constructor once during injection; that instance lives inside the singleton forever. Fix: use ObjectProvider<MyPrototype> or @Lookup method injection when you need a fresh instance each time.
Topic 1.5: Deep Skill — BeanPostProcessors (BPP)
This is how Spring implements @Value, @Autowired, and @Transactional. A BeanPostProcessor lets you modify beans before they enter the container. Use it for infrastructure: e.g. decrypting passwords in properties, wrapping beans in monitoring or logging proxies.
🔥 Phase 1 Challenge: Build Your Own "Starter"
Create a library that, when added to a project, auto-configures a GreetingService bean—only if the user hasn't defined one.
Steps
(1) Configuration class: @ConditionalOnClass(GreetingService::class), @Bean @ConditionalOnMissingBean returning DefaultGreetingService. (2) Register (Spring Boot 3): create META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports and add the full class name, e.g. com.example.starter.GreetingAutoConfiguration. (3) Test: Scenario A — no bean defined, inject GreetingService, expect "Default Hello". Scenario B — define your own @Bean greetingService(); the starter should back off.
@Configuration@ConditionalOnClass(GreetingService::class)class GreetingAutoConfiguration {@Bean@ConditionalOnMissingBeanfun greetingService(): GreetingService {return DefaultGreetingService()}}
Validation: If you can explain exactly why your starter backed off in Scenario B, you've mastered Spring Core configuration precedence.
// Phase 1 Challenge: Build Your Own Starter// 1. GreetingAutoConfiguration + @ConditionalOnMissingBean// 2. META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports// 3. Scenario A: no bean → DefaultGreetingService. Scenario B: user @Bean → starter backs off.//// Register: com.example.starter.GreetingAutoConfiguration
Phase 2: Web Layer (MVC) — Done Correctly
The layer where most developers spend their time—but often do it wrong. Senior mindset: the Controller is a Gateway, not a worker. It should have zero business logic. Its job is Deserialise → Validate → Delegate → Respond.
Topic 2.1: Controller Design & DTOs (The "No Entities" Rule)
The rule: Never return your JPA @Entity (database object) to the frontend. Why: (1) Security — you risk leaking password_hash or version. (2) Coupling — DB schema changes break API clients. (3) Cyclic references — bidirectional relations (e.g. User ↔ Orders) can cause StackOverflowError when serialising to JSON.
Senior pattern: Keep DTOs dumb. Use Kotlin extension functions for mapping. Request DTO → toDomain(); Entity → toResponse(). Controller stays thin.
// 1. Request DTO (what user sends)data class CreateUserRequest(@field:NotBlank val username: String,@field:Email val email: String) {fun toDomain(): User = User(username = username, email = email)}// 2. Response DTO (what user gets)data class UserResponse(val id: String, val username: String, val joinedAt: String)// 3. Extension: Entity -> Responsefun User.toResponse(): UserResponse = UserResponse(id = id.toString(), username = username, joinedAt = createdAt.toString())// 4. Controller (clean)@PostMappingfun createUser(@Valid @RequestBody request: CreateUserRequest): UserResponse {val created = userService.createUser(request.toDomain())return created.toResponse()}
Topic 2.2: Global Error Handling (Stop try-catch)
The rule: Controllers should almost never have try-catch. Let exceptions bubble up. Solution: @ControllerAdvice (or @RestControllerAdvice). Return a standard format—e.g. RFC 7807 (Problem Details), which Spring Boot 3 supports natively.
Handle specific exceptions (e.g. UserNotFoundException → 404) and MethodArgumentNotValidException for validation errors. Extract field paths from bindingResult.fieldErrors and attach them to the problem detail so clients get a map like email → "must be a valid email".
@RestControllerAdviceclass GlobalExceptionHandler {@ExceptionHandler(UserNotFoundException::class)fun handleNotFound(ex: UserNotFoundException): ProblemDetail {return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.message ?: "User not found")}@ExceptionHandler(MethodArgumentNotValidException::class)fun handleValidation(ex: MethodArgumentNotValidException): ProblemDetail {val problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST)problem.title = "Validation Failed"val errors = ex.bindingResult.fieldErrors.associate {it.field to (it.defaultMessage ?: "Invalid")}problem.setProperty("errors", errors)return problem}}
Topic 2.3: Filters vs Interceptors
Common interview question: When do you use a Filter versus an Interceptor?
- Filters (Servlet layer): Run before Spring MVC. They only see raw
HttpServletRequest. Use for: CORS, auth headers, logging raw bytes, compression (GZIP). Blind spot: they don't know which controller method will handle the request. - Interceptors (Spring MVC layer): Run inside
DispatcherServlet. They have access to the Handler (the specific controller method). Use for: permission checks per endpoint, adding model attributes, measuring execution time.
Quick rule: Manipulate the stream (body/headers) → use a Filter. Need application context (beans, handler method) → use an Interceptor.
Topic 2.4: Validation (Bean Validation)
Don't write if (request.email == null). Use Jakarta Validation (@NotBlank, @Email, etc.). Senior tip: use group sequences when you need ordered checks (e.g. "Not null" first, then "database uniqueness").
Don't limit yourself to built-in annotations. Define custom validators for domain rules (e.g. @StrongPassword) with @Constraint and a ConstraintValidator implementation.
@Target(AnnotationTarget.FIELD)@Constraint(validatedBy = [StrongPasswordValidator::class])annotation class StrongPassword(val message: String = "Password must be strong",val groups: Array<KClass<*>> = [],val payload: Array<KClass<out Payload>> = [])class StrongPasswordValidator : ConstraintValidator<StrongPassword, String> {override fun isValid(value: String?, ctx: ConstraintValidatorContext): Boolean {if (value == null) return falsereturn value.length > 8 && value.any { it.isDigit() }}}
🔥 Phase 2 Challenge: The "Perfect" API
Design a Product Catalog API with one endpoint: POST /products.
Requirements
- Validation:
price> 0.skuexactly 8 alphanumeric characters. - Idempotency: Client sends
X-Idempotency-Key. If missing →400 Bad Request. If seen before →409 Conflictor return the cached response. Use a Filter or Interceptor to check. - Traceability: Every response has
X-Correlation-ID. Echo if the client sent one; otherwise generate a UUID. Hint: use a Filter. - Error handling: Validation failures → JSON map of field errors, never a stack trace.
Self-check: Did you put logic in the controller? (Fail.) Use a Filter for Correlation ID? (Correct—it applies to every request.) Return a Product entity? (Fail. Use ProductResponse.)
// Phase 2 Challenge: The "Perfect" Product Catalog API// POST /products// 1. Validation: price > 0, sku exactly 8 alphanumeric chars// 2. Idempotency: X-Idempotency-Key header. Missing -> 400. Seen -> 409 or cached response// 3. Traceability: X-Correlation-ID on every response. Echo if sent, else UUID// 4. Errors: JSON map of field errors, no stack traces// Use Filter for Correlation ID; Filter or Interceptor for idempotency. DTOs only.
Phase 3: Data, Hibernate & Transactions
The phase that separates "Spring Boot developers" from Senior Engineers. In MVC, mistakes usually mean a bug. In the data layer, mistakes mean production outages, deadlocks, and data corruption. Senior mindset: Hibernate is a leaky abstraction—if you don't know the SQL it generates, you are writing bugs.
Topic 3.1: The N+1 Problem (The Performance Killer)
The problem: You fetch 10 users, then loop and access each user's orders. Hibernate runs 1 query for users, then N extra queries (one per user) for orders. Total: N+1. With N=1000, your database dies.
❌ Junior code (the trap)
@Entityclass User(@OneToMany(fetch = FetchType.LAZY) val orders: List<Order>)fun printUserOrders() {val users = userRepository.findAll() // Query 1: SELECT * FROM usersusers.forEach { user ->// Query 2,3,4...N: SELECT * FROM orders WHERE user_id = ?println("User has ${user.orders.size} orders")}}
✅ Senior solution (JOIN FETCH / @EntityGraph)
Tell Hibernate to fetch everything in one query: @Query("SELECT u FROM User u LEFT JOIN FETCH u.orders") or @EntityGraph(attributePaths = ["orders"]) on findAll().
interface UserRepository : JpaRepository<User, Long> {@Query("SELECT u FROM User u LEFT JOIN FETCH u.orders")fun findAllWithOrders(): List<User>@EntityGraph(attributePaths = ["orders"])override fun findAll(): List<User>}
Topic 3.2: Transaction Propagation (The "Proxy" Gotcha)
@Transactional wraps a method in a DB transaction. Spring uses proxies. If you call method B from method A inside the same class, the @Transactional on B is ignored—you're calling this.methodB(), bypassing the proxy that starts the transaction.
@Serviceclass OrderService {fun processOrder() {saveToDb() // Internal call — proxy bypassed! @Transactional IGNORED.}@Transactional(propagation = Propagation.REQUIRES_NEW)fun saveToDb() {// This never runs in its own tx when called from processOrder().}}
Fix: Move saveToDb to a separate service (so the call goes through the proxy) or use self-injection / lazy injection of the same bean.
Topic 3.3: Propagation Types (Know Your Boundaries)
- REQUIRED (default): Join an existing transaction or start a new one. If an inner method throws, the entire transaction is marked for rollback; you can't catch in the outer method and proceed.
- REQUIRES_NEW: Suspend the current transaction, start a fresh one, commit/rollback it independently, then resume the original. Use for e.g. saving an audit log even when the main business logic fails.
Topic 3.4: Optimistic Locking (Preventing Lost Updates)
Scenario: Two admins edit the same user. A sets name to "Alice", B to "Bob". A saves, then B saves—B overwrites A silently. Solution: @Version. Hibernate auto-increments the version on update. If B saves with an outdated version, the DB version is higher → OptimisticLockingFailureException.
@Entityclass Product(@Id val id: Long,var name: String,@Version val version: Long = 0)
🔥 Phase 3 Challenge: The High-Load Optimizer
Endpoint GET /dashboard loads a User, their last 5 orders, and the items in those orders. Currently it runs 151 queries for a user with 50 orders (N+1 nested inside N+1).
Your mission
- Profile: Enable SQL logging (
spring.jpa.show-sql=true,format_sql=true) or use P6Spy. - Fix: Refactor the repository so User + Orders + Items load in 1 or 2 queries. Watch for
MultipleBagFetchException(cartesian product) when fetching multiple collections—useSetinstead ofListor two separate queries. - Prove: Add a test (e.g. QuickPerf or log counting) that asserts query count ≤ 2.
Self-check: Did you switch to FetchType.EAGER? (Fail—never use EAGER.) Use @EntityGraph? (Pass.) Handle LazyInitializationException? (Pass.)
// Phase 3 Challenge: High-Load Optimizer// GET /dashboard: User + Last 5 Orders + Items in those orders.// Current: 151 queries (N+1 inside N+1). Target: 1 or 2 queries.// 1. Profile: show-sql, format_sql, or P6Spy.// 2. Fix: JOIN FETCH / @EntityGraph. Beware MultipleBagFetchException — Set vs List or 2 queries.// 3. Prove: QuickPerf or log count; assert queries <= 2.// Self-check: No EAGER. Use @EntityGraph. Handle LazyInitializationException.
Phase 4: Security (OAuth2, JWT & The Filter Chain)
Senior mindset: Don't write your own login page. Don't hash passwords yourself if you can avoid it. Use standard protocols (OIDC/OAuth2). Your job is to secure the resource, not manage identity.
Topic 4.1: OAuth2 vs OIDC (Know the Difference)
OAuth2 (Authorization): "I have a key to your house." It handles access (e.g. "Allow this app to read my Google Drive"). OIDC (OpenID Connect — Authentication): "I know who you are." It adds an identity layer on top of OAuth2 (e.g. "Sign in with Google").
Flow (simplified): (1) User clicks Login → (2) Redirect to IdP (Auth0, Keycloak, Google). (3) User logs in there, not on your server. (4) IdP returns a code to your frontend. (5) Frontend exchanges code for a JWT. (6) Frontend sends Authorization: Bearer <token> to your Spring Boot API.
Topic 4.2: The Spring Security Filter Chain
Spring Security is a chain of Servlet Filters. If a request reaches your controller, it has already passed the chain.
- SecurityContextPersistenceFilter: Restores user session (often disabled for stateless APIs).
- CorsFilter: Decides if e.g.
localhost:3000can calllocalhost:8080. - BearerTokenAuthenticationFilter: Validates JWT, extracts roles.
- FilterSecurityInterceptor: Final gatekeeper; checks e.g. "Does this user have
ROLE_ADMINfor/admin/**?"
Debug tip: Enable logging.level.org.springframework.security=DEBUG to see exactly which filter rejected a request (e.g. 403 Forbidden).
Topic 4.3: Implementing a Resource Server (The Code)
You build the API; you trust the IdP (e.g. Auth0). You only validate the token. Add spring-boot-starter-oauth2-resource-server. Use @EnableWebSecurity and @EnableMethodSecurity for @PreAuthorize.
@Configuration@EnableWebSecurity@EnableMethodSecurityclass SecurityConfig {@Beanfun securityFilterChain(http: HttpSecurity): SecurityFilterChain {http.csrf { it.disable() }.cors { }.authorizeHttpRequests { auth ->auth.requestMatchers("/public/**").permitAll().requestMatchers("/actuator/health").permitAll().anyRequest().authenticated()}.oauth2ResourceServer { oauth2 -> oauth2.jwt { } }return http.build()}}
In application.yml, set spring.security.oauth2.resourceserver.jwt.issuer-uri to your IdP URL. Spring Boot fetches public keys and validates JWT signatures automatically.
spring:security:oauth2:resourceserver:jwt:issuer-uri: https://dev-xyz.us.auth0.com/
Topic 4.5: CORS (Cross-Origin Resource Sharing)
Common cause of "It works in Postman but fails in Chrome." Browsers block cross-origin requests (e.g. React on localhost:3000 → Spring on localhost:8080) unless you allow them. Define a CorsConfigurationSource bean: allowedOrigins, allowedMethods, allowedHeaders, and registerCorsConfiguration("/**", ...).
@Beanfun corsConfigurationSource(): CorsConfigurationSource {val configuration = CorsConfiguration()configuration.allowedOrigins = listOf("http://localhost:3000")configuration.allowedMethods = listOf("GET", "POST", "PUT", "DELETE")configuration.allowedHeaders = listOf("*")val source = UrlBasedCorsConfigurationSource()source.registerCorsConfiguration("/**", configuration)return source}
🔥 Phase 4 Challenge: The Secure Vault
Build GET /vault/secrets that returns "Gold Bar". Only users with ROLE_ADMIN may access it.
Requirements
- Setup: Auth0 (free) or Keycloak via Docker. Point Spring Boot to the issuer URI.
- Roles: Create Alice (User) and Bob (Admin).
/vault/secrets→ "Gold Bar". Alice →403 Forbidden. Bob →200 OK. - Custom converter: Implement
JwtAuthenticationConverterto map IdP roles (e.g. Auth0) into SpringROLE_...format.
Self-check: Did you implement a login page in Spring? (Fail—login happens on the IdP.) Does Alice get 401 or 403? She should get 403 (forbidden): "I know you, but you're not allowed." 401 = "Who are you?"
// Phase 4 Challenge: The Secure Vault// GET /vault/secrets -> "Gold Bar". Only ROLE_ADMIN. Alice (User) -> 403, Bob (Admin) -> 200.// 1. Auth0 or Keycloak; issuer-uri in application.yml.// 2. JwtAuthenticationConverter to map IdP roles to ROLE_...// 3. No login page in Spring; login on IdP. Alice: 403 (forbidden), not 401.
Phase 5: Async & Integration (Coroutines, Messaging & The Outbox)
Senior mindset: "Fire and forget" is a lie. Unsupervised background tasks crash silently. Sending to Kafka without an Outbox loses data. Use structured concurrency and the Transactional Outbox for consistency.
Topic 5.1: Coroutines in Spring Boot (The Native Way)
Since Spring Boot 3.0, Coroutines are first-class. No CompletableFuture wrappers. Controllers and services use suspend; the thread is released while waiting. Use CoroutineCrudRepository (or R2DBC) for reactive data access.
@GetMapping("/{id}")suspend fun getUser(@PathVariable id: String): UserResponse = userService.getUser(id)@Serviceclass UserService(private val repo: UserRepository) {suspend fun getUser(id: String): UserResponse {val user = repo.findById(id) ?: throw UserNotFoundException()val score = externalCreditClient.getScore(id)return user.toResponse(score)}}interface UserRepository : CoroutineCrudRepository<User, String>
⚠️ Trap: Never use GlobalScope.launch. If the request ends or errors, the global coroutine keeps running (zombie). Use a CoroutineScope tied to a lifecycle or plain suspend functions that Spring manages.
Topic 5.2: Structured Concurrency (Parallel Execution)
Fetch User and Orders in parallel instead of sequentially. Wrap parallel work in coroutineScope so that if one task fails, the other is cancelled (cleanup). Use async for both, then await()—total time ≈ slowest task, not the sum.
// ❌ Sequential: 2s totalval user = repo.getUser(id); val orders = orderClient.getOrders(id)// ✅ Parallel: coroutineScope + asyncsuspend fun getUserDashboard(id: String): Dashboard = coroutineScope {val userDeferred = async { repo.getUser(id) }val ordersDeferred = async { orderClient.getOrders(id) }Dashboard(user = userDeferred.await(), orders = ordersDeferred.await())}
Topic 5.3: Resilient HTTP Clients (WebClient)
Don't use RestTemplate (blocking, maintenance mode). Use WebClient with Coroutine extensions. Senior tip: Always set timeouts (response, connect); defaults can be effectively infinite and hang your pool.
@Beanfun remoteClient(builder: WebClient.Builder): WebClient {val httpClient = HttpClient.create().responseTimeout(Duration.ofSeconds(5)).option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2000)return builder.baseUrl("https://api.external.com").clientConnector(ReactorClientHttpConnector(httpClient)).build()}suspend fun fetchData(): String = webClient.get().uri("/data").retrieve().awaitBody()
Topic 5.4: Messaging Patterns (Kafka / RabbitMQ)
Consumer groups: Multiple instances (scale 3) should not all process the same "Order Created" event. Put them in the same consumer group; the broker ensures only one instance gets each message.
Dead Letter Queue (DLQ): If processing crashes, retry (e.g. 3x). After exhausting retries, move the message to a DLQ (orders-dlq). Avoids blocking the main queue with a poison pill while preserving the message for later inspection.
Topic 5.5: The Outbox Pattern (Critical Consistency)
Dual-write problem: Save Order to DB and publish "Order Created" to Kafka. If DB commits but Kafka fails → downstream never sees the order. If Kafka sends but DB fails → ghost order. Neither is acceptable.
Transactional Outbox: (1) Start transaction. (2) Insert Order into orders_table. (3) Insert event payload into outbox_table in the same transaction. (4) Commit—both or neither. (5) A separate relay (e.g. Debezium, Kafka Connect) reads outbox_table and publishes to Kafka. Atomicity guaranteed.
🔥 Phase 5 Challenge: The Reliable Notifier
Build a system that sends a "Welcome Email" when a user signs up.
Requirements
- User service:
POST /users— save user (Mongo/Postgres), then publish to RabbitMQ or Kafka. - Notification service (consumer): Listen to the queue; "send email" (e.g.
delay(1000)+ print). - Resilience: Make "send email" throw randomly (simulate mail server down). Configure retry 3×; after 3 failures, route to a DLQ.
Self-check: Retries? Outbox (or at least transactional simulation)? If the mail server is down forever, does the main app crash? It shouldn't—the message should sit in the DLQ.
// Phase 5 Challenge: The Reliable Notifier// POST /users -> save user, send message to Rabbit/Kafka.// Consumer: listen, 'send email' (delay(1000) + print). Randomly throw -> retry 3x -> DLQ.// Self-check: Retries? Outbox or transactional sim? Main app stays up if mail down (message in DLQ).
Phase 6: Observability & Production Readiness
Senior mindset: Code that runs without eyes on it is a ticking time bomb. You don't debug production with a debugger—you debug by reading the telemetry your code emits.
Topic 6.1: Spring Boot Actuator (The Cockpit)
Actuator adds production endpoints. Not just "UP"—it powers Kubernetes orchestration. Liveness (/actuator/health/liveness): is the process running? If no → K8s restarts the container. Readiness (/actuator/health/readiness): can the app accept traffic? (DB up, cache warm.) If no → K8s stops routing traffic but keeps the container alive.
Don't expose everything. Use management.endpoints.web.exposure.include (e.g. health,info,metrics,prometheus), management.endpoint.health.probes.enabled: true, and show-details only where appropriate (secure in prod).
management:endpoints:web:exposure:include: "health,info,metrics,prometheus"endpoint:health:probes:enabled: trueshow-details: always
Topic 6.2: Structured Logging & Correlation IDs
Rule: No println. No stack traces without context. Goal: Track a single request across Nginx → Service A → Service B → DB. MDC (Mapped Diagnostic Context) is a thread-local map that Logback/Log4j2 read from. Use a filter: read or generate X-Correlation-ID, put it in MDC, add it to the response, then clear MDC in finally—threads are reused; otherwise you leak data to the next request.
@Componentclass CorrelationIdFilter : Filter {override fun doFilter(req: ServletRequest, res: ServletResponse, chain: FilterChain) {val id = (req as HttpServletRequest).getHeader("X-Correlation-ID")?: UUID.randomUUID().toString()MDC.put("correlationId", id)try {(res as HttpServletResponse).setHeader("X-Correlation-ID", id)chain.doFilter(req, res)} finally {MDC.clear()}}}
Configure Logback to include %X{correlationId} in the pattern. Logs look like: [correlationId=abc-123] INFO OrderService - Processing order... (Spring Boot 3 + Micrometer Tracing can do this automatically, but understanding MDC is essential.)
Topic 6.3: Custom Metrics (Micrometer)
Beyond CPU/memory: you need business metrics—e.g. "orders failed payment", "time spent in calculateTax()". Spring Boot uses Micrometer (SLF4J-style facade for metrics). It ships to Prometheus, Datadog, New Relic, etc. Use MeterRegistry: counter("name", "tag", "value").increment() and @Timed for latency. Tag failures (e.g. status=failure) so you can alert: "If payments.total with status=failure > 5% for 5 minutes, page the engineer."
@Serviceclass PaymentService(private val meterRegistry: MeterRegistry) {fun processPayment(amount: Double) {try {meterRegistry.counter("payments.total", "status", "success").increment()} catch (e: Exception) {meterRegistry.counter("payments.total", "status", "failure").increment()throw e}}@Timed(value = "payments.latency", description = "Time to process payment")fun slowExternalCall() { Thread.sleep(1000) }}
Topic 6.4: Distributed Tracing (OpenTelemetry)
Logs = what happened. Metrics = trends. Traces = where time was spent. If "Get User" takes 5s, a trace shows Controller 5ms, Service 10ms, DB 4900ms—you've found the bottleneck. Add micrometer-tracing-bridge-otel; Spring Boot 3 injects trace IDs into logs and propagates them over HTTP (WebClient/RestTemplate) to other services.
🔥 Phase 6 Challenge: The Glass Box
Make your API transparent.
Requirements
- Actuator:
GET /actuator/healthreturns{"status": "UP"}. - Correlation: MDC filter. Request via Postman; logs include
[correlationId=...]and response headers includeX-Correlation-ID. - Metric:
POST /config/feature-toggleincrementsconfig.updates. Verify at/actuator/metrics/config.updates.
Self-check: MDC cleared in finally? (If not, logs are wrong.) Using Micrometer abstraction, not a hardcoded metrics backend? (So you can swap Prometheus for Datadog later.)
// Phase 6 Challenge: The Glass Box// 1. Actuator: /actuator/health -> {"status":"UP"}// 2. MDC Filter: X-Correlation-ID in logs and response headers// 3. POST /config/feature-toggle -> increment config.updates; verify at /actuator/metrics/config.updates// Self-check: MDC.clear() in finally? Use Micrometer, not hardcoded backend.
Phase 7: Architecture & Engineering Judgment
Senior mindset: Any junior can add a library. A senior knows when not to. Architecture is not drawing boxes—it's managing complexity and drawing boundaries. Postpone hard decisions (like splitting into microservices) as long as possible by keeping the system modular.
Topic 7.1: The Modular Monolith (The "Golden Default")
Trap: "We need microservices because Netflix uses them." Reality: If you can't build a clean monolith, you'll build a distributed monolith—the worst of both worlds. Solution: a Modular Monolith: single deployment (one JAR), strict boundaries. Code in Order must not directly import User internals.
Use Kotlin internal: domain and DTOs public; repository implementations and helpers internal. That physically prevents other modules from touching your privates.
Topic 7.2: Packaging by Feature (Not by Layer)
Don't slice vertically (/controllers, /services, /repositories). Slice horizontally: group what changes together (/features/users, /features/orders). When you extract "Orders" into a microservice, you cut-and-paste the orders folder.
❌ Vertical (by layer)
/controllers
- UserController
- OrderController
/services
- UserService
- OrderService
/repositories✅ Horizontal (by feature)
/features
/users
- UserController.kt
- UserService.kt
- UserRepository.kt (internal)
- User.kt
/orders
- OrderController.kt
- OrderService.kt
...Topic 7.3: DDD Lite (Rich vs Anemic Models)
Anemic: Entities are bags of getters/setters; all logic in the service. Rich: The entity enforces invariants; the service orchestrates. Use private set for status; only the entity changes it. Move rules like "cannot confirm free order" inside Order.confirm().
❌ Anemic
data class Order(var status: String, var total: Double)fun confirmOrder(order: Order) {if (order.total <= 0) throw Error()order.status = "CONFIRMED"repo.save(order)}
✅ Rich domain
@Entityclass Order {var status: String = "DRAFT"private setfun confirm() {if (total <= 0) throw IllegalStateException("Cannot confirm free order")if (status != "DRAFT") throw IllegalStateException("Already confirmed")status = "CONFIRMED"}}fun confirmOrder(id: String) {val order = repo.findById(id)order.confirm()repo.save(order)}
Topic 7.4: Testing Strategy (The Pyramid)
Test behaviors, not methods. (1) Unit: Domain entities (rich models). No Spring, no DB. (2) Integration: Repositories and controllers. Use Testcontainers—not H2 if you run Postgres in prod; H2 differs (JSON, syntax). (3) E2E: Critical flows only (e.g. Login → Buy → Logout). Keep these few.
🔥 Phase 7 Challenge: The Refactor (Capstone)
You inherit a 3,000-line "God class" ShopService mixing Users, Inventory, Payments, and Notifications. Everything is public.
Task
- Identify boundaries: Inventory vs Payment.
- Create modules:
com.app.inventory,com.app.payment. - Lock down: Move code; mark repositories
internal. - Define APIs: Public
InventoryServiceinterface.ShopServicebecomes an orchestrator and only calls that interface. - Bonus (DDD): Move logic like
if (stock < 0) throw ...into theProductentity.
// Phase 7 Challenge: The Refactor (Capstone)// God class ShopService: 3k lines, Users+Inventory+Payments+Notifications, all public.// 1. Identify boundaries (Inventory vs Payment). 2. com.app.inventory, com.app.payment.// 3. Move code; internal repos. 4. Public InventoryService interface; ShopService orchestrates.// 5. Bonus: move 'if (stock < 0) throw...' into Product entity.
Recap
Phase 1
- Constructor-only DI; lifecycle; @Configuration vs @Component; Prototype trap; BeanPostProcessors.
Phase 2
- Controller as gateway; DTOs only;
@ControllerAdvice; Filters vs Interceptors; validation; idempotency and correlation IDs.
Phase 3
- N+1: JOIN FETCH /
@EntityGraph; never EAGER. Transaction proxy; REQUIRED vs REQUIRES_NEW;@Version; profile and prove query count.
Phase 4
- OAuth2 vs OIDC; resource server + JWT; filter chain;
@PreAuthorize; CORS; JWT converter; 401 vs 403.
Phase 5
- Coroutines &
suspend; noGlobalScope.coroutineScope+async; WebClient with timeouts; consumer groups; DLQ; Transactional Outbox.
Phase 6
- Actuator liveness/readiness; MDC & correlation IDs; Micrometer; distributed tracing.
Phase 7
- Modular Monolith;
internalboundaries. Packaging by feature. Rich vs anemic domain; Testcontainers; testing pyramid. Refactor capstone.
🎓 Course Completion
You've gone from DI fundamentals to architectural boundaries. You now have the toolkit of a Senior Spring Engineer:
- Core: Constructor injection & proxies.
- Web: Global error handling & DTOs.
- Data: N+1 prevention & transaction propagation.
- Security: OAuth2 resource servers.
- Async: Coroutines & Outbox pattern.
- Ops: Metrics & correlation IDs.
- Arch: Modular monoliths & DDD.
Next step: Pick a Master Project (e.g. an Event-Driven Payment System) and build it. Apply every phase to that one project. That is how you become unstoppable. Good luck.