01 — Overview
Layer Architecture at a Glance
A clean, unidirectional dependency graph: HTTP hits Routing → UseCase orchestrates domain logic → Repository abstracts data → Database/Cache persists.
02 — Layer Breakdown
Every Layer Explained
Each layer has one job, one direction of knowledge, and strict boundaries enforced by Kotlin visibility modifiers and Koin scoping.
ApiResponse<T>. No business logic lives here. Plugins (Authentication, RateLimit, CORS, StatusPages) are installed once in Application.kt — features just use them.Result<T>. A use case never touches Exposed, Redis, or any I/O directly — it holds repository interfaces. Every use case is independently unit-testable with mocks.UserRepositoryImpl extends UserRepository and uses Exposed DSL inside dbQuery { } (dispatches to IO dispatcher). Redis is accessed via a CacheRepository interface — domain never sees Lettuce or Jedis. HikariCP manages the connection pool. Flyway handles versioned schema migrations at startup.03 — Features Deep Dive
Every Concern Covered
From JWT auth to AES payload encryption, WebSocket real-time, rate limiting, and structured error handling — nothing is an afterthought.
class JwtService(private val config: AppConfig) {
private val algorithm = Algorithm.HMAC256(config.jwtSecret)
private val verifier = JWT.require(algorithm)
.withIssuer(config.jwtIssuer)
.build()
fun generateAccessToken(userId: String): String =
JWT.create()
.withSubject(userId)
.withIssuer(config.jwtIssuer)
.withExpiresAt(Date(System.currentTimeMillis() + 15 * 60_000))
.withClaim("type", "access")
.sign(algorithm)
fun generateRefreshToken(userId: String): String =
JWT.create()
.withSubject(userId)
.withIssuer(config.jwtIssuer)
.withExpiresAt(Date(System.currentTimeMillis() + 30L * 24 * 3600_000))
.withClaim("type", "refresh")
.withJWTId(UUID.randomUUID().toString()) // jti for blacklist
.sign(algorithm)
fun verify(token: String): DecodedJWT = verifier.verify(token)
}
fun Application.configureAuthentication(jwtService: JwtService) {
install(Authentication) {
jwt("auth-jwt") {
realm = "ktor-backend"
verifier(jwtService.verifier)
validate { credential ->
if (credential.payload.getClaim("type").asString() == "access")
JWTPrincipal(credential.payload) else null
}
challenge { _, _ ->
call.respond(HttpStatusCode.Unauthorized,
ApiResponse.Error("Invalid or expired token"))
}
}
}
}
class EncryptionService {
fun encrypt(plaintext: ByteArray, key: SecretKey): ByteArray {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val iv = ByteArray(12).also { SecureRandom().nextBytes(it) }
cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(128, iv))
val ciphertext = cipher.doFinal(plaintext)
// prepend IV for transport: [12 bytes IV | ciphertext]
return iv + ciphertext
}
fun decrypt(payload: ByteArray, key: SecretKey): ByteArray {
val iv = payload.copyOfRange(0, 12)
val ciphertext = payload.copyOfRange(12, payload.size)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv))
return cipher.doFinal(ciphertext)
}
}
val EncryptionPlugin = createRouteScopedPlugin("EncryptionPlugin") {
onCall { call ->
val sessionKey = call.getSessionKey() // resolved from header
if (sessionKey != null) {
val rawBody = call.receiveChannel().toByteArray()
val decrypted = encryptionService.decrypt(rawBody, sessionKey)
call.setEncryptedBody(decrypted) // replaces receive channel
}
}
onCallRespond { call, body ->
val sessionKey = call.getSessionKey()
if (sessionKey != null && body is ByteArray) {
transformBody { encryptionService.encrypt(body, sessionKey) }
}
}
}
"entity:id" — e.g. "user:42". Cache invalidation is explicit on write operations. For list queries, use short TTLs (30s) rather than complex invalidation logic.interface CacheRepository {
suspend fun get(key: String): String?
suspend fun set(key: String, value: String, ttlSeconds: Long = 3600)
suspend fun delete(key: String)
suspend fun exists(key: String): Boolean
}
// Usage in repository impl — cache-aside
override suspend fun findById(id: Long): User? {
val key = "user:$id"
cache.get(key)?.let { return json.decodeFromString(it) }
return dbQuery {
UserTable.selectAll().where { UserTable.id eq id }
.map { it.toUser() }.singleOrNull()
}?.also { user ->
cache.set(key, json.encodeToString(user), ttlSeconds = 3600)
}
}
ConnectionManager singleton that maps userId → WebSocketSession. Coroutine Channel fan-out broadcasts events to all connected clients. Sealed WsMessage types are serialized as JSON with a type discriminator. Automatic reconnect is handled client-side; server sends periodic ping frames.class ConnectionManager {
private val sessions = ConcurrentHashMap<String, DefaultWebSocketSession>()
suspend fun connect(userId: String, session: DefaultWebSocketSession) {
sessions[userId] = session
}
fun disconnect(userId: String) { sessions.remove(userId) }
suspend fun sendTo(userId: String, message: WsMessage) {
sessions[userId]?.send(Frame.Text(json.encodeToString(message)))
}
suspend fun broadcast(message: WsMessage) {
val frame = Frame.Text(json.encodeToString(message))
sessions.values.forEach { session ->
try { session.send(frame) }
catch (_: Exception) { /* session closed, ignored */ }
}
}
}
// Sealed event types — exhaustive when()
@Serializable
@JsonClassDiscriminator("type")
sealed class WsMessage {
@Serializable @SerialName("notification")
data class Notification(val title: String, val body: String) : WsMessage()
@Serializable @SerialName("data_update")
data class DataUpdate(val entity: String, val id: String) : WsMessage()
@Serializable @SerialName("ping")
object Ping : WsMessage()
}
RateLimit plugin (1.0+) with token-bucket algorithm. Separate limit tiers: strict for auth endpoints (5 req/min), standard for API (100 req/min), relaxed for public reads (300 req/min). Key is resolved from JWT userId for authenticated requests, falling back to IP for anonymous — preventing bypass via multiple accounts.fun Application.configureRateLimit() {
install(RateLimit) {
register(RateLimitName("auth")) {
rateLimiter(limit = 5, refillPeriod = 1.minutes)
requestKey { call -> call.request.origin.remoteHost }
}
register(RateLimitName("api")) {
rateLimiter(limit = 100, refillPeriod = 1.minutes)
requestKey { call ->
call.principal<JWTPrincipal>()?.subject
?: call.request.origin.remoteHost
}
}
register(RateLimitName("public")) {
rateLimiter(limit = 300, refillPeriod = 1.minutes)
requestKey { call -> call.request.origin.remoteHost }
}
}
}
// Usage in routes
rateLimit(RateLimitName("auth")) {
post("/login") { authController.login(call) }
post("/register") { authController.register(call) }
}
AppError hierarchy flows from domain to presentation. The global StatusPages plugin maps every AppError subtype to an HTTP status and a consistent ApiResponse.Error JSON body. No raw exceptions escape to clients. Use cases return Result<T> — routes call .respondResult() extension. One error handling file to rule them all.sealed class AppError(override val message: String) : Exception(message) {
data class NotFound(val resource: String) : AppError("$resource not found")
data class Unauthorized(val reason: String = "Unauthorized") : AppError(reason)
data class Forbidden(val reason: String = "Forbidden") : AppError(reason)
data class Conflict(val resource: String) : AppError("$resource already exists")
data class Validation(val fields: Map<String, String>) : AppError("Validation failed")
data class Internal(val cause: Throwable) : AppError("Internal server error")
}
// StatusPages plugin — single location
exception<AppError> { call, error ->
val status = when (error) {
is AppError.NotFound -> HttpStatusCode.NotFound
is AppError.Unauthorized -> HttpStatusCode.Unauthorized
is AppError.Forbidden -> HttpStatusCode.Forbidden
is AppError.Conflict -> HttpStatusCode.Conflict
is AppError.Validation -> HttpStatusCode.UnprocessableEntity
is AppError.Internal -> HttpStatusCode.InternalServerError
}
call.respond(status, ApiResponse.Error(error.message))
}
jwt("auth-jwt") scheme.dbQuery { } that dispatches to Dispatchers.IO. Never blocks the event loop."entity:id". Explicit invalidation on mutations. TTLs prevent stale data accumulation.single { } for singletons, factory { } for use cases. Zero reflection overhead vs Spring. Ktor integration via koin-ktor.AppError.Validation(fields) — mapped to 422 Unprocessable Entity with field-level error messages in the response body.resources/db/migration/. Run at startup before the server accepts connections. Immutable migration files — never edit a deployed migration. Rollback via new down migrations./health endpoint for readiness + liveness probes.04 — Request Lifecycle
A Request's Journey
Every HTTP call passes through an ordered, auditable pipeline. Plugins run before any route handler sees the request.
Inbound Pipeline
WebSocket Lifecycle
ConnectionManager.connect(userId, session) stores the session. Previous session for same userId is closed (single device).for (frame in incoming) processes frames. Text frames deserialized to sealed WsMessage. Each handler is a coroutine launch.Ping WsMessage every 30 seconds. Client expected to respond with Pong. No response within 60s → session closed.finally { ConnectionManager.disconnect(userId) } in the coroutine ensures cleanup even on abnormal close.05 — Principles
SOLID + KISS Applied
Not abstract philosophy — concrete Ktor/Kotlin decisions made because of these principles. Every architecture choice is justifiable.
LoginUseCase authenticates — it does not send emails. EncryptionService only encrypts. JwtService only signs/verifies. UserRoutes.kt only routes — zero business logic. Every class has one reason to change.Application.kt; existing plugins are closed for modification. New WsMessage types extend the sealed class, never edit handlers.UserRepository implementation (Postgres, H2 for tests, in-memory fake) can replace another without the use case knowing. Tests use FakeUserRepository — behavior identical from the use case's perspective.CacheRepository exposes get/set/delete/exists — not a full Redis client. UserRepository exposes only what use cases need. No use case knows about findByEmailWithJoins() unless it needs it.LoginUseCase depends on UserRepository (interface) and JwtService (interface). The domain layer knows nothing about Exposed, Lettuce, or Ktor. Koin wires the concretions — the domain is clean.ConnectionManager is a ConcurrentHashMap — not a complex actor system. dbQuery { } is a single-line dispatcher wrapper. AppError is a sealed class — no exception hierarchy. The simplest approach that meets the constraint always wins.06 — Dependency Registry
The Right Tool Every Time
Curated for stability, active maintenance, and Kotlin-first design. No Spring. No ORMs with reflection. No legacy Java baggage unless strictly necessary.
val ktor_version = "3.0.3"
val exposed_version = "0.55.0"
val koin_version = "4.0.0"
dependencies {
// Ktor server
implementation("io.ktor:ktor-server-core:$ktor_version")
implementation("io.ktor:ktor-server-netty:$ktor_version")
implementation("io.ktor:ktor-server-auth-jwt:$ktor_version")
implementation("io.ktor:ktor-server-websockets:$ktor_version")
implementation("io.ktor:ktor-server-rate-limit:$ktor_version")
implementation("io.ktor:ktor-server-content-negotiation:$ktor_version")
implementation("io.ktor:ktor-server-request-validation:$ktor_version")
implementation("io.ktor:ktor-server-compression:$ktor_version")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")
implementation("io.ktor:ktor-client-core:$ktor_version")
implementation("io.ktor:ktor-client-cio:$ktor_version")
// Exposed ORM + DB
implementation("org.jetbrains.exposed:exposed-core:$exposed_version")
implementation("org.jetbrains.exposed:exposed-dao:$exposed_version")
implementation("org.jetbrains.exposed:exposed-jdbc:$exposed_version")
implementation("org.jetbrains.exposed:exposed-kotlin-datetime:$exposed_version")
implementation("com.zaxxer:HikariCP:6.0.0")
implementation("org.postgresql:postgresql:42.7.4")
implementation("org.flywaydb:flyway-core:10.15.0")
// Cache
implementation("io.lettuce:lettuce-core:6.4.0.RELEASE")
// DI
implementation("io.insert-koin:koin-ktor:$koin_version")
implementation("io.insert-koin:koin-logger-slf4j:$koin_version")
// Security
implementation("com.auth0:java-jwt:4.4.0")
implementation("org.bouncycastle:bcprov-jdk18on:1.79")
// Serialization + datetime
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1")
// Logging
implementation("ch.qos.logback:logback-classic:1.5.8")
// Test
testImplementation("io.ktor:ktor-server-test-host:$ktor_version")
testImplementation("io.mockk:mockk:1.13.12")
testImplementation(kotlin("test"))
}
✦ — AI Scaffold
Generate with AI
Paste into Claude, ChatGPT, Gemini, or any AI agent to scaffold this exact architecture from scratch.
You are a senior Kotlin backend engineer. Scaffold a production-ready Ktor REST API. Stack: - Language: Kotlin 1.9+ (coroutines, serialization) - Framework: Ktor 2.x (server, client) - DI: Koin - Auth: Ktor JWT plugin (nimbus-jose-jwt) - Database: PostgreSQL via Exposed ORM (DSL + DAO) - Migrations: Flyway - Validation: Konform - Logging: Logback + structured JSON - Testing: kotest + ktor-server-test-host + testcontainers - Deployment: Docker + Gradle shadow JAR Provide: 1. Project structure (plugins/, routes/, services/, repositories/, models/) 2. Ktor Application.kt with plugin installation (routing, auth, serialization, DI) 3. Koin module definitions (appModule, databaseModule) 4. Exposed table definition + repository pattern 5. JWT auth plugin configuration + route guard 6. Error handling with StatusPages plugin 7. Kotest integration test with testcontainers PostgreSQL 8. Dockerfile + docker-compose.yml