Advanced Backend Engineering: Kotlin & Spring Boot
A comprehensive Master Course for aspiring Staff or Principal Engineers. This guide synthesizes theoretical computer science with production-hardened patterns โ from the JVM and K2 compiler to Spring Boot 4, Coroutines, Hexagonal Architecture, and distributed systems. Deep-dive preparation for high-stakes technical interviews and architectural decisions.
Chapter 1: The Modern JVM Ecosystem & Kotlin Philosophy
IntermediateKotlin is not simply "Java with less typing" โ it is a pragmatic language designed to fix specific shortcomings while maintaining 100% interoperability. Understanding the JVM foundation and Kotlin's design philosophy is crucial for any senior backend interview.
1.1 The "Billion Dollar Mistake" and Null Safety
NullPointerException is the most common runtime error in traditional Java. Kotlin integrates nullability into the type system: String and String? are distinct types. At runtime there is no wrapper overhead (unlike Optional), making Kotlin's null safety performance-neutral.
// Java โ defensive checks and OptionalOptional<User> user = userRepo.findById(id);if (user.isEmpty()) throw new NotFoundException();return user.get();
// Kotlin โ type system enforces at compile timeval user = repository.findByName("Alice") ?: throw CustomerNotFoundException()// String and String? are distinct types โ no wrapper overhead
A: Unannotated Java types become "Platform Types" (
T!). The compiler relaxes null-checks, which is a known risk. Modern Spring Boot 4 apps use JSpecify annotations to enforce strict boundaries and eliminate platform types.1.2 Platform Types โ The Hidden Danger
When calling Java libraries without nullability annotations, Kotlin infers "platform types" (T!). These bypass null safety entirely โ the compiler won't warn you, and NPEs can occur at runtime. This is the #1 source of Kotlin NPEs in real-world projects.
// Java library without nullability annotationspublic class LegacyUserService {public String getUserEmail(Long id) { // returns T! (platform type)return userDao.findEmail(id); // might return null!}}// Kotlin caller โ the compiler WON'T warn youval email: String = legacyService.getUserEmail(42) // NPE at runtime!// Safe approach โ always treat Java returns as nullableval email: String? = legacyService.getUserEmail(42)val safeEmail = email ?: "no-email@default.com"// Best approach โ add JSpecify annotations to Java code@NullMarkedpublic class UserService {public @Nullable String getUserEmail(Long id) { ... }}
T! platform type. Always assign Java return values to T? (nullable) unless the Java code has @NonNull / @NullMarked annotations. Spring Framework 6+ is fully annotated with JSpecify.1.3 Immutability and State Management
Concurrency bugs stem from shared mutable state. Use val, immutable collections (List vs MutableList), and "copy-on-write" with .copy() on data classes. Service layers should be stateless; DTOs should be immutable.
// Anti-pattern: mutable state shared across threadsclass OrderService {private var cachedOrders = mutableListOf<Order>() // race condition!fun addOrder(order: Order) {cachedOrders.add(order) // NOT thread-safe}}// Correct: immutable data, copy-on-write semanticsdata class Order(val id: Long, val items: List<OrderItem>, val status: Status)class OrderService(private val repository: OrderRepository) {fun updateStatus(order: Order, newStatus: Status): Order {val updated = order.copy(status = newStatus) // new object, old is untouchedrepository.save(updated)return updated}}
val. If it changes during the object's lifetime, ask yourself: "Should I create a new object instead?" The answer is usually yes.1.4 The K2 Compiler and Spring Boot 4
Kotlin 2.0 introduced the K2 Compiler: a complete rewrite of the frontend compiler. It brings 2x faster compilation, smarter smart casts that work across when branches and lambda boundaries, and better type inference for builder DSLs. Spring Boot 4 leverages K2 metadata for faster startup and more efficient reflection.
// K2 Compiler โ smarter smart casts (Kotlin 2.0+)fun processInput(input: Any) {if (input is String && input.length > 5) {// K1: requires explicit cast in some complex cases// K2: smart cast works across when, if-else chains, and lambdasprintln(input.uppercase()) // smart cast to String}}// K2 also enables: contracts, better type inference in buildersbuildList {add("hello") // K2 infers MutableList<String> correctly in more casesadd("world")}
A: Three main benefits: (1) Build speed โ K2 compiles 1.5-2x faster, significant in large monorepos. (2) Smarter smart casts โ fewer explicit casts needed in complex control flow. (3) Spring Boot 4 integration โ K2 metadata enables AOT (Ahead of Time) compilation for GraalVM native images, reducing startup time from seconds to milliseconds.
Chapter 2: Idiomatic Kotlin for the Spring Developer
IntermediateAvoid the "Java developer writing Kotlin" anti-pattern. Idiomatic Kotlin code is more concise, safer, and easier to maintain. Interviewers specifically look for candidates who write Kotlin, not "Java without semicolons".
2.1 Expressions vs Statements
In Kotlin, if, when, and try are expressions โ they return values. Use them to assign directly and enforce immutability. This eliminates temporary var declarations entirely.
// Java style (anti-pattern in Kotlin)var status: String = ""if (response.code == 200) { status = "Success" } else { status = "Error" }// Idiomatic Kotlin โ expressionsval status = when (response.code) {200 -> "Success"404 -> "Not Found"in 500..599 -> "Server Error"else -> "Unknown"}
2.2 Extension Functions
Extend classes without inheriting. This is Kotlin's killer feature for building clean DSLs and utility APIs. But there's a critical interview gotcha: extension functions are statically resolved.
// Add domain-specific behavior without inheritancefun String.toSlug(): String =this.lowercase().replace(Regex("[^a-z0-9\s-]"), "").replace(Regex("\s+"), "-").trim('-')// Usageval slug = "My Blog Post Title!".toSlug() // "my-blog-post-title"// Extension on a Spring class โ cleaner DSLfun ResponseEntity.BodyBuilder.jsonError(message: String): ResponseEntity<Map<String, String>> =this.body(mapOf("error" to message))// IMPORTANT: extensions are resolved STATICALLYopen class Shapeclass Circle : Shape()fun Shape.name() = "Shape"fun Circle.name() = "Circle"val shape: Shape = Circle()println(shape.name()) // prints "Shape" โ NOT "Circle"!
shape.name() print when shape is actually a Circle?" โ It prints "Shape" because extensions are resolved by the declared type, not runtime type. This is a classic interview question to test whether you truly understand Kotlin.2.3 Scope Functions (let, run, with, apply, also)
The five scope functions are often confused. The key differentiator: what is the context object (this vs it) and what is the return value (the object vs the lambda result).
?.let for null-safety.// apply โ configure an object, returns the objectval user = User().apply {name = "Alice"email = "alice@example.com"role = Role.ADMIN}// let โ transform + null safety, returns lambda resultval length = name?.let {println("Processing: $it")it.length} ?: 0// also โ side effects (logging, validation), returns the objectfun createUser(request: CreateUserRequest): User =userRepository.save(request.toEntity()).also { logger.info("Created user: ${it.id}") }.also { eventPublisher.publish(UserCreatedEvent(it)) }// run โ configure + compute, returns lambda resultval dbConfig = config.run {DatabaseConfig(url = databaseUrl,poolSize = maxConnections,timeout = Duration.ofSeconds(connectionTimeout))}
also vs apply?A: Use
apply when configuring properties on the object (receiver is this, so you can access properties directly). Use also for side effects like logging, where having the object as it makes the code clearer โ also { logger.info("Created: $it") }.Chapter 3: Advanced Spring Boot Architecture & Configuration
AdvancedSpring Boot 4 moves from reflection-heavy annotation scanning toward functional bean definition and AOT compilation. Understanding these internals separates senior from mid-level engineers.
3.1 The Functional Bean Definition DSL
Kotlin's beans { } DSL allows programmatic registration. Benefits: no reflection scanning at startup, compilation errors for missing dependencies, profile-aware definitions, and centralized bean configuration.
// Beans.kt โ Functional Bean Definition DSLval applicationBeans = beans {bean<UserService>()bean {val repo = ref<UserRepository>()val config = ref<AppConfig>()AuthenticationService(repo, config.tokenSecret)}profile("dev") { bean<EmailSender> { MockEmailSender() } }profile("prod") { bean<EmailSender> { SmtpEmailSender(env.getProperty("smtp.host")!!) } }}// Application.ktfun main(args: Array<String>) {runApplication<MyServiceApplication>(*args) {addInitializers(BeansInitializer(applicationBeans))}}
3.2 Constructor Injection vs Field Injection
@Autowired lateinit var is an anti-pattern: it forces mutability, makes testing harder, hides dependencies, and prevents immutability guarantees. Always use Constructor Injection โ Kotlin makes this elegant with primary constructor parameters.
// Anti-pattern: field injection@Serviceclass OrderService {@Autowired lateinit var repository: OrderRepository // mutable!@Autowired lateinit var paymentClient: PaymentClient // hidden deps!}// Correct: constructor injection (Kotlin makes this elegant)@Serviceclass OrderService(private val repository: OrderRepository,private val paymentClient: PaymentClient) {// val = immutable, thread-safe, easy to test with mocks// Dependencies are explicit in the constructor signature// No need for @Autowired โ Spring infers single constructor}
A: Four reasons: (1) Immutability โ
val guarantees thread safety. (2) Testability โ pass mocks directly, no reflection needed. (3) Visibility โ all dependencies are explicit in the constructor. (4) Fail-fast โ missing dependencies cause startup failures, not runtime NPEs.3.3 Configuration Properties with Immutable Data Classes
Use @ConfigurationProperties with data classes for immutable config. Once the app starts, configuration cannot change โ preventing drift bugs. Spring Boot 4 uses constructor binding by default for data classes.
// Immutable configuration with data classes@ConfigurationProperties(prefix = "app.payment")data class PaymentConfig(val apiKey: String,val baseUrl: String,val timeout: Duration = Duration.ofSeconds(30),val retryAttempts: Int = 3,val webhookSecret: String)// Usage โ injected as a bean, immutable after startup@Serviceclass PaymentService(private val config: PaymentConfig) {private val client = HttpClient.newBuilder().connectTimeout(config.timeout).build()}// application.yml// app:// payment:// api-key: ${PAYMENT_API_KEY}// base-url: https://api.stripe.com/v1// webhook-secret: ${STRIPE_WEBHOOK_SECRET}
3.4 Custom Auto-Configuration
Creating custom starters is a senior-level skill. It involves @AutoConfiguration, @ConditionalOn* annotations, and the new AutoConfiguration.imports file (replacing spring.factories in Spring Boot 3+).
// Create your own Spring Boot starter@AutoConfiguration@ConditionalOnClass(MetricsRegistry::class)@EnableConfigurationProperties(MetricsProperties::class)class MetricsAutoConfiguration {@Bean@ConditionalOnMissingBeanfun metricsRegistry(props: MetricsProperties): MetricsRegistry =MetricsRegistry(props.prefix, props.tags)@Bean@ConditionalOnProperty("app.metrics.http.enabled", havingValue = "true")fun httpMetricsFilter(registry: MetricsRegistry): HttpMetricsFilter =HttpMetricsFilter(registry)}// META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports// com.myapp.metrics.MetricsAutoConfiguration
@ConditionalOnMissingBean so users can provide their own implementation, and @ConditionalOnProperty to let them disable features entirely.Chapter 4: Persistence Engineering (JPA vs Exposed vs R2DBC)
Advanced"Can I use a Kotlin data class for a JPA Entity?" No. Data classes generate equals/hashCode based on all properties โ lazy-loaded @OneToMany will trigger unexpected queries. Use a standard class, mark it open, and implement equals/hashCode on the primary key only.
@Entityclass Customer(@Column(nullable = false)var name: String,) {@Id@GeneratedValue(strategy = GenerationType.IDENTITY)var id: Long? = null// CRITICAL: equals/hashCode on business key or ID onlyoverride fun equals(other: Any?): Boolean {if (this === other) return trueif (other !is Customer) return falsereturn id != null && id == other.id}override fun hashCode(): Int = 14592834 // constant โ safe for sets}
data class for JPA entities causes: (1) lazy loading triggers in toString(), (2) equals/hashCode on all fields including lazy collections, (3) copy() creates detached entities that confuse Hibernate session. Always use regular class for JPA entities.4.1 Exposed โ Type-Safe SQL DSL
JetBrains' Exposed library takes a different approach: instead of mapping objects to tables (ORM), it provides a type-safe DSL to write SQL. Queries are validated at compile time โ no more JPQL string typos discovered in production.
// Exposed โ type-safe SQL DSL (no ORM magic)object Users : LongIdTable("users") {val name = varchar("name", 255)val email = varchar("email", 255).uniqueIndex()val createdAt = datetime("created_at").defaultExpression(CurrentDateTime)}// Type-safe queries โ compile-time errors for wrong column typessuspend fun findActiveUsers(minAge: Int): List<UserDto> = newSuspendedTransaction {Users.selectAll().where { Users.name.isNotNull() }.orderBy(Users.createdAt, SortOrder.DESC).limit(100).map { row ->UserDto(id = row[Users.id].value,name = row[Users.name],email = row[Users.email])}}
4.2 R2DBC โ Reactive Persistence
For fully non-blocking applications, R2DBC provides reactive database access. Combined with Kotlin coroutines, you get suspend functions and Flow for streaming query results with backpressure.
// Reactive persistence with R2DBC + Coroutines@Repositoryclass UserRepository(private val client: DatabaseClient) {suspend fun findById(id: Long): User? =client.sql("SELECT * FROM users WHERE id = :id").bind("id", id).map { row, _ -> row.toUser() }.awaitOneOrNull()fun findAll(): Flow<User> =client.sql("SELECT * FROM users ORDER BY created_at DESC").map { row, _ -> row.toUser() }.flow()suspend fun save(user: User): User =client.sql("""INSERT INTO users (name, email) VALUES (:name, :email)RETURNING *""".trimIndent()).bind("name", user.name).bind("email", user.email).map { row, _ -> row.toUser() }.awaitOne()}
JPA vs Exposed vs R2DBC
| Feature | Spring Data JPA | Exposed | R2DBC |
|---|---|---|---|
| Philosophy | ORM (object-centric) | SQL DSL (data-centric) | Reactive (stream-centric) |
| Performance | Proxies, cache overhead | Lightweight, direct SQL | Non-blocking, high throughput |
| Async Support | Blocking JDBC | Coroutines (native) | Reactive / Coroutines |
| Type Safety | JPQL strings | Kotlin DSL (compile-time) | Raw SQL strings |
| Learning Curve | High (Hibernate quirks) | Medium (SQL knowledge) | High (reactive paradigm) |
| Best For | CRUD-heavy, existing teams | Complex queries, Kotlin-first | High-concurrency services |
A: Choose Exposed when: (1) your team has strong SQL skills, (2) you need complex queries with joins, CTEs, or window functions, (3) you want compile-time query validation, (4) you're building a Kotlin-first codebase (no Java team to support). Stick with JPA when migrating existing Java projects or when the team is already proficient with Hibernate.
Chapter 5: Asynchronous Systems (Coroutines vs Virtual Threads)
AdvancedCoroutines: lightweight threads where suspend functions become state machines, releasing the thread while waiting. Virtual Threads (Java 21+): better for migrating legacy blocking apps without code changes. For new Kotlin projects, Coroutines remain superior thanks to Flow, select, structured concurrency, and cancellation.
5.1 Suspend Functions & Spring WebFlux
Spring Boot natively supports suspend functions in controllers. When a function suspends, the underlying thread is released back to the pool. The Flow return type enables streaming responses with backpressure.
@RestControllerclass UserController(private val service: UserService) {@GetMapping("/users/{id}")suspend fun getUser(@PathVariable id: Long): UserDto {return service.getUserById(id)}@GetMapping("/users")fun getAllUsers(): Flow<UserDto> {return service.getAllUsers() // returns Flow, not List โ backpressure-ready}}
5.2 Structured Concurrency & Parallel Calls
The killer feature of coroutines is structured concurrency: if one async call fails, all siblings are automatically cancelled. No orphan threads, no resource leaks. Compare this to CompletableFuture where you must manually track and cancel.
// Coroutines โ structured concurrency, rich operatorssuspend fun fetchUserDashboard(userId: Long): Dashboard = coroutineScope {val profile = async { userService.getProfile(userId) }val orders = async { orderService.getRecentOrders(userId) }val recommendations = async { mlService.getRecommendations(userId) }Dashboard(profile = profile.await(),orders = orders.await(),recommendations = recommendations.await())// If any async fails, ALL are cancelled (structured concurrency)}// Flow โ reactive streams with backpressurefun streamPrices(symbol: String): Flow<Price> = flow {while (currentCoroutineContext().isActive) {emit(priceService.getLatestPrice(symbol))delay(100) // non-blocking, releases thread}}.conflate() // drop intermediate if consumer is slow.distinctUntilChanged() // skip duplicates.catch { e -> logger.error("Price stream error", e) }
A: Yes. Coroutines offer: (1) Rich operators โ Flow (map, filter, conflate), select, channels. (2) Structured cancellation โ parent-child scope hierarchy. (3) Explicit suspension points โ
suspend keyword makes it clear where thread-release happens. Virtual Threads are ideal for migrating legacy blocking code (JDBC, file I/O) without rewriting.5.3 Channels โ Producer/Consumer Pattern
Channels provide a CSP-style (Communicating Sequential Processes) mechanism for coroutines. They support fan-out (multiple consumers), fan-in (multiple producers), and backpressure via bounded buffers.
// Channels โ producer/consumer patternval orderChannel = Channel<Order>(capacity = 100)// Producer coroutinelaunch {orderStream.collect { order ->orderChannel.send(order) // suspends if buffer full (backpressure)}}// Multiple consumers (fan-out)repeat(4) { workerId ->launch {for (order in orderChannel) {processOrder(order) // each order processed by exactly one workerlogger.info("Worker $workerId processed order ${order.id}")}}}
| Feature | Coroutines | Virtual Threads |
|---|---|---|
| Paradigm | Cooperative (suspend/resume) | Preemptive (OS-like) |
| Cancellation | Structured, hierarchical | Thread.interrupt() (legacy) |
| Streaming | Flow (backpressure built-in) | No native equivalent |
| Blocking I/O | Requires Dispatchers.IO | Works natively (pin-free) |
| Best For | New Kotlin projects, reactive | Legacy Java migration |
Chapter 6: Hexagonal Architecture (Ports & Adapters)
ExpertDependencies point inwards. The Domain knows nothing about the Web or Database. This is the most asked architecture pattern in Staff+ interviews. The core insight: your business logic should be testable without Spring, without a database, without HTTP.
6.1 Defining Ports (Interfaces)
Ports are interfaces defined in the domain layer. Input ports (use cases) define what the application can do. Output ports define what the application needs from infrastructure.
// domain/port/in โ Use cases (what the app CAN do)interface CreateOrderUseCase {suspend fun execute(command: CreateOrderCommand): Order}// domain/port/out โ Infrastructure contracts (what the app NEEDS)interface OrderRepository {suspend fun save(order: Order): Ordersuspend fun findById(id: OrderId): Order?}interface PaymentGateway {suspend fun charge(amount: Money, method: PaymentMethod): PaymentResult}interface EventPublisher {suspend fun publish(event: DomainEvent)}
6.2 Rich Domain Model
Domain entities contain business rules โ not just data. Use private constructor with factory methods to enforce invariants. Business validations live in the entity, not in the service layer.
// domain/model โ Pure Kotlin, NO framework annotationsdata class Order private constructor(val id: OrderId,val customerId: CustomerId,val items: List<OrderItem>,val status: OrderStatus,val total: Money,val createdAt: Instant) {// Business rules live HERE, not in the servicefun cancel(): Order {require(status == OrderStatus.PENDING) {"Can only cancel pending orders, current: $status"}return copy(status = OrderStatus.CANCELLED)}fun addItem(item: OrderItem): Order {require(status == OrderStatus.DRAFT) { "Cannot modify non-draft order" }return copy(items = items + item,total = total + item.price * item.quantity)}companion object {fun create(customerId: CustomerId, items: List<OrderItem>): Order {require(items.isNotEmpty()) { "Order must have at least one item" }return Order(id = OrderId.generate(),customerId = customerId,items = items,status = OrderStatus.DRAFT,total = items.sumOf { it.price * it.quantity },createdAt = Instant.now())}}}
private constructor with a companion object factory?A: It enforces invariants at construction time. Every
Order that exists in your system is guaranteed to have at least one item because the create() factory validates this. You can never accidentally create an invalid Order โ the compiler prevents it.6.3 Application Services (Use Case Orchestration)
Services orchestrate the domain model and ports. They do NOT contain business logic โ that belongs in the domain. Services handle: transaction boundaries, port coordination, and event publishing.
// domain/service โ Orchestrates ports, NO infrastructure awareness@Serviceclass CreateOrderService(private val orderRepo: OrderRepository,private val paymentGateway: PaymentGateway,private val eventPublisher: EventPublisher) : CreateOrderUseCase {override suspend fun execute(command: CreateOrderCommand): Order {val order = Order.create(command.customerId, command.items)val savedOrder = orderRepo.save(order)val paymentResult = paymentGateway.charge(order.total, command.paymentMethod)val confirmedOrder = when (paymentResult) {is PaymentResult.Success -> savedOrder.copy(status = OrderStatus.CONFIRMED)is PaymentResult.Declined -> savedOrder.copy(status = OrderStatus.PAYMENT_FAILED)}orderRepo.save(confirmedOrder)eventPublisher.publish(OrderCreatedEvent(confirmedOrder))return confirmedOrder}}
6.4 Adapters (Pluggable Infrastructure)
Adapters implement ports. Input adapters (controllers) call use cases. Output adapters (repositories, HTTP clients) implement domain port interfaces. You can swap PostgreSQL for MongoDB by changing only the adapter โ zero domain changes.
// infrastructure/adapter/in โ REST adapter (plugs into the port)@RestController@RequestMapping("/api/orders")class OrderController(private val createOrder: CreateOrderUseCase) {@PostMappingsuspend fun create(@RequestBody request: CreateOrderRequest): ResponseEntity<OrderResponse> {val command = request.toCommand() // map DTO to domain commandval order = createOrder.execute(command)return ResponseEntity.status(201).body(order.toResponse())}}// infrastructure/adapter/out โ JPA adapter (implements the port)@Repositoryclass JpaOrderRepository(private val jpa: SpringDataOrderRepository) : OrderRepository {override suspend fun save(order: Order): Order {val entity = order.toEntity()val saved = jpa.save(entity)return saved.toDomain()}override suspend fun findById(id: OrderId): Order? =jpa.findById(id.value)?.toDomain()}
domain/ โ model (entities, value objects), port (in/out interfaces), service (domain services)application/ โ use case implementations, command/query handlersinfrastructure/ โ adapter/in (REST, gRPC), adapter/out (JPA, HTTP clients, Kafka), configChapter 7: Distributed Systems Patterns
ExpertDistributed systems are where theory meets reality. You need to understand: idempotency, circuit breakers, saga patterns, CQRS, and context propagation. These are the topics that separate Staff from Senior engineers.
7.1 Idempotency
Idempotency: Client sends Idempotency-Key header. Server checks Redis: key exists & completed returns cached result; key exists & processing returns 409 Conflict; key missing means process and cache. This prevents duplicate charges, duplicate emails, duplicate orders.
suspend fun processPayment(key: String, request: PaymentRequest): PaymentResponse {val lock = redisTemplate.opsForValue().setIfAbsent(key, "PROCESSING", Duration.ofMinutes(5))if (lock == false) throw DuplicateRequestException()return try {val result = paymentGateway.charge(request)redisTemplate.opsForValue().set(key, result, Duration.ofHours(24))result} catch (e: Exception) {redisTemplate.delete(key)throw e}}
7.2 Circuit Breaker Pattern
When a downstream service is failing, continuing to send requests makes things worse (cascading failures). A circuit breaker "opens" after a threshold of failures, rejecting calls immediately. After a cooldown period, it enters "half-open" state to test recovery.
// Resilience4j with Kotlin coroutines@Serviceclass ProductClient(private val webClient: WebClient,private val circuitBreakerRegistry: CircuitBreakerRegistry) {private val cb = circuitBreakerRegistry.circuitBreaker("productService") {failureRateThreshold(50f)waitDurationInOpenState(Duration.ofSeconds(30))slidingWindowSize(10)minimumNumberOfCalls(5)}suspend fun getProduct(id: Long): Product =cb.executeSuspendFunction {webClient.get().uri("/products/{id}", id).retrieve().awaitBody<Product>()}}// States: CLOSED (normal) -> OPEN (failing, reject calls) -> HALF_OPEN (test)
A: Start with production metrics: (1) Failure rate threshold โ typically 50% over a sliding window of 10-20 calls. (2) Wait duration โ match the downstream service's typical recovery time (30s-60s). (3) Minimum calls โ at least 5-10 to avoid opening on a single failure. Always combine with retries (with exponential backoff) and fallback responses.
7.3 Saga Pattern
Distributed transactions (2PC) are fragile and slow. The Saga pattern breaks a transaction into a sequence of local transactions, each with a compensating action for rollback. Two flavors: orchestration (central coordinator) and choreography (event-driven).
// Saga โ distributed transaction via compensating actionsclass OrderSaga(private val orderService: OrderService,private val paymentService: PaymentService,private val inventoryService: InventoryService,private val shippingService: ShippingService) {suspend fun execute(command: CreateOrderCommand): OrderResult {val order = orderService.create(command)val payment = try {paymentService.charge(order)} catch (e: Exception) {orderService.cancel(order.id) // compensatethrow e}val reservation = try {inventoryService.reserve(order.items)} catch (e: Exception) {paymentService.refund(payment.id) // compensateorderService.cancel(order.id) // compensatethrow e}return OrderResult(order, payment, reservation)}}
7.4 CQRS (Command Query Responsibility Segregation)
Separate the write model (rich domain, normalized) from the read model (denormalized projections, optimized for queries). This is essential at scale: writes go to a transactional DB, reads come from materialized views or search indices (Elasticsearch).
// CQRS โ separate read and write models// Write side: rich domain model@Serviceclass OrderCommandService(private val repository: OrderRepository,private val eventStore: EventStore) {suspend fun handle(cmd: PlaceOrderCommand): OrderId {val order = Order.create(cmd.customerId, cmd.items)repository.save(order)eventStore.append(OrderPlacedEvent(order))return order.id}}// Read side: optimized projection for queries@Serviceclass OrderQueryService(private val readDb: OrderReadRepository) {suspend fun getOrderSummary(orderId: OrderId): OrderSummaryDto =readDb.findSummaryById(orderId) // denormalized, fast read?: throw OrderNotFoundException(orderId)fun getOrdersByCustomer(customerId: CustomerId): Flow<OrderListItemDto> =readDb.streamByCustomerId(customerId) // pre-joined, no N+1}
7.5 Context Propagation
In async/coroutine systems, ThreadLocal (e.g. MDC for logging) breaks because coroutines hop between threads. Use Micrometer Context Propagation to ensure Trace IDs and Span IDs flow correctly through suspend functions and reactive chains.
// Coroutine context propagation for tracingval mdcContext = MDCContext() // carries MDC (trace IDs) across suspensionssuspend fun processRequest(traceId: String) {withContext(mdcContext + Dispatchers.IO) {MDC.put("traceId", traceId)logger.info("Processing...") // traceId is available in logsval result = async { externalService.call() } // traceId propagated!result.await()}}// application.yml โ automatic propagation with Micrometer// spring:// reactor:// context-propagation: auto// management:// tracing:// sampling:// probability: 1.0
Chapter 8: Testing Strategies for Production Systems
AdvancedTesting is not optional for Staff-level engineers โ it's a core competency. You need to know when to use unit tests vs integration tests vs contract tests, how to mock Kotlin-specific constructs, and how to test without a database vs with a real one.
8.1 MockK โ Kotlin-First Mocking
MockK is the de facto mocking library for Kotlin. Unlike Mockito, it handles final classes (all Kotlin classes are final by default), extension functions, suspend functions, and object declarations natively. Use coEvery / coVerify for coroutine functions.
// MockK โ Kotlin-first mocking (handles final classes, suspend, extensions)class OrderServiceTest {private val repository = mockk<OrderRepository>()private val paymentGateway = mockk<PaymentGateway>()private val eventPublisher = mockk<EventPublisher>(relaxUnitFun = true)private val service = CreateOrderService(repository, paymentGateway, eventPublisher)@Testfun `should create order and charge payment`() = runTest {// Givenval command = createOrderCommand()coEvery { repository.save(any()) } returnsArgument 0coEvery { paymentGateway.charge(any(), any()) } returns PaymentResult.Success("tx-123")// Whenval order = service.execute(command)// ThenassertThat(order.status).isEqualTo(OrderStatus.CONFIRMED)coVerify(exactly = 2) { repository.save(any()) } // draft + confirmedcoVerify { eventPublisher.publish(any<OrderCreatedEvent>()) }}@Testfun `should fail order when payment is declined`() = runTest {coEvery { repository.save(any()) } returnsArgument 0coEvery { paymentGateway.charge(any(), any()) } returns PaymentResult.Declined("insufficient funds")val order = service.execute(createOrderCommand())assertThat(order.status).isEqualTo(OrderStatus.PAYMENT_FAILED)}}
8.2 Testcontainers โ Real Infrastructure
Never mock the database. Testcontainers spins up real PostgreSQL, Redis, Kafka containers for integration tests. This catches real issues: FK constraints, unique index violations, SQL dialect differences, and N+1 queries.
// Testcontainers โ real database in integration tests@Testcontainers@SpringBootTestclass UserRepositoryIntegrationTest {companion object {@Containerval postgres = PostgreSQLContainer("postgres:16-alpine").apply {withDatabaseName("testdb")withUsername("test")withPassword("test")}@JvmStatic@DynamicPropertySourcefun properties(registry: DynamicPropertyRegistry) {registry.add("spring.datasource.url") { postgres.jdbcUrl }registry.add("spring.datasource.username") { postgres.username }registry.add("spring.datasource.password") { postgres.password }}}@Autowiredlateinit var repository: UserRepository@Testfun `should enforce unique email constraint`() {repository.save(User(name = "Alice", email = "alice@test.com"))assertThrows<DataIntegrityViolationException> {repository.save(User(name = "Bob", email = "alice@test.com"))}}@Testfun `should cascade delete orders when user is deleted`() {val user = repository.save(User(name = "Charlie", email = "charlie@test.com"))orderRepository.save(Order(userId = user.id!!, total = 99.99))repository.deleteById(user.id!!)assertThat(orderRepository.findByUserId(user.id!!)).isEmpty()}}
A: Mock (unit test) when testing business logic โ the domain service's behavior given certain repository responses. Use Testcontainers (integration test) when testing data access logic โ that queries return correct results, constraints are enforced, and migrations work. Never mock in integration tests; never use real DBs in unit tests.
8.3 Architecture Tests with ArchUnit
Enforce architectural rules as automated tests. Prevent domain-from-depending-on-infrastructure drift, ensure naming conventions, and validate layer boundaries โ all checked in CI.
// ArchUnit โ enforce architecture rules at compile-time@AnalyzeClasses(packages = ["com.myapp"])class ArchitectureTest {@ArchTestval domainShouldNotDependOnInfrastructure: ArchRule =noClasses().that().resideInAPackage("..domain..").should().dependOnClassesThat().resideInAPackage("..infrastructure..").because("Domain must not know about infrastructure")@ArchTestval controllersShouldNotAccessRepositoriesDirectly: ArchRule =noClasses().that().resideInAPackage("..adapter.in..").should().dependOnClassesThat().resideInAPackage("..adapter.out..").because("Input adapters should use domain ports, not output adapters")@ArchTestval servicesShouldBeSuffixed: ArchRule =classes().that().implement(UseCase::class.java).should().haveSimpleNameEndingWith("Service")}
@SpringBootTest + Testcontainers. Infrastructure adapters = contract tests verifying port implementations. Architecture = ArchUnit tests in CI.Chapter 9: The Senior Interview Gauntlet
ExpertThis chapter combines everything: system design, live coding, and behavioral questions. These are the exact scenarios you'll face in L5-L7 backend interviews at top tech companies.
9.1 System Design: High-Throughput Notification Service
Design a notification service that sends millions of notifications (email, push, SMS) with guarantees: at-least-once delivery, idempotency, rate limiting per user, and multi-channel support.
Idempotency: Redis-based dedup with TTL (as shown in Ch.7).
Rate Limiting: Per-user token bucket to prevent notification fatigue.
Pagination: Keyset pagination (not OFFSET) for querying millions of users.
Retry: DLQ for failed sends, exponential backoff per channel.
// Notification Service โ High-Throughput Design@Serviceclass NotificationService(private val kafka: KafkaTemplate<String, NotificationEvent>,private val rateLimiter: RateLimiter,private val templateEngine: TemplateEngine) {suspend fun sendBulkNotification(userIds: List<Long>,template: NotificationTemplate) {// Keyset pagination โ no OFFSET, scales to millionsvar lastId = 0Ldo {val batch = userRepository.findNextBatch(afterId = lastId, limit = 1000)batch.forEach { user ->val event = NotificationEvent(userId = user.id,channel = user.preferredChannel,content = templateEngine.render(template, user),idempotencyKey = "${template.id}-${user.id}")kafka.send("notifications", user.id.toString(), event)}lastId = batch.lastOrNull()?.id ?: break} while (batch.size == 1000)}}
9.2 Live Coding: Token Bucket Rate Limiter
Implement the Token Bucket algorithm with Kotlin Coroutines Mutex. This is a classic interview problem โ interviewers want to see thread-safe code, mathematical correctness, and clean API design.
// Token Bucket Rate Limiter with Coroutinesclass RateLimiter(val capacity: Int, val refillRate: Int) {private val mutex = Mutex()private var tokens = capacity.toDouble()private var lastRefill = System.currentTimeMillis()suspend fun tryAcquire(): Boolean {mutex.withLock {refill()if (tokens >= 1) { tokens -= 1; return true }return false}}private fun refill() {val now = System.currentTimeMillis()val delta = (now - lastRefill) / 1000.0tokens = min(capacity.toDouble(), tokens + delta * refillRate)lastRefill = now}}// Usage in a filterclass RateLimitFilter(private val limiter: RateLimiter) : WebFilter {override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> =mono {if (!limiter.tryAcquire()) {exchange.response.statusCode = HttpStatus.TOO_MANY_REQUESTSexchange.response.setComplete().awaitFirstOrNull()} else {chain.filter(exchange).awaitFirstOrNull()}}}
9.3 Rapid-Fire Interview Questions
coroutineScope and supervisorScope?A:
coroutineScope: if any child fails, all siblings are cancelled (fail-fast). supervisorScope: child failures don't affect siblings โ each is independent. Use supervisorScope for fan-out operations where partial results are acceptable (e.g., fetching data from multiple optional sources).A: Loading a parent entity and then lazily loading each child individually. Solutions: (1)
@EntityGraph for declarative fetch joins, (2) JOIN FETCH in JPQL, (3) @BatchSize to batch lazy loads. In Exposed: just write the JOIN explicitly in the DSL.A: Expand-and-contract pattern: (1) Add new column (nullable, with default). (2) Deploy code that writes to both old and new columns. (3) Backfill existing data. (4) Deploy code that reads from new column only. (5) Drop old column. Never rename or drop columns in a single deployment.
A: Backpressure occurs when a producer emits data faster than a consumer can process it. Flows handle it natively because
emit() suspends until the collector is ready. Additional strategies: conflate() drops intermediate values, buffer() adds a buffer, and collectLatest cancels slow processing when new values arrive.A: Event sourcing when: (1) you need a complete audit trail (finance, healthcare), (2) you need to replay events to rebuild state or create new projections, (3) domain events drive other systems (CQRS). Traditional CRUD when: (1) simple CRUD operations, (2) team isn't familiar with event-driven patterns, (3) eventual consistency is unacceptable for the business.
inline modifier work and when should you use it?A:
inline replaces the function call with the function body at the call site โ no lambda object allocation, no virtual dispatch. Use it for: (1) higher-order functions called in tight loops (performance), (2) reified type parameters (inline fun <reified T> โ type info available at runtime). Don't inline large functions โ it increases bytecode size."Why Kotlin?" Focus on correctness (null safety), maintainability (expressiveness), and performance (Coroutines). Avoid subjective answers like "it's nicer" โ cite specific technical advantages with production impact.
Chapter 10: API Design & REST Best Practices
AdvancedA well-designed API is the most important interface in your system. It outlasts any implementation. Senior engineers are expected to design APIs that are consistent, evolvable, and self-documenting. This chapter covers RESTful design, error handling with ProblemDetail, versioning strategies, and cursor-based pagination.
10.1 RESTful Controller Design
Use proper HTTP methods: GET for reads, POST for creation (returns 201 Created with Location header), PATCH for partial updates, DELETE returns 204 No Content. Always return the created/updated resource in the response body.
// RESTful API with proper HTTP semantics@RestController@RequestMapping("/api/v1/products")class ProductController(private val productService: ProductService,private val assembler: ProductModelAssembler) {@GetMappingsuspend fun list(@RequestParam(defaultValue = "0") page: Int,@RequestParam(defaultValue = "20") size: Int,@RequestParam(required = false) category: String?): ResponseEntity<PagedModel<ProductResponse>> {val products = productService.findAll(PageRequest.of(page, size, Sort.by("createdAt").descending()),category)return ResponseEntity.ok(assembler.toPagedModel(products))}@PostMappingsuspend fun create(@Valid @RequestBody request: CreateProductRequest): ResponseEntity<ProductResponse> {val product = productService.create(request.toCommand())val uri = URI.create("/api/v1/products/${product.id}")return ResponseEntity.created(uri).body(product.toResponse())}@PatchMapping("/{id}")suspend fun update(@PathVariable id: Long,@Valid @RequestBody request: UpdateProductRequest): ResponseEntity<ProductResponse> {val product = productService.update(id, request.toCommand())return ResponseEntity.ok(product.toResponse())}@DeleteMapping("/{id}")@ResponseStatus(HttpStatus.NO_CONTENT)suspend fun delete(@PathVariable id: Long) {productService.delete(id)}}
10.2 Error Handling with RFC 9457 Problem Detail
Spring Boot 3+ natively supports ProblemDetail (RFC 9457) โ a standardized JSON error format. Stop returning ad-hoc error objects. Use @RestControllerAdvice with typed exception handlers. Include a type URI for documentation, and setProperty() for domain-specific fields.
// RFC 9457 Problem Detail โ structured error responses@RestControllerAdviceclass GlobalExceptionHandler : ResponseEntityExceptionHandler() {@ExceptionHandler(OrderNotFoundException::class)fun handleNotFound(ex: OrderNotFoundException): ProblemDetail =ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND,ex.message ?: "Resource not found").apply {title = "Order Not Found"type = URI.create("https://api.myapp.com/errors/order-not-found")setProperty("orderId", ex.orderId)}@ExceptionHandler(BusinessRuleViolation::class)fun handleBusinessRule(ex: BusinessRuleViolation): ProblemDetail =ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY,ex.message).apply {title = "Business Rule Violation"type = URI.create("https://api.myapp.com/errors/business-rule")setProperty("rule", ex.ruleName)}@ExceptionHandler(ConstraintViolationException::class)fun handleValidation(ex: ConstraintViolationException): ProblemDetail =ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,"Validation failed").apply {title = "Validation Error"setProperty("violations", ex.constraintViolations.map {mapOf("field" to it.propertyPath.toString(), "message" to it.message)})}}// Response format:// {// "type": "https://api.myapp.com/errors/order-not-found",// "title": "Order Not Found",// "status": 404,// "detail": "Order with ID 42 does not exist",// "orderId": 42// }
A: Use RFC 9457
ProblemDetail: consistent structure with type (URI to error docs), title (human-readable), status (HTTP code), detail (instance-specific message), and custom extension fields. Never expose stack traces or internal details. Map internal exceptions to business-friendly error codes.10.3 API Versioning
Three strategies: URI path (/api/v1/) โ most explicit, easy to route. Header-based (X-API-Version) โ cleaner URLs, harder to test. Content negotiation (Accept: application/vnd.myapi.v2+json) โ RESTful purist approach. For most teams, URI path versioning wins on simplicity.
// API Versioning โ URI path (most explicit, widely adopted)@RestController@RequestMapping("/api/v1/users")class UserControllerV1(private val service: UserService) {@GetMapping("/{id}")suspend fun getUser(@PathVariable id: Long): UserResponseV1 =service.findById(id).toResponseV1()}@RestController@RequestMapping("/api/v2/users")class UserControllerV2(private val service: UserService) {@GetMapping("/{id}")suspend fun getUser(@PathVariable id: Long): UserResponseV2 =service.findById(id).toResponseV2() // includes new fields}// API Versioning โ Header-based (cleaner URLs)@RestController@RequestMapping("/api/users")class UserController(private val service: UserService) {@GetMapping("/{id}", headers = ["X-API-Version=1"])suspend fun getUserV1(@PathVariable id: Long): UserResponseV1 =service.findById(id).toResponseV1()@GetMapping("/{id}", headers = ["X-API-Version=2"])suspend fun getUserV2(@PathVariable id: Long): UserResponseV2 =service.findById(id).toResponseV2()}
10.4 Cursor-Based Pagination
OFFSET pagination is O(n) โ the database scans and discards all previous rows. Keyset pagination uses a cursor (typically a timestamp or ID) and seeks directly to the position. This is O(1) and stable under concurrent writes. Always use keyset for large datasets.
// Keyset (cursor) pagination โ O(1) vs OFFSET O(n)@Repositoryclass ProductRepository(private val dsl: DSLContext) {// WRONG: offset pagination โ scans and discards rowsfun findAllOffset(page: Int, size: Int): List<Product> =dsl.selectFrom(PRODUCTS).orderBy(PRODUCTS.CREATED_AT.desc()).offset(page * size) // scans ALL previous rows!.limit(size).fetchInto(Product::class.java)// CORRECT: keyset pagination โ seeks directly to the cursorfun findAllKeyset(cursor: Instant?, size: Int): List<Product> =dsl.selectFrom(PRODUCTS).apply { cursor?.let { where(PRODUCTS.CREATED_AT.lt(it)) } }.orderBy(PRODUCTS.CREATED_AT.desc()).limit(size + 1) // fetch one extra to know if there's a next page.fetchInto(Product::class.java)}// Usage: GET /api/v1/products?cursor=2026-01-15T10:30:00Z&size=20// Response includes: { data: [...], nextCursor: "2026-01-15T09:00:00Z" }
OFFSET 100000 on a table with millions of rows will scan and discard 100,000 rows before returning results. With keyset pagination, the query jumps directly to the cursor position using an index seek. The difference can be 100x at scale.Chapter 11: Security & Authentication
ExpertSecurity is not a feature โ it's a constraint that affects every layer. Senior engineers must understand Spring Security configuration, JWT token lifecycle, OAuth2 resource servers, CORS policies, and method-level authorization. A single misconfiguration can expose your entire system.
11.1 Spring Security Kotlin DSL
Spring Security 6+ provides a Kotlin DSL that replaces the verbose Java HttpSecurity builder chain. Configure CSRF (disable for stateless APIs), CORS origins, authorization rules, OAuth2 resource server, and session management in a single readable block.
// Spring Security โ Kotlin DSL configuration@Configuration@EnableWebSecurityclass SecurityConfig {@Beanfun securityFilterChain(http: HttpSecurity): SecurityFilterChain =http {csrf { disable() } // stateless API, use CORS insteadcors { configurationSource = corsConfig() }authorizeHttpRequests {authorize("/api/v1/auth/**", permitAll)authorize("/api/v1/public/**", permitAll)authorize("/actuator/health", permitAll)authorize("/api/v1/admin/**", hasRole("ADMIN"))authorize(anyRequest, authenticated)}oauth2ResourceServer {jwt {jwtDecoder = jwtDecoder()}}sessionManagement {sessionCreationPolicy = SessionCreationPolicy.STATELESS}exceptionHandling {authenticationEntryPoint = HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)}}.build()@Beanfun corsConfig() = CorsConfigurationSource {CorsConfiguration().apply {allowedOrigins = listOf("https://app.mycompany.com")allowedMethods = listOf("GET", "POST", "PUT", "PATCH", "DELETE")allowedHeaders = listOf("*")allowCredentials = true}}}
A: CSRF protects against cross-site form submissions that use cookies for authentication. Stateless APIs that use
Authorization: Bearer headers are not vulnerable to CSRF because browsers don't automatically attach custom headers. Disabling CSRF for cookie-based auth would be a critical vulnerability.11.2 JWT Authentication
JWTs enable stateless authentication: the token contains the user's identity and roles, signed with a secret key. No session storage needed. Use HMAC-SHA256 for symmetric signing or RS256 for asymmetric (when multiple services need to validate tokens).
// JWT token generation and validation@Serviceclass JwtService(private val props: JwtProperties) {private val secretKey = Keys.hmacShaKeyFor(props.secret.toByteArray())fun generateToken(user: UserPrincipal): String =Jwts.builder().subject(user.id.toString()).claim("email", user.email).claim("roles", user.roles).issuedAt(Date()).expiration(Date(System.currentTimeMillis() + props.expiration.toMillis())).signWith(secretKey).compact()fun validateToken(token: String): Claims =Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).payloadfun extractUserId(token: String): Long =validateToken(token).subject.toLong()}// Authentication filter@Componentclass JwtAuthFilter(private val jwtService: JwtService,private val userService: UserService) : OncePerRequestFilter() {override fun doFilterInternal(request: HttpServletRequest,response: HttpServletResponse,chain: FilterChain) {val token = request.getHeader("Authorization")?.removePrefix("Bearer ")?.takeIf { it.isNotBlank() }if (token != null) {try {val userId = jwtService.extractUserId(token)val user = userService.loadById(userId)val auth = UsernamePasswordAuthenticationToken(user, null, user.authorities)SecurityContextHolder.getContext().authentication = auth} catch (e: JwtException) {// Invalid token โ continue without authentication}}chain.doFilter(request, response)}}
localStorage โ vulnerable to XSS. Use HttpOnly cookies for web apps, or keep tokens in memory and use refresh tokens. Always set short expiration times (15-30 min) and implement token rotation. Never put sensitive data in the JWT payload โ it's base64-encoded, not encrypted.11.3 Method-Level Security
Use @PreAuthorize and @PostAuthorize for fine-grained access control at the service layer. Spring Expression Language (SpEL) enables dynamic rules: "only the owner can access this resource" or "admins can access everything". Create custom annotations for reusable security patterns.
// Method-level security with custom annotations@EnableMethodSecurity(prePostEnabled = true)@Configurationclass MethodSecurityConfig// Built-in annotations@Serviceclass OrderService(private val repo: OrderRepository) {@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")suspend fun getOrdersForUser(userId: Long): List<Order> =repo.findByUserId(userId)@PreAuthorize("hasRole('ADMIN')")suspend fun deleteOrder(orderId: Long) =repo.deleteById(orderId)@PostAuthorize("returnObject.userId == authentication.principal.id")suspend fun getOrder(orderId: Long): Order =repo.findById(orderId) ?: throw OrderNotFoundException(orderId)}// Custom security expression@Target(AnnotationTarget.FUNCTION)@Retention(AnnotationRetention.RUNTIME)@PreAuthorize("hasRole('ADMIN') or @ownershipChecker.isOwner(#id, authentication)")annotation class OwnerOrAdmin@Serviceclass OwnershipChecker(private val repo: OrderRepository) {fun isOwner(id: Long, auth: Authentication): Boolean {val principal = auth.principal as UserPrincipalreturn repo.findById(id)?.userId == principal.id}}
SecurityFilterChain for URL-level rules, (2) @PreAuthorize for method-level, (3) domain-level checks in your business logic. If the security annotation is removed by accident, the domain validation still prevents unauthorized access.Chapter 12: Performance & Observability
ExpertYou can't improve what you can't measure. Staff engineers are expected to design systems with observability built in: multi-level caching, custom business metrics, structured logging with correlation IDs, and Kubernetes-native health checks. This chapter covers the full observability stack.
12.1 Multi-Level Caching
Use a two-tier cache: L1 (Caffeine, in-process, microsecond reads) for hot data, L2 (Redis, shared, millisecond reads) for consistency across instances. Spring's @Cacheable / @CacheEvict annotations make it declarative. Use CompositeCacheManager to chain both.
// Multi-level caching โ in-memory (Caffeine) + distributed (Redis)@Configuration@EnableCachingclass CacheConfig {@Beanfun cacheManager(redisConnectionFactory: RedisConnectionFactory): CacheManager =CompositeCacheManager(caffeineCacheManager(), // L1: in-process, fastredisCacheManager(redisConnectionFactory) // L2: shared, consistent).apply { setFallbackToNoOpCache(false) }private fun caffeineCacheManager() = CaffeineCacheManager().apply {setCaffeine(Caffeine.newBuilder().maximumSize(1000).expireAfterWrite(Duration.ofMinutes(5)).recordStats())}private fun redisCacheManager(factory: RedisConnectionFactory) =RedisCacheManager.builder(factory).cacheDefaults(RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(30)).serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(GenericJackson2JsonRedisSerializer()))).build()}// Usage with cache annotations@Serviceclass ProductService(private val repo: ProductRepository) {@Cacheable("products", key = "#id")suspend fun findById(id: Long): Product =repo.findById(id) ?: throw ProductNotFoundException(id)@CacheEvict("products", key = "#id")suspend fun update(id: Long, cmd: UpdateProductCommand): Product {val product = findById(id)return repo.save(product.update(cmd))}@CacheEvict("products", allEntries = true)suspend fun bulkImport(products: List<CreateProductCommand>) =repo.saveAll(products.map { it.toEntity() })}
A: L1 (in-memory) caches are instance-local โ updates on one instance won't invalidate others. Solutions: (1) Short TTL for L1 (5 min max). (2) Use Redis Pub/Sub to broadcast invalidation events. (3)
@CacheEvict only evicts L2 (Redis); L1 expires naturally. (4) For critical data, skip L1 entirely and use only L2.12.2 Custom Business Metrics
Technical metrics (http_requests_total) are not enough. Track business metrics: orders per minute, revenue by channel, conversion rates. Use Micrometer's Counter, Timer, and Gauge to expose these to Prometheus/Grafana. Add structured logging with correlation IDs for distributed tracing.
// Custom business metrics with Micrometer@Componentclass OrderMetrics(private val registry: MeterRegistry) {private val orderCounter = registry.counter("orders.created", "type", "all")private val orderTimer = registry.timer("orders.processing.time")private val activeOrders = registry.gauge("orders.active.count",AtomicInteger(0))!!fun recordOrderCreated(order: Order) {orderCounter.increment()registry.counter("orders.created", "channel", order.channel.name).increment()registry.counter("orders.revenue","currency", order.currency).increment(order.total.toDouble())}suspend fun <T> timeOrderProcessing(block: suspend () -> T): T {val sample = Timer.start(registry)return try {block()} finally {sample.stop(orderTimer)}}}// Structured logging with correlation IDs@Componentclass RequestLoggingFilter : WebFilter {override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {val requestId = exchange.request.headers.getFirst("X-Request-ID")?: UUID.randomUUID().toString()return chain.filter(exchange).contextWrite { ctx ->ctx.put("requestId", requestId)}.doOnEach { signal ->if (!signal.isOnComplete) {MDC.put("requestId", requestId)MDC.put("method", exchange.request.method.name())MDC.put("path", exchange.request.path.value())}}}}
12.3 Health Checks & Kubernetes Probes
Kubernetes needs three probes: liveness (is the app alive?), readiness (can it serve traffic?), and startup (has it finished initializing?). Spring Boot Actuator provides these out of the box. Add custom HealthIndicator implementations for each dependency (database, Kafka, external APIs).
// Custom health indicators for Kubernetes probes@Componentclass DatabaseHealthIndicator(private val dataSource: DataSource) : HealthIndicator {override fun health(): Health {return try {dataSource.connection.use { conn ->conn.createStatement().use { stmt ->stmt.executeQuery("SELECT 1").use { rs ->if (rs.next()) Health.up().withDetail("database", "PostgreSQL").withDetail("latency", measureTimeMillis { }).build()else Health.down().build()}}}} catch (e: Exception) {Health.down(e).build()}}}@Componentclass KafkaHealthIndicator(private val adminClient: AdminClient) : HealthIndicator {override fun health(): Health {return try {val nodes = adminClient.describeCluster().nodes().get(5, TimeUnit.SECONDS)Health.up().withDetail("brokers", nodes.size).withDetail("clusterId", adminClient.describeCluster().clusterId().get()).build()} catch (e: Exception) {Health.down(e).withDetail("error", e.message).build()}}}// application.yml// management:// endpoints:// web:// exposure:// include: health, metrics, prometheus// endpoint:// health:// show-details: when_authorized// probes:// enabled: true # /actuator/health/liveness, /readiness
Chapter 13: Kotlin Advanced Type System
AdvancedKotlin's type system goes far beyond null safety. sealed classes enable exhaustive pattern matching, value classes provide type-safe wrappers with zero overhead, delegation replaces inheritance, and DSL builders create domain-specific languages. Mastering these puts you in the top tier of Kotlin developers.
13.1 Sealed Classes & Interfaces
sealed class restricts hierarchy โ all subtypes must be defined in the same package. The compiler knows every possible subtype, enabling exhaustive when expressions without else. Adding a new subtype forces you to update every when โ the compiler catches missing cases.
// Sealed classes โ exhaustive when, type-safe hierarchiessealed class PaymentResult {data class Success(val transactionId: String, val amount: Money) : PaymentResult()data class Declined(val reason: String, val code: Int) : PaymentResult()data class RequiresAction(val actionUrl: String) : PaymentResult()data object NetworkError : PaymentResult()}// Compiler enforces exhaustive when โ no "else" neededfun handlePayment(result: PaymentResult): String = when (result) {is PaymentResult.Success -> "Paid: ${result.transactionId}"is PaymentResult.Declined -> "Declined: ${result.reason} (code ${result.code})"is PaymentResult.RequiresAction -> "Redirect to: ${result.actionUrl}"is PaymentResult.NetworkError -> "Network error, retry later"// Adding a new subclass forces updating ALL when expressions}// Sealed interfaces โ multiple inheritance for domain eventssealed interface DomainEvent {val occurredAt: Instant}sealed interface OrderEvent : DomainEvent {val orderId: OrderId}data class OrderPlaced(override val orderId: OrderId,override val occurredAt: Instant,val items: List<OrderItem>) : OrderEventdata class OrderShipped(override val orderId: OrderId,override val occurredAt: Instant,val trackingNumber: String) : OrderEvent
sealed class vs enum class?A: Use
enum when all variants are singletons with the same data (e.g., Status.ACTIVE, Status.INACTIVE). Use sealed class when variants carry different data (e.g., Success(data) vs Error(message, code)). Sealed classes are Kotlin's equivalent of Rust's enum or Haskell's algebraic data types.13.2 Value Classes (Inline Classes)
@JvmInline value class wraps a single property with zero runtime overhead โ the wrapper is erased at compile time. Use them to prevent "primitive obsession": instead of passing Long for every ID, use UserId, OrderId, ProductId. The compiler prevents mixing them up.
// Value classes โ type safety without runtime overhead@JvmInlinevalue class UserId(val value: Long)@JvmInlinevalue class OrderId(val value: Long)@JvmInlinevalue class Email(val value: String) {init {require(value.contains("@")) { "Invalid email: $value" }}}@JvmInlinevalue class Money(val cents: Long) {operator fun plus(other: Money) = Money(cents + other.cents)operator fun times(quantity: Int) = Money(cents * quantity)fun toFormattedString(): String ="$${cents / 100}.${(cents % 100).toString().padStart(2, '0')}"companion object {fun fromDollars(dollars: Double) = Money((dollars * 100).toLong())}}// Compile-time safety โ can't accidentally swap IDsfun transferMoney(from: UserId, // can't pass an OrderId here!to: UserId,amount: Money) { /* ... */ }// At runtime, these are just Long/String โ zero overheadval userId = UserId(42)val email = Email("alice@example.com")val price = Money.fromDollars(29.99)
transferMoney(orderId, userId, amount) โ silently swapping user and order IDs. With value classes, it's a compile-time error. This is especially critical in financial systems where ID confusion can cause real money to go to the wrong account.13.3 Delegation Pattern
Kotlin's by keyword enables class delegation: implement an interface by delegating to another object. Override only the methods you need โ the rest forward automatically. This replaces inheritance-heavy designs with clean composition. Property delegation (lazy, observable, map) is equally powerful.
// Delegation โ composition over inheritanceinterface Logger {fun info(message: String)fun error(message: String, throwable: Throwable? = null)}class Slf4jLogger(name: String) : Logger {private val delegate = LoggerFactory.getLogger(name)override fun info(message: String) = delegate.info(message)override fun error(message: String, throwable: Throwable?) =delegate.error(message, throwable)}// Class delegation with "by" keywordclass AuditedOrderService(private val delegate: OrderService,private val logger: Logger) : OrderService by delegate {// Only override what you need โ rest delegates automaticallyoverride suspend fun create(command: CreateOrderCommand): Order {logger.info("Creating order for customer: ${command.customerId}")return delegate.create(command).also {logger.info("Order created: ${it.id}")}}}// Property delegation โ lazy, observable, map-backedclass AppConfig(properties: Map<String, String>) {val dbUrl: String by properties // backed by mapval maxRetries: Int by lazy { loadFromVault("max-retries").toInt() }var debugMode: Boolean by Delegates.observable(false) { _, old, new ->logger.info("Debug mode changed: $old -> $new")}}
13.4 Type-Safe DSL Builders
Kotlin's lambda-with-receiver (T.() -> Unit) enables building fluent DSLs. This is the same mechanism behind buildList, apply, Ktor routes, Gradle build scripts, and Spring's Kotlin DSL. Understanding this pattern lets you create domain-specific APIs that read like English.
// Type-safe DSL builders โ Kotlin's killer feature// HTML DSL examplefun html(init: HtmlBuilder.() -> Unit): String =HtmlBuilder().apply(init).build()class HtmlBuilder {private val elements = mutableListOf<String>()fun head(init: HeadBuilder.() -> Unit) {elements += HeadBuilder().apply(init).build()}fun body(init: BodyBuilder.() -> Unit) {elements += BodyBuilder().apply(init).build()}fun build() = "<html>${elements.joinToString("\n")}</html>"}// Query DSL for your domainclass QueryBuilder<T> {private val conditions = mutableListOf<String>()private var orderField: String? = nullprivate var limitValue: Int = 100fun where(field: String, op: String, value: Any) {conditions += "$field $op '$value'"}fun orderBy(field: String) { orderField = field }fun limit(n: Int) { limitValue = n }infix fun String.eq(value: Any) = where(this, "=", value)infix fun String.gt(value: Any) = where(this, ">", value)}// Usage โ reads like Englishval query = buildQuery<User> {"status" eq "active""age" gt 18orderBy("created_at")limit(50)}
.withX() calls, a DSL with lambda-with-receiver will be more readable and discoverable via IDE autocomplete.Chapter 14: DevOps & Deployment
AdvancedStaff engineers own the full lifecycle โ from code to production. This chapter covers multi-stage Docker builds, GraalVM native compilation for instant startup, Kubernetes deployments with proper health probes, and Helm charts for parameterized deployments. These are the skills that turn a backend developer into a platform engineer.
14.1 Multi-Stage Docker Builds
Always use multi-stage builds: the builder stage has the JDK and Gradle for compilation, the runtime stage has only the JRE. This reduces image size from ~800MB to ~200MB. Run as a non-root user, tune JVM flags for containers (-XX:+UseContainerSupport), and add a HEALTHCHECK instruction.
# Multi-stage Dockerfile for Kotlin Spring Boot
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
# Cache Gradle dependencies
COPY build.gradle.kts settings.gradle.kts gradle.properties ./
COPY gradle ./gradle
RUN ./gradlew dependencies --no-daemon
# Build the app
COPY src ./src
RUN ./gradlew bootJar --no-daemon -x test
# Runtime stage โ minimal image
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
# Security: non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
COPY --from=builder /app/build/libs/*.jar app.jar
# JVM tuning for containers
ENV JAVA_OPTS="-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-Djava.security.egd=file:/dev/./urandom"
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -q --spider http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]-XX:MaxRAMPercentage=75.0 important in containers?A: Without it, the JVM may try to use more memory than the container limit, causing OOMKill.
UseContainerSupport makes the JVM aware of cgroup limits, and MaxRAMPercentage caps heap usage at 75% of the container memory, leaving room for off-heap memory (metaspace, thread stacks, NIO buffers).14.2 GraalVM Native Images
GraalVM compiles your Kotlin/Spring Boot app to a native binary at build time. Result: ~50ms startup (vs ~3s JVM), ~50MB memory (vs ~256MB), 25MB binary. The tradeoff: longer build times (~5 min), no JIT optimization at runtime, and reflection must be declared ahead of time.
// GraalVM Native Image โ millisecond startup// build.gradle.ktsplugins {id("org.graalvm.buildtools.native") version "0.10.4"}graalvmNative {binaries {named("main") {imageName.set("my-service")mainClass.set("com.myapp.ApplicationKt")buildArgs.add("--enable-preview")buildArgs.add("-H:+ReportExceptionStackTraces")// Spring AOT processes beans at build timejvmArgs.add("-Dspring.aot.enabled=true")}}}// Reflection hints for GraalVM (if needed)@RegisterReflectionForBinding(UserDto::class,OrderDto::class,PaymentResult::class)@SpringBootApplicationclass Application// Build: ./gradlew nativeCompile// Result: 25MB binary, starts in ~50ms vs ~3s JVM// Tradeoff: longer build time (~5min), no JIT optimization at runtime
| Metric | JVM (JIT) | GraalVM Native |
|---|---|---|
| Startup time | 2-5 seconds | ~50 milliseconds |
| Memory usage | 256-512 MB | 50-100 MB |
| Peak throughput | Higher (JIT optimized) | Lower (AOT only) |
| Build time | ~30 seconds | ~5 minutes |
| Best for | Long-running services | Serverless, CLI, scale-to-zero |
14.3 Kubernetes Deployment
A production Kubernetes deployment needs: RollingUpdate strategy with maxUnavailable: 0 for zero-downtime, proper resource requests/limits, liveness/readiness/startup probes, Prometheus annotations for metrics scraping, and secrets mounted from SecretKeyRef.
# Kubernetes Deployment with proper health checksapiVersion: apps/v1kind: Deploymentmetadata:name: order-servicelabels:app: order-serviceversion: v1spec:replicas: 3strategy:type: RollingUpdaterollingUpdate:maxSurge: 1maxUnavailable: 0 # zero-downtime deploymentsselector:matchLabels:app: order-servicetemplate:metadata:labels:app: order-serviceannotations:prometheus.io/scrape: "true"prometheus.io/port: "8080"prometheus.io/path: "/actuator/prometheus"spec:containers:- name: order-serviceimage: registry.mycompany.com/order-service:1.2.3ports:- containerPort: 8080resources:requests:memory: "256Mi"cpu: "250m"limits:memory: "512Mi"cpu: "1000m"env:- name: SPRING_PROFILES_ACTIVEvalue: "prod"- name: DB_PASSWORDvalueFrom:secretKeyRef:name: db-credentialskey: passwordlivenessProbe:httpGet:path: /actuator/health/livenessport: 8080initialDelaySeconds: 15periodSeconds: 10readinessProbe:httpGet:path: /actuator/health/readinessport: 8080initialDelaySeconds: 5periodSeconds: 5startupProbe:httpGet:path: /actuator/health/livenessport: 8080failureThreshold: 30periodSeconds: 2
14.4 Helm Charts for Parameterized Deployments
Helm packages Kubernetes manifests as reusable charts with environment-specific values. Use values.yaml for defaults, override with values-prod.yaml. Enable HorizontalPodAutoscaler for auto-scaling, configure Ingress with TLS via cert-manager, and set rate limits at the ingress level.
# Helm values.yaml โ parameterized deploymentreplicaCount: 3image:repository: registry.mycompany.com/order-servicetag: "1.2.3"pullPolicy: IfNotPresentservice:type: ClusterIPport: 8080ingress:enabled: trueclassName: nginxannotations:cert-manager.io/cluster-issuer: letsencrypt-prodnginx.ingress.kubernetes.io/rate-limit: "100"hosts:- host: api.mycompany.compaths:- path: /api/v1/orderspathType: Prefixtls:- secretName: api-tlshosts:- api.mycompany.comresources:requests:memory: 256Micpu: 250mlimits:memory: 512Micpu: 1000mautoscaling:enabled: trueminReplicas: 3maxReplicas: 10targetCPUUtilizationPercentage: 70targetMemoryUtilizationPercentage: 80# Environment-specific overrides# helm install order-service ./chart -f values-prod.yaml
Chapter 15: Microservices Architecture
ExpertMicroservices decompose a monolith into independently deployable services, each owning its own data store and business capability. While the pattern enables team autonomy and independent scaling, it introduces significant distributed systems complexity โ network partitions, data consistency challenges, and operational overhead. This chapter covers the gateway pattern, service discovery, and inter-service communication strategies that production systems at scale actually use.
- API Gateway pattern with Spring Cloud Gateway โ routing, rate limiting, and circuit breakers
- Service discovery with Kubernetes-native approaches and load-balanced WebClient
- Event-driven inter-service communication with Kafka for eventual consistency
- When to choose REST, gRPC, or async messaging between services
15.1 API Gateway Pattern
The API Gateway is the single entry point for all client traffic. It handles cross-cutting concerns like authentication, rate limiting, circuit breaking, and request routing โ keeping these concerns out of individual microservices. Spring Cloud Gateway provides a reactive, non-blocking gateway built on Project Reactor and Netty. Each route can have its own CircuitBreaker, retry policy, and RequestRateLimiter. Always remove sensitive headers (like Cookie) before forwarding to internal services, and use lb:// URIs for load-balanced routing via service discovery.
// Spring Cloud Gateway โ Kotlin DSL routes@Configurationclass GatewayConfig {@Beanfun routeLocator(builder: RouteLocatorBuilder): RouteLocator =builder.routes {route("order-service") {path("/api/v1/orders/**")filters {circuitBreaker {setName("orderServiceCB")setFallbackUri("forward:/fallback/orders")}retry { setRetries(3) }requestRateLimiter {setRateLimiter(redisRateLimiter())setKeyResolver(userKeyResolver())}removeRequestHeader("Cookie") // no cookies to microservices}uri("lb://order-service") // load-balanced via service discovery}route("user-service") {path("/api/v1/users/**")filters {circuitBreaker { setName("userServiceCB") }addRequestHeader("X-Internal-Call", "true")}uri("lb://user-service")}}}
15.2 Service Discovery & Internal Communication
In a microservices world, services must find each other without hardcoded URLs. Kubernetes-native discovery uses DNS (http://order-service resolves via kube-dns), while Spring Cloud Discovery adds features like health-aware routing and zone affinity. For internal high-throughput communication, gRPC offers binary serialization (Protocol Buffers) with ~10x lower latency than JSON REST. Use @LoadBalanced WebClient for HTTP-based calls and gRPC for performance-critical service-to-service paths.
// Service registration with Spring Cloud + Kubernetes// application.yml โ each microservice registers itself// spring:// application:// name: order-service// cloud:// kubernetes:// discovery:// enabled: true// all-namespaces: false// Using WebClient with load balancing (no hardcoded URLs)@Serviceclass UserClient(@LoadBalanced private val webClientBuilder: WebClient.Builder) {private val client = webClientBuilder.baseUrl("http://user-service") // resolved via service discovery.build()suspend fun getUser(userId: Long): UserDto =client.get().uri("/api/v1/users/{id}", userId).retrieve().awaitBody<UserDto>()}// gRPC for internal high-performance communication@GrpcServiceclass OrderGrpcService(private val orderService: OrderService) : OrderServiceGrpcKt.OrderServiceCoroutineImplBase() {override suspend fun getOrder(request: GetOrderRequest): OrderResponse {val order = orderService.findById(OrderId(request.orderId))return order.toGrpcResponse()}}
A: gRPC excels for high-throughput, low-latency internal communication: binary serialization is ~5-10x faster than JSON, HTTP/2 multiplexing avoids head-of-line blocking, and
.proto files provide strong contracts. REST is better for external APIs (browser-friendly, widely tooled) and services with simple request/response patterns. A common production setup uses REST at the gateway for external clients and gRPC internally between services.15.3 Event-Driven Microservice Communication
Synchronous inter-service calls create tight coupling and cascade failures. Event-driven architecture decouples services: the Order Service publishes an OrderCreatedEvent to Kafka, and the Inventory Service consumes it asynchronously. This achieves eventual consistency โ the inventory won't update in the same millisecond, but the system is resilient to individual service outages. The key pattern is "publish events for state changes, consume events for reactions."
// Event-driven communication between microservices// Order Service publishes events to Kafka@Serviceclass OrderEventPublisher(private val kafka: KafkaTemplate<String, DomainEvent>) {suspend fun publishOrderCreated(order: Order) {val event = OrderCreatedEvent(orderId = order.id.value,customerId = order.customerId.value,totalAmount = order.total.cents,items = order.items.map { it.toEventItem() },timestamp = Instant.now())kafka.send("order-events", order.id.toString(), event).await()}}// Inventory Service consumes events โ eventually consistent@Componentclass InventoryEventConsumer(private val inventoryService: InventoryService) {@KafkaListener(topics = ["order-events"],groupId = "inventory-service",containerFactory = "kafkaListenerContainerFactory")suspend fun handleOrderEvent(event: OrderCreatedEvent) {event.items.forEach { item ->inventoryService.reserveStock(productId = ProductId(item.productId),quantity = item.quantity,orderId = OrderId(event.orderId))}}}
OrderCreatedEvent. The Inventory Service reserves stock, the Payment Service charges the card, and the Notification Service sends a confirmation email โ all reacting independently to the same event. If the Payment Service is temporarily down, messages queue in Kafka and are processed when it recovers. No data is lost, and other services continue unaffected.Chapter 16: Database Design & Optimization
AdvancedDatabase performance is the bottleneck of most backend systems. A well-indexed PostgreSQL database can serve 50,000 queries per second on modest hardware, while a poorly indexed one struggles at 500. This chapter covers strategic indexing (the single highest-impact optimization), query patterns to avoid (N+1 is the classic trap), and connection pool tuning that prevents your application from starving under load.
- Composite, partial, covering, and expression indexes โ when to use each type
- Detecting and eliminating N+1 queries with JOINs and batch fetching
- HikariCP connection pool sizing and tuning for production workloads
- Read replica routing for scaling read-heavy workloads
16.1 Strategic Indexing
Indexes are the single biggest performance lever in any relational database. A composite index on (customer_id, status, created_at DESC) covers your most common query pattern in a single B-tree lookup. Partial indexes (with a WHERE clause) index only the rows you actually query โ a partial index on status = 'PENDING' is 90% smaller than a full index. Covering indexes use INCLUDE to add non-key columns, enabling index-only scans that skip the table heap entirely. Expression indexes like LOWER(email) support case-insensitive lookups efficiently.
// Strategic indexing โ the single biggest performance lever
-- Composite index for common query patterns
CREATE INDEX idx_orders_customer_status_created
ON orders (customer_id, status, created_at DESC);
-- Covers: WHERE customer_id = ? AND status = ? ORDER BY created_at DESC
-- Partial index โ only index what you actually query
CREATE INDEX idx_orders_pending
ON orders (created_at DESC)
WHERE status = 'PENDING';
-- 90% smaller than a full index, 10x faster for pending order queries
-- Covering index โ eliminates table lookups entirely
CREATE INDEX idx_products_category_covering
ON products (category_id)
INCLUDE (name, price, stock_count);
-- SELECT name, price, stock_count WHERE category_id = ? โ index-only scan
-- Expression index for case-insensitive search
CREATE INDEX idx_users_email_lower
ON users (LOWER(email));
-- WHERE LOWER(email) = 'alice@example.com' โ uses the indexEXPLAIN ANALYZE to verify the optimizer uses your index before leaving it in production.16.2 Query Optimization Patterns
The N+1 problem is the most common performance bug in ORM-based applications. It happens when you load a list of entities, then lazily load a related entity for each one โ resulting in 1 query for the list plus N queries for the relations. The fix is straightforward: use an explicit JOIN query with projection, or use @EntityGraph in JPA to eager-fetch relations in a single query. This turns N+1 queries into 1 query, often reducing response time from seconds to milliseconds.
// Query optimization patterns in Kotlin@Repositoryclass OrderAnalyticsRepository(private val jdbcTemplate: NamedParameterJdbcTemplate) {// BAD: N+1 โ loads orders, then each customer separatelysuspend fun getOrdersWithCustomersBad(): List<OrderWithCustomer> {val orders = orderRepo.findAll() // query 1return orders.map { order ->val customer = customerRepo.findById(order.customerId) // query NOrderWithCustomer(order, customer!!)}}// GOOD: Single query with JOIN + projectionfun getOrdersWithCustomersGood(status: OrderStatus,limit: Int): List<OrderWithCustomer> =jdbcTemplate.query("""SELECT o.id, o.total, o.created_at,c.id AS customer_id, c.name, c.emailFROM orders oJOIN customers c ON c.id = o.customer_idWHERE o.status = :statusORDER BY o.created_at DESCLIMIT :limit""", mapOf("status" to status.name, "limit" to limit)) { rs, _ ->OrderWithCustomer(orderId = rs.getLong("id"),total = rs.getBigDecimal("total"),customerName = rs.getString("name"),customerEmail = rs.getString("email"))}}
A: (1) Run
EXPLAIN ANALYZE to get the execution plan โ look for sequential scans on large tables, nested loop joins, and sort operations. (2) Check if the right indexes exist for the WHERE/ORDER BY columns. (3) Verify the query planner is using the indexes (sometimes a stale statistics leads to bad plans โ run ANALYZE). (4) Consider if the query can be restructured to use a covering index. (5) If it's a reporting query, consider materializing it or using a read replica.16.3 Connection Pool Tuning
Connection pools prevent your application from opening/closing database connections on every request (each connection costs ~3MB of PostgreSQL memory and ~150ms to establish). HikariCP is the default in Spring Boot and the fastest JVM connection pool. The optimal pool size follows the formula: (2 * CPU cores) + number of spinning disks. For a 4-core server with SSDs, that's about 10 connections. More connections means more contention and context switching โ bigger is not better. Set maxLifetime slightly below the database's connection timeout to prevent stale connections, and enable leakDetectionThreshold in development to catch leaked connections early.
// HikariCP tuning for production workloads@Configurationclass DataSourceConfig {@Bean@ConfigurationProperties(prefix = "spring.datasource.hikari")fun dataSource(): HikariDataSource = HikariDataSource().apply {// Pool size = (2 * CPU cores) + number of spinning disks// For SSD with 4 cores: pool size = 10maximumPoolSize = 10minimumIdle = 5// Connection lifetime โ rotate before DB timeout (usually 30 min)maxLifetime = Duration.ofMinutes(25).toMillis()// Fail fast if no connection availableconnectionTimeout = Duration.ofSeconds(5).toMillis()// Detect leaked connections in developmentleakDetectionThreshold = Duration.ofSeconds(30).toMillis()// Validation query (lightweight, no table scan)connectionTestQuery = "SELECT 1"}}// application.yml for read replicas// spring:// datasource:// primary:// url: jdbc:postgresql://primary-db:5432/myapp// replica:// url: jdbc:postgresql://replica-db:5432/myapp// hikari:// read-only: true
Chapter 17: Event-Driven Architecture
ExpertEvent-driven architecture (EDA) is the backbone of modern distributed systems. Instead of synchronous request-response chains, services communicate through events โ immutable facts that describe something that happened. This decouples producers from consumers, enables temporal decoupling (consumers can be offline), and creates a natural audit trail. This chapter covers Apache Kafka configuration for exactly-once delivery, the Transactional Outbox pattern for atomic database + event publishing, consumer error handling with Dead Letter Queues, and Event Sourcing for reconstructing state from event history.
- Kafka producer configuration with idempotence and exactly-once semantics
- Transactional Outbox pattern โ solving the dual-write problem
- Consumer error handling with manual acknowledgment and Dead Letter Queues
- Event Sourcing โ storing events as the source of truth and rebuilding state
17.1 Kafka Producer & Transactional Outbox
The dual-write problem is a classic distributed systems trap: you need to save data to your database AND publish an event to Kafka, but these are two separate systems that can't participate in the same transaction. If the DB write succeeds but Kafka publish fails, you have inconsistency. The Transactional Outbox pattern solves this elegantly: write the event to an outbox table in the same database transaction as your business data. A separate poller (or CDC connector like Debezium) reads the outbox and publishes to Kafka. This guarantees at-least-once delivery with no data loss.
// Kafka producer with guaranteed delivery@Configurationclass KafkaProducerConfig {@Beanfun producerFactory(): ProducerFactory<String, DomainEvent> =DefaultKafkaProducerFactory(mapOf(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to "kafka:9092",ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to JsonSerializer::class.java,// Exactly-once semanticsProducerConfig.ENABLE_IDEMPOTENCE_CONFIG to true,ProducerConfig.ACKS_CONFIG to "all",ProducerConfig.RETRIES_CONFIG to 3,ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION to 1))}// Transactional outbox pattern โ atomic DB + event publishing@Serviceclass OrderService(private val orderRepo: OrderRepository,private val outboxRepo: OutboxRepository,@Transactional private val txManager: PlatformTransactionManager) {@Transactionalsuspend fun createOrder(command: CreateOrderCommand): Order {val order = Order.create(command.customerId, command.items)val saved = orderRepo.save(order)// Write event to outbox table in SAME transactionoutboxRepo.save(OutboxEvent(aggregateType = "Order",aggregateId = saved.id.toString(),eventType = "OrderCreated",payload = Json.encodeToString(OrderCreatedEvent(saved))))return saved// A separate poller reads outbox and publishes to Kafka}}
A: At-most-once: consumer commits offset before processing โ if it crashes mid-processing, the message is skipped (data loss). At-least-once: consumer processes first, then commits โ if it crashes after processing but before committing, the message is reprocessed (duplicates). Exactly-once: Kafka's idempotent producer (
enable.idempotence=true) prevents duplicate writes, and transactional consumers use read-committed isolation to see only completed transactions. In practice, most systems use at-least-once with idempotent consumers (processing the same event twice produces the same result).17.2 Kafka Consumer & Error Handling
Consumer reliability is just as important as producer reliability. Disable auto.commit and use manual acknowledgment โ this ensures you only commit offsets after successful processing. When a message fails, the DefaultErrorHandler retries with configurable backoff (e.g., 3 retries with 1-second intervals). After exhausting retries, the message is sent to a Dead Letter Queue (DLQ) topic for manual investigation. Always set concurrency on the listener container to match your partition count for maximum parallelism.
// Kafka consumer with error handling and DLQ@Configuration@EnableKafkaclass KafkaConsumerConfig {@Beanfun consumerFactory(): ConsumerFactory<String, String> =DefaultKafkaConsumerFactory(mapOf(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to "kafka:9092",ConsumerConfig.GROUP_ID_CONFIG to "payment-service",ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to "earliest",ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to false, // manual commitConsumerConfig.MAX_POLL_RECORDS_CONFIG to 100))@Beanfun kafkaListenerContainerFactory(consumerFactory: ConsumerFactory<String, String>): ConcurrentKafkaListenerContainerFactory<String, String> =ConcurrentKafkaListenerContainerFactory<String, String>().apply {this.consumerFactory = consumerFactorysetConcurrency(3) // 3 consumer threadscontainerProperties.ackMode = ContainerProperties.AckMode.MANUAL_IMMEDIATE// Retry 3 times, then send to DLQsetCommonErrorHandler(DefaultErrorHandler(DeadLetterPublishingRecoverer(kafkaTemplate),FixedBackOff(1000L, 3)))}}// Consumer with manual acknowledgment@Componentclass PaymentEventConsumer(private val paymentService: PaymentService) {@KafkaListener(topics = ["order-events"], groupId = "payment-service")suspend fun handleOrderCreated(record: ConsumerRecord<String, String>,ack: Acknowledgment) {val event = Json.decodeFromString<OrderCreatedEvent>(record.value())try {paymentService.processPayment(event)ack.acknowledge() // commit offset only on success} catch (e: RetryableException) {throw e // will be retried by error handler} catch (e: Exception) {logger.error("Non-retryable error for order ${event.orderId}", e)ack.acknowledge() // skip to DLQ}}}
auto.commit=true in production. If your consumer crashes after polling but before processing, those messages are lost (committed but not processed). Manual acknowledgment with AckMode.MANUAL_IMMEDIATE ensures you only commit offsets for messages you've actually handled. This is the difference between "works in dev" and "works in production."17.3 Event Sourcing
Event Sourcing flips the traditional persistence model: instead of storing the current state, you store every state change as an immutable event. The current state is reconstructed by replaying events from the beginning (or from a snapshot). This provides a complete audit trail, enables temporal queries ("what was the order status at 3pm yesterday?"), and allows projecting the same events into different read models. The tradeoff is complexity โ you need event versioning, snapshot optimization, and CQRS (Command Query Responsibility Segregation) to keep read performance acceptable as event history grows.
// Event Sourcing โ reconstruct state from event history// Events are immutable facts that happenedsealed class OrderEvent {abstract val orderId: Stringabstract val timestamp: Instantdata class OrderPlaced(override val orderId: String,override val timestamp: Instant,val customerId: String,val items: List<OrderItem>) : OrderEvent()data class PaymentReceived(override val orderId: String,override val timestamp: Instant,val transactionId: String,val amount: Money) : OrderEvent()data class OrderShipped(override val orderId: String,override val timestamp: Instant,val trackingNumber: String,val carrier: String) : OrderEvent()}// Rebuild current state by replaying eventsclass OrderAggregate {var status: OrderStatus = OrderStatus.UNKNOWNprivate setvar items: List<OrderItem> = emptyList()private setvar trackingNumber: String? = nullprivate setfun apply(event: OrderEvent) {when (event) {is OrderEvent.OrderPlaced -> {status = OrderStatus.PLACEDitems = event.items}is OrderEvent.PaymentReceived -> {status = OrderStatus.PAID}is OrderEvent.OrderShipped -> {status = OrderStatus.SHIPPEDtrackingNumber = event.trackingNumber}}}companion object {fun fromHistory(events: List<OrderEvent>): OrderAggregate =OrderAggregate().apply { events.forEach { apply(it) } }}}
Chapter 18: Kotlin Coroutines Deep Dive
ExpertChapter 5 introduced coroutines vs virtual threads. This chapter goes deeper into the mechanics: dispatchers (where coroutines execute), structured concurrency (how failures propagate), exception handling patterns for production reliability, and testing strategies with virtual time. Mastering these concepts separates engineers who "use coroutines" from engineers who "understand coroutines." At the staff level, you're expected to design coroutine architectures, debug subtle concurrency bugs, and teach your team the right patterns.
- Dispatcher selection โ
Defaultfor CPU,IOfor blocking,limitedParallelismfor resource protection - Structured concurrency with
supervisorScopefor graceful partial failures CoroutineExceptionHandlerand therunCatchingpattern for production error handling- Testing coroutines with
runTest,StandardTestDispatcher, and Turbine for Flows
18.1 Dispatchers โ Where Coroutines Execute
Every coroutine runs on a dispatcher, which determines which thread (or thread pool) executes it. Dispatchers.Default uses a shared pool sized to the number of CPU cores โ use it for CPU-bound computation like JSON parsing or analytics. Dispatchers.IO uses a larger elastic pool (up to 64 threads) designed for blocking I/O like JDBC calls or file reads. limitedParallelism(n) creates a view of a dispatcher with at most n concurrent coroutines โ perfect for protecting limited resources like database connection pools. The critical rule: never do blocking I/O on Dispatchers.Default โ it will starve the shared pool and freeze all CPU-bound work in the application.
// Understanding Dispatchers โ where coroutines executesuspend fun processOrder(orderId: Long) = coroutineScope {// Dispatchers.Default โ CPU-bound work (computation, JSON parsing)val analytics = async(Dispatchers.Default) {calculateOrderAnalytics(orderId) // heavy computation}// Dispatchers.IO โ blocking I/O (JDBC, file reads, legacy APIs)val orderData = async(Dispatchers.IO) {legacyOrderDao.findById(orderId) // blocking JDBC call}// Dispatchers.Unconfined โ starts in caller's thread, resumes in// whatever thread the suspend function resumes on (rarely used)// No dispatcher = inherits from parent scopeval enrichedOrder = async {enrichOrder(orderData.await()) // inherits parent dispatcher}OrderResult(analytics = analytics.await(),order = enrichedOrder.await())}// Custom limited dispatcher for resource protectionval dbDispatcher = Dispatchers.IO.limitedParallelism(10)// Max 10 concurrent DB operations โ prevents connection pool exhaustionsuspend fun queryWithLimit() = withContext(dbDispatcher) {repository.findExpensiveQuery() // at most 10 concurrent calls}
Dispatchers.Default?A:
Dispatchers.Default has only N threads (where N = CPU cores). If all threads are blocked on JDBC calls, no CPU-bound coroutines can execute โ the entire application hangs. This is called "thread starvation." The fix: always wrap blocking calls in withContext(Dispatchers.IO), or better, use Dispatchers.IO.limitedParallelism(poolSize) to cap concurrent blocking operations at your connection pool size.18.2 Structured Concurrency & Exception Handling
Structured concurrency ensures that when a parent coroutine is cancelled, all its children are cancelled too โ no orphan coroutines leaking resources. supervisorScope is the key pattern for production code: unlike regular coroutineScope, a child failure in supervisorScope doesn't cancel siblings. This is critical for dashboard-style APIs where you fetch data from multiple sources and want partial results even if one source fails. Combine supervisorScope with runCatching and getOrElse to provide graceful fallbacks for each parallel operation.
// Structured exception handling in coroutines// SupervisorJob โ child failures don't cancel siblingsval scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)// CoroutineExceptionHandler โ last resort for uncaught exceptionsval handler = CoroutineExceptionHandler { _, exception ->logger.error("Uncaught coroutine exception", exception)metrics.incrementCounter("coroutine.unhandled.errors")}// Pattern: supervised fan-out with individual error handlingsuspend fun fetchDashboardData(userId: Long): Dashboard = supervisorScope {// Each async can fail independentlyval profile = async {runCatching { userService.getProfile(userId) }.getOrElse { UserProfile.DEFAULT }}val orders = async {runCatching { orderService.getRecent(userId) }.getOrElse { emptyList() }}val recommendations = async {runCatching { mlService.recommend(userId) }.getOrDefault(emptyList())}Dashboard(profile = profile.await(),orders = orders.await(),recommendations = recommendations.await())// Even if ML service is down, dashboard renders with partial data}
coroutineScope, one ML failure cancels everything and returns a 500. With supervisorScope + runCatching, the dashboard renders with the profile and orders, and shows "recommendations unavailable" โ a much better user experience.18.3 Testing Coroutines
Coroutine tests use runTest which provides virtual time โ delays complete instantly without actual waiting. Use StandardTestDispatcher to control exactly when coroutines execute (unlike UnconfinedTestDispatcher which runs eagerly). For testing Flow emissions, the Turbine library provides an elegant API: flow.test { awaitItem() } lets you assert on each emission sequentially. Always inject dispatchers rather than hardcoding them, so tests can substitute test dispatchers for predictable, deterministic execution.
// Testing coroutines with runTest and turbineclass OrderServiceTest {@Testfun `should process orders concurrently`() = runTest {val service = OrderService(repo = FakeOrderRepository(),dispatcher = StandardTestDispatcher(testScheduler))val result = service.processOrder(OrderId(1))assertThat(result.status).isEqualTo(OrderStatus.CONFIRMED)// Virtual time โ no real delays, tests run instantly}@Testfun `should emit price updates via Flow`() = runTest {val priceFlow = priceService.streamPrices("AAPL")priceFlow.test { // turbine extensionval first = awaitItem()assertThat(first.symbol).isEqualTo("AAPL")val second = awaitItem()assertThat(second.price).isGreaterThan(0)cancelAndConsumeRemainingEvents()}}@Testfun `should cancel children on timeout`() = runTest {val result = withTimeoutOrNull(Duration.ofSeconds(5)) {slowExternalService.call() // takes 10s}assertThat(result).isNull() // timed out gracefully}}
supervisorScope for parallel operations that can partially fail, and always inject dispatchers for testability. A well-designed coroutine architecture handles partial failures gracefully and never starves shared thread pools.Chapter 19: GraphQL with Kotlin
AdvancedGraphQL gives clients the power to request exactly the data they need โ no more over-fetching (getting 30 fields when you need 3) and no more under-fetching (making 5 REST calls to assemble one view). The Netflix DGS Framework brings GraphQL to Kotlin/Spring Boot with type-safe resolvers, DataLoaders for batch optimization, and native coroutine support. This chapter covers schema design, resolver implementation, and the critical N+1 problem in GraphQL (which is even more dangerous than in REST because clients control the query shape).
- GraphQL schema design with proper types, mutations, and subscriptions
- Netflix DGS type-safe resolvers with nested field resolution and computed fields
- DataLoaders for batching โ solving the N+1 problem in GraphQL
- When to choose GraphQL vs REST for your API
19.1 Schema Design
A well-designed GraphQL schema is your API contract. Define Query for reads, Mutation for writes, and Subscription for real-time updates. Use the payload pattern for mutations: return a OrderPayload with both the result and potential errors, rather than throwing exceptions. This gives clients structured error handling. Design types to reflect your domain model โ User has orders as a nested field, not a separate endpoint. This is the fundamental shift from REST: the client navigates relationships in a single query.
// GraphQL schema definition (schema.graphqls)type Query {user(id: ID!): Userusers(filter: UserFilter, page: PageInput): UserConnection!order(id: ID!): Order}type Mutation {createOrder(input: CreateOrderInput!): OrderPayload!updateUserProfile(input: UpdateProfileInput!): User!}type Subscription {orderStatusChanged(orderId: ID!): OrderStatusUpdate!}type User {id: ID!name: String!email: String!orders(first: Int = 10): [Order!]!totalSpent: Money!}type Order {id: ID!user: User!items: [OrderItem!]!status: OrderStatus!total: Money!createdAt: DateTime!}input CreateOrderInput {userId: ID!items: [OrderItemInput!]!paymentMethod: PaymentMethod!}type OrderPayload {order: Ordererrors: [UserError!]}
19.2 DGS Resolvers โ Type-Safe Data Fetching
The Netflix DGS Framework maps your schema to Kotlin functions with annotations. @DgsQuery resolves top-level queries, while @DgsData resolves nested fields. The key insight: nested resolvers are only called when the client actually requests that field. If a client queries { user(id: 1) { name } }, the orders resolver is never called โ this is GraphQL's efficiency advantage. Computed fields like totalSpent are resolved on-demand, keeping your User entity lean while providing rich derived data when clients need it.
// Netflix DGS Framework โ type-safe resolvers in Kotlin@DgsComponentclass UserDataFetcher(private val userService: UserService,private val orderService: OrderService) {@DgsQuerysuspend fun user(@InputArgument id: Long): User? =userService.findById(UserId(id))@DgsQuerysuspend fun users(@InputArgument filter: UserFilter?,@InputArgument page: PageInput?): UserConnection =userService.findAll(filter, page?.toPageable()).toConnection()// Nested resolver โ called only when "orders" field is requested@DgsData(parentType = "User", field = "orders")suspend fun ordersForUser(dfe: DgsDataFetchingEnvironment,@InputArgument first: Int?): List<Order> {val user = dfe.getSource<User>()return orderService.findByUserId(user.id, limit = first ?: 10)}// Computed field โ resolved on demand@DgsData(parentType = "User", field = "totalSpent")suspend fun totalSpent(dfe: DgsDataFetchingEnvironment): Money {val user = dfe.getSource<User>()return orderService.calculateTotalSpent(user.id)}}
19.3 DataLoaders โ Solving N+1 in GraphQL
The N+1 problem in GraphQL is especially insidious because the server doesn't control which fields are requested. A query like { orders { user { name } } } would trigger one query for orders, then one query per order to fetch the user โ classic N+1. DataLoader solves this by collecting all user IDs requested in a single GraphQL execution and batching them into one query. Instead of N individual findById calls, you make a single findByIds(batch) call. This is mandatory for any production GraphQL API.
// DataLoader โ solves N+1 in GraphQL@DgsComponentclass UserDataLoaderRegistrar {@DgsDataLoader(name = "users")val userLoader = MappedBatchLoaderWithContext<Long, User> { userIds, env ->// Called ONCE with all user IDs collected from the query// Instead of N individual queries, we make 1 batch queryval users = userService.findByIds(userIds.toList())users.associateBy { it.id.value }}}// Using DataLoader in a resolver@DgsComponentclass OrderDataFetcher {@DgsData(parentType = "Order", field = "user")suspend fun userForOrder(dfe: DgsDataFetchingEnvironment): CompletableFuture<User> {val order = dfe.getSource<Order>()val loader = dfe.getDataLoader<Long, User>("users")return loader.load(order.userId.value)// All user loads are batched into a single query}}// Query: { orders { id, user { name } } }// Without DataLoader: 1 query for orders + N queries for users// With DataLoader: 1 query for orders + 1 batch query for users
A: GraphQL advantages: no over/under-fetching, strong typing, single endpoint, great for mobile (bandwidth-sensitive). GraphQL disadvantages: harder to cache (HTTP caching works on URLs, not POST bodies), complex authorization (field-level access control), potential for expensive queries (depth/complexity limits needed). Rule of thumb: use GraphQL for client-facing APIs with diverse consumers (web, mobile, partners), REST for simple CRUD APIs and machine-to-machine communication where the query shape is fixed.
Chapter 20: Behavioral & System Design Framework
ExpertAt the Staff/Principal level, system design interviews are the most heavily weighted portion of the loop. You're not just evaluated on technical knowledge โ interviewers assess your ability to structure ambiguity, make reasoned trade-offs, and communicate complex architectures clearly. This chapter provides a repeatable 5-step framework that works for any system design question, from URL shorteners to real-time chat systems. The framework forces you to start with requirements (not solutions), estimate scale, design high-level components, deep-dive into critical paths, and explicitly address bottlenecks and failure modes.
- The 5-step system design framework: Requirements โ Estimation โ High-Level โ Deep Dive โ Trade-offs
- Back-of-the-envelope capacity estimation with real numbers
- How to identify and address bottlenecks, single points of failure, and hot spots
- Behavioral framing: how to communicate technical decisions to non-technical stakeholders
20.1 The 5-Step Framework
Every system design interview should follow this structure: (1) Requirements clarification โ ask what the system does, what scale it operates at, and what constraints exist. Never jump straight to "I'd use Kafka and Redis." (2) Capacity estimation โ calculate QPS, storage needs, and bandwidth. This shows you think about scale concretely, not abstractly. (3) High-level design โ draw the architecture: client โ load balancer โ API servers โ cache โ database. Identify the 3-5 core components. (4) Deep dive โ pick the most interesting/challenging component and design it in detail: database schema, sharding strategy, caching policy, consistency model. (5) Trade-offs and bottlenecks โ proactively identify single points of failure, hot keys, and what breaks at 10x scale.
// System Design Interview โ structured approach// Use this framework for any system design question// Step 1: Requirements clarification (2-3 minutes)// - Functional: What does the system DO?// - Non-functional: Scale, latency, availability targets// - Constraints: Budget, team size, timeline// Step 2: Capacity estimation (2 minutes)// Example: Design a URL shortenerval dailyUsers = 100_000_000L // 100M DAUval writesPerDay = dailyUsers * 0.1 // 10M new URLs/dayval readsPerDay = dailyUsers * 10 // 1B reads/day (100:1 read:write)val writeQps = writesPerDay / 86400 // ~115 writes/secval readQps = readsPerDay / 86400 // ~11,500 reads/secval storagePerYear = writesPerDay * 365 * 500 // ~1.8TB/year (500 bytes/URL)// Step 3: High-level design (5 minutes)// Draw the architecture: Client โ LB โ API โ Cache โ DB// Identify core components and data flow// Step 4: Deep dive into components (15 minutes)// - Database choice and schema// - Caching strategy (write-through, write-behind)// - Sharding / partitioning strategy// - Consistency model (strong vs eventual)// Step 5: Address bottlenecks and trade-offs (5 minutes)// - Single points of failure// - Hot spots / hot keys// - What happens at 10x scale?
20.2 Capacity Estimation Cheat Sheet
Memorize these numbers for quick estimation in interviews. Latency: L1 cache: 1ns, RAM: 100ns, SSD random read: 150ฮผs, HDD seek: 10ms, round-trip same datacenter: 0.5ms, round-trip cross-country: 70ms. Throughput: a single PostgreSQL instance can handle ~10,000-50,000 simple queries/sec, Redis handles ~100,000 ops/sec, a single Kafka broker handles ~100,000 messages/sec. Storage: 1 tweet = ~250 bytes, 1 URL = ~500 bytes, 1 user profile = ~1KB, 1 image thumbnail = ~50KB, 1 high-res photo = ~2MB.
| Component | Throughput | Latency |
|---|---|---|
| PostgreSQL (indexed) | 10-50K qps | 1-5ms |
| Redis | 100K ops/sec | sub-ms |
| Kafka (per broker) | 100K msg/sec | 2-5ms |
| Nginx (HTTP) | 50K req/sec | sub-ms |
| gRPC (unary) | 30K req/sec | 1-2ms |
A: Use the STAR framework (Situation, Task, Action, Result) but add a Lessons Learned section. Staff-level interviewers want to see self-awareness and growth. Example: "We chose microservices too early (Situation/Task). I advocated for service decomposition without considering our team's operational maturity (Action). Deployments became fragile, and we spent 60% of sprint time on infrastructure (Result). I led the migration back to a modular monolith, which cut deployment failures by 80% (Lesson: organization and maturity dictate architecture, not technical elegance)."
Appendix A: Recommended Stack (2026)
A production-ready stack for building high-performance, maintainable backend services in Kotlin. Each choice reflects the state of the art in 2026.