Production Architecture Guide — 2025

Ktor + Kotlin

Backend Architecture Field Guide

A battle-tested, scalable backend architecture using Ktor, Kotlin coroutines, Exposed ORM, Koin DI, and WebSockets — covering JWT auth, request encryption, caching, real-time communication, and clean layered design.

Ktor 3.x Kotlin Coroutines Exposed ORM Koin DI JWT Auth WebSockets Redis Cache PostgreSQL AES-256 Rate Limiting SOLID + KISS
Scroll to explore

Layer Architecture at a Glance

A clean, unidirectional dependency graph: HTTP hits Routing → UseCase orchestrates domain logic → Repository abstracts data → Database/Cache persists.

🌐
Presentation
Routes, Plugins, Request/Response DTOs. HTTP boundary only.
⚙️
Application
Use Cases / Service layer. Orchestrates domain. No I/O here.
🏛️
Domain
Pure Kotlin entities, repository interfaces, business rules.
🗄️
Data / Infra
Exposed tables, Redis cache, external API clients, repo impls.
4
Architecture Layers
100%
Coroutine-native
0
Blocking I/O
Horizontal Scale
AES-256
Payload Encryption
Project Structure
📁ktor-backend/
📁src/main/kotlin/com/app/
📁core/// cross-cutting: DI, config, extensions, security utils
📁di/
📄AppModule.kt// Koin root module
📄DatabaseModule.kt
📄CacheModule.kt// Redis / in-process
📄RepositoryModule.kt
📁security/
📄JwtService.kt// sign, verify, refresh
📄EncryptionService.kt// AES-256-GCM
📄PasswordHasher.kt// Argon2id
📁config/
📄AppConfig.kt// typed env-driven config
📄DatabaseConfig.kt
📁extensions/
📄CallExtensions.kt// respond helpers, validated body
📄ResultExtensions.kt// kotlin.Result → HTTP

📁domain/// pure Kotlin — zero framework deps
📁model/
📄User.kt
📄AuthToken.kt
📁repository/// interfaces only
📄UserRepository.kt
📄AuthRepository.kt
📁error/
📄AppError.kt// sealed class hierarchy

📁application/// use cases — orchestration only
📁auth/
📄LoginUseCase.kt
📄RegisterUseCase.kt
📄RefreshTokenUseCase.kt
📁user/
📄GetUserProfileUseCase.kt
📄UpdateUserUseCase.kt

📁data/// implementations, Exposed tables, Redis
📁database/
📄UserTable.kt// Exposed Table DSL
📄UserRepositoryImpl.kt
📄DatabaseFactory.kt// HikariCP pool + migrations
📁cache/
📄RedisClient.kt
📄CacheRepository.kt
📁remote/
📄HttpClientProvider.kt// Ktor client for external APIs

📁presentation/// Ktor routing, plugins, WebSocket handlers
📁plugins/
📄Authentication.kt// JWT bearer install
📄Serialization.kt
📄RateLimit.kt
📄Compression.kt
📄StatusPages.kt// global error → HTTP
📄CORS.kt
📄EncryptionPlugin.kt// AES body intercept
📁routes/
📄AuthRoutes.kt
📄UserRoutes.kt
📄WebSocketRoutes.kt
📁dto/
📄AuthRequest.kt
📄ApiResponse.kt// sealed wrapper
📄Application.kt// entry point

📁realtime/
📄ConnectionManager.kt// WebSocket session registry
📄WsMessage.kt// sealed event types
📄BroadcastChannel.kt// coroutine channels for fan-out

📁src/main/resources/
📄application.conf// HOCON: server, database, jwt, redis
📁db/migration/// Flyway versioned SQL migrations
📁src/test/kotlin/
📁unit/
📁integration/
📁e2e/// testApplication { } block
📄build.gradle.kts
📄docker-compose.yml
📄Dockerfile

Every Layer Explained

Each layer has one job, one direction of knowledge, and strict boundaries enforced by Kotlin visibility modifiers and Koin scoping.

01
Presentation Layer 🌐
Ktor routing, plugins, DTOs — the HTTP boundary
Owns everything HTTP. Routes receive requests, validate + deserialize via kotlinx.serialization @Serializable DTOs, call use cases, and map results back to ApiResponse<T>. No business logic lives here. Plugins (Authentication, RateLimit, CORS, StatusPages) are installed once in Application.kt — features just use them.
Route extensions Request DTOs Response DTOs Plugin install WebSocket handlers StatusPages error mapping
02
Application Layer ⚙️
Use Cases — orchestrate, never implement
Use cases are suspend fun invoke() single-purpose classes. They coordinate across repositories, apply business rules, and return 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.
LoginUseCase RegisterUseCase RefreshTokenUseCase GetUserProfileUseCase UpdateUserUseCase …per feature
03
Domain Layer 🏛️
Pure Kotlin — zero Ktor, zero Exposed, zero annotations
The safest layer: data classes for entities, sealed classes for errors, interfaces for repositories. Nothing here changes when you swap PostgreSQL for MongoDB or Ktor for Spring. This is the stable core the application is built around. AppError sealed hierarchy means the presentation layer maps one-to-one from domain error → HTTP status without stringly-typed exceptions.
User (entity) AuthToken (entity) UserRepository (interface) AuthRepository (interface) AppError (sealed)
04
Data / Infrastructure Layer 🗄️
Exposed ORM, HikariCP, Redis, Flyway, external HTTP
Implements domain repository interfaces. 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.
UserTable (Exposed) UserRepositoryImpl DatabaseFactory (HikariCP) RedisClient (Lettuce) CacheRepositoryImpl Flyway migrations

Every Concern Covered

From JWT auth to AES payload encryption, WebSocket real-time, rate limiting, and structured error handling — nothing is an afterthought.

Strategy
Access token (short-lived, 15 min) + Refresh token (long-lived, 30 days, stored in Redis as a blacklist-friendly set). HMAC-SHA256 signing via nimbus-jose-jwt. No symmetric secret in code — loaded from environment. Token rotation invalidates old refresh token on use.
Kotlin core/security/JwtService.kt
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)
}
Kotlin presentation/plugins/Authentication.kt
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"))
            }
        }
    }
}
Strategy
AES-256-GCM authenticated encryption. Each request body is encrypted client-side; the Ktor plugin decrypts before the route handler sees the body. The symmetric key is shared via an initial Diffie-Hellman ECDH handshake, stored per-session. GCM provides both confidentiality and integrity — no separate HMAC needed.
Kotlin core/security/EncryptionService.kt
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)
    }
}
Kotlin presentation/plugins/EncryptionPlugin.kt
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) }
        }
    }
}
Strategy
Redis (via Lettuce coroutine client) as the primary cache. Cache-aside pattern: read cache → on miss, query Postgres → write to cache with TTL. Cache keys are structured as "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.
Kotlin data/cache/CacheRepository.kt
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)
    }
}
Strategy
Ktor's built-in WebSocket support + a 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.
Kotlin realtime/ConnectionManager.kt
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()
}
Strategy
Ktor's built-in 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.
Kotlin presentation/plugins/RateLimit.kt
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) }
}
Strategy
Sealed 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.
Kotlin domain/error/AppError.kt
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 Authentication
Access + refresh token pair. HMAC-SHA256 signed. Refresh tokens stored as JTI set in Redis — rotated on use, invalidated on logout. Ktor Authentication plugin with custom jwt("auth-jwt") scheme.
ktor-auth-jwtjava-jwt
🔒
AES-256-GCM Payload Encryption
Route-scoped Ktor plugin intercepts request body pre-handler and response body post-handler. Keys exchanged via ECDH on session init. GCM mode provides authenticated encryption — no extra HMAC.
JDK javax.cryptoBouncy Castle
🗃️
Exposed ORM + HikariCP
Type-safe SQL DSL — no reflection, no annotation processors. HikariCP connection pool. All DB ops inside dbQuery { } that dispatches to Dispatchers.IO. Never blocks the event loop.
exposed-core ^0.55HikariCPFlyway
Redis Caching (Cache-Aside)
Lettuce async/coroutine Redis client. Cache-aside pattern in repository layer. Structured keys: "entity:id". Explicit invalidation on mutations. TTLs prevent stale data accumulation.
lettuce-corekotlin-redis
🔄
WebSocket Real-time
Ktor native WebSockets. ConnectionManager maps userId → session in ConcurrentHashMap. Sealed WsMessage types with JSON discriminator. Coroutine Channel fan-out for pub/sub-style broadcast across connected users.
ktor-websocketskotlinx-coroutines
🛡️
Rate Limiting
Ktor built-in RateLimit plugin with named tiers: auth (5/min), api (100/min), public (300/min). Key resolves to JWT userId for authed requests, IP for anonymous — prevents multi-account bypass.
ktor-rate-limit
🧩
Dependency Injection (Koin)
Koin modules per concern — DatabaseModule, CacheModule, RepositoryModule, UseCaseModule. single { } for singletons, factory { } for use cases. Zero reflection overhead vs Spring. Ktor integration via koin-ktor.
koin-ktor ^4.xkoin-logger-slf4j
🔑
Argon2id Password Hashing
Argon2id (memory-hard, PHC winner) for all stored passwords. Configured with secure defaults: memory=65536 KB, iterations=3, parallelism=4. Never store plain or bcrypt-hashed passwords for new systems.
bouncy-castle
📐
Request Validation
Ktor RequestValidation plugin with custom validators per DTO. Validation errors become AppError.Validation(fields) — mapped to 422 Unprocessable Entity with field-level error messages in the response body.
ktor-request-validation
🗺️
Schema Migrations (Flyway)
Versioned SQL migrations in resources/db/migration/. Run at startup before the server accepts connections. Immutable migration files — never edit a deployed migration. Rollback via new down migrations.
flyway-coreflyway-database-postgresql
📊
Structured Logging
SLF4J + Logback with structured JSON output in production. Each request logs traceId (UUID), userId, method, path, status, duration. MDC for correlation IDs across async coroutine boundaries via coroutine MDC integration.
logback-classicktor-call-logging
🐳
Docker + Health Checks
Multi-stage Dockerfile: build stage compiles fatJar, runtime stage uses distroless JRE21 image. Docker Compose for local dev with Postgres + Redis. /health endpoint for readiness + liveness probes.
ktor-status-pagesdocker-compose

A Request's Journey

Every HTTP call passes through an ordered, auditable pipeline. Plugins run before any route handler sees the request.

Inbound Pipeline

1
CORS Check
Origin validated against allow-list. Pre-flight OPTIONS handled. Rejects unauthorized origins with 403 before any processing.
Plugin
2
Rate Limit
Token bucket checked for key (userId or IP). Exceeded requests receive 429 Too Many Requests with Retry-After header.
Plugin
3
Decompression
GZIP/deflate encoded bodies are decompressed transparently. Compressed responses negotiated via Accept-Encoding.
Plugin
4
Body Decryption
AES-256-GCM decryption using session key. Routes see plaintext JSON — unaware of encryption layer entirely.
Plugin
5
JWT Verification
Bearer token extracted, signature verified, expiry checked. Principal set on call. Fails to 401 if invalid.
Auth
6
Route Match + Validation
Ktor routes matched. Request body deserialized and validated against DTO validators. Validation errors become 422.
Route
7
Use Case Execution
Route delegates to use case. Coroutine-suspended. Business logic runs, cache checked, DB queried as needed.
Domain
8
Response + Encryption
Result mapped to ApiResponse DTO. Body serialized to JSON, encrypted with session key, compressed, sent.
Plugin

WebSocket Lifecycle

Client Connect
WS handshake via HTTP Upgrade. JWT token validated from query param or first message. Anonymous connections rejected.
Session Registration
ConnectionManager.connect(userId, session) stores the session. Previous session for same userId is closed (single device).
Message Loop
Coroutine for (frame in incoming) processes frames. Text frames deserialized to sealed WsMessage. Each handler is a coroutine launch.
Server → Client Push
Business events (via Channel) fan-out to ConnectionManager. Broadcast or targeted send based on event type. Fire-and-forget with try/catch for closed sessions.
Ping / Keepalive
Server sends Ping WsMessage every 30 seconds. Client expected to respond with Pong. No response within 60s → session closed.
Disconnect + Cleanup
finally { ConnectionManager.disconnect(userId) } in the coroutine ensures cleanup even on abnormal close.

SOLID + KISS Applied

Not abstract philosophy — concrete Ktor/Kotlin decisions made because of these principles. Every architecture choice is justifiable.

S
Single Responsibility
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.
O
Open / Closed
Add a new feature by adding a new route file, use case, and repository — never modifying the domain layer. Plugins are open to addition in Application.kt; existing plugins are closed for modification. New WsMessage types extend the sealed class, never edit handlers.
L
Liskov Substitution
Any 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.
I
Interface Segregation
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.
D
Dependency Inversion
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.
K
Keep It Simple
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.

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.

Dependency Version Purpose Layer
ktor-server-core^3.0Ktor server foundation — coroutine-native HTTP, plugin system, routing DSLrouting
ktor-server-netty^3.0Netty engine — high-throughput, non-blocking I/O. Preferred over CIO for productionrouting
ktor-server-auth-jwt^3.0JWT bearer plugin for Ktor Authentication — uses java-jwt under the hoodrouting
ktor-server-websockets^3.0WebSocket support with coroutine-native Frame processingreal-time
ktor-server-rate-limit^3.0Built-in rate limiting plugin with named tiers and custom key resolversrouting
ktor-server-content-negotiation^3.0Content negotiation + kotlinx.serialization JSON codecrouting
ktor-server-request-validation^3.0Declarative request body validation with typed validators per DTOrouting
ktor-server-compression^3.0GZIP/deflate response compression negotiated via Accept-Encodingrouting
ktor-client-core^3.0Ktor HTTP client for outbound API calls — coroutine-native, same plugin modeldata
exposed-core + exposed-dao^0.55JetBrains Exposed — type-safe SQL DSL & DAO. No reflection, compile-time SQLdata
exposed-kotlin-datetime^0.55kotlinx.datetime column types for Exposed — avoid java.util.Date entirelydata
HikariCP^6.0Best-in-class JDBC connection pool — used by Exposed DatabaseFactorydata
postgresql^42.7PostgreSQL JDBC driverdata
flyway-core^10.0Versioned database schema migrations — run at startup before server bootdata
lettuce-core^6.4Async/reactive Redis client with coroutine bridges — Jedis is deprecateddata
koin-ktor^4.0Koin DI integration for Ktor — zero reflection, Kotlin DSL modulesdi
koin-logger-slf4j^4.0SLF4J bridge for Koin diagnostic outputdi
kotlinx-serialization-json^1.7Official Kotlin JSON serialization — sealed class discriminators, camelCase, strict modedata
kotlinx-datetime^0.6Multiplatform datetime — Instant, LocalDateTime. Zero java.util.Date in domaindata
java-jwt^4.4Auth0 java-jwt — sign & verify JWTs. Used by JwtService under Ktor's auth pluginsecurity
bcprov-jdk18on^1.79Bouncy Castle — Argon2id password hashing + AES-GCM beyond JDK limitssecurity
logback-classic^1.5Structured JSON logging in production, colored console in dev. SLF4J backendinfra
kotlin-test + mockklatestMockK for coroutine-aware mocking. testApplication { } for integration tests with real Ktor pipelinedev
ktor-server-test-host^3.0In-memory test server — full plugin stack, real routing, no port binding neededdev
Kotlin DSL build.gradle.kts
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"))
}

Generate with AI

Paste into Claude, ChatGPT, Gemini, or any AI agent to scaffold this exact architecture from scratch.

 Architecture Prompt
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
crafted with by Sam
 Copied to clipboard!