21 May 2025

Kotlin for data pipelines: Why I ditched Scala for backend data architecture

You know that moment when you’re debugging a Scala data pipeline at 2 AM and realize 80% of the code is type gymnastics and implicit hell?

I spent years building data pipelines in Scala. Type-safe, functional, elegant - but also verbose, complex, and frankly exhausting. Then I tried Kotlin for a backend data pipeline.

The difference? Shocking.

  • Coroutines for async flow → no callback hell
  • Data classes + kotlinx.serialization → zero boilerplate
  • Spark integration → same power, 50% less code
  • Null-safety out of the box → no more Option[Option[T]] madness

This post shows you how to build production-grade data-centric backends with Kotlin. You’ll see:

  • Pipeline patterns with composable stages
  • Spark and Flink integration (with performance comparisons)
  • LLM enrichment stages for AI-powered data
  • Lakehouse patterns (Iceberg, Delta Lake)
  • Observability with Prometheus and OpenTelemetry

Real code, real architectures, real production lessons.


Why this matters

Here’s the data engineering reality in 2025:

Traditional approach (Scala/Java):

  • Heavy type systems → steep learning curve
  • Boilerplate everywhere → slow development
  • Futures/monads → complex async code
  • Spark verbosity → 10 lines for simple transformations

The Kotlin advantage:

  • Concise syntax → 50% less code
  • Coroutines → async without complexity
  • Null-safety → catch errors at compile time
  • JVM compatibility → use existing Spark/Flink/Kafka libraries

Why you should care:

  • Faster development (less boilerplate)
  • Easier onboarding (simpler syntax)
  • Better async (coroutines > futures)
  • Production-ready (JVM stability + modern features)

This post is for data engineers who are tired of fighting their language and want something that just works.


Part 1 - Pipeline patterns: composable stages with coroutines

A classic pipeline-of-transformations lets us compose reusable stages:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
fun <T> pipeline(input: T, stages: List<suspend (T) -> T>): suspend () -> T =
  suspend {
    stages.fold(input) { acc, fn -> fn(acc) }
  }

suspend fun cleanText(s: String) = s.trim().lowercase()
suspend fun sanitize(s: String) = s.replace(Regex("\\s+"), "-")
suspend fun pipelineExample() {
  val input = " Hello   WORLD! "
  val result = pipeline(input, listOf(::cleanText, ::sanitize))()
  println(result) // output: "hello-world!"
}

Part 2 - Data classes + Spark integration (zero boilerplate)

1
2
3
4
5
6
@Serializable
data class Event(val id: Long, val ts: Instant, val payload: Map<String, Any?>)

fun Dataset<Event>.transformEvents(): Dataset<Event> = this.map { ev ->
ev.copy(payload = ev.payload + ("processedAt" to Instant.now()))
}

Kotlin data classes + map closures = Spark pipelines without boilerplate. You could also easily integrate custom UDFs, JSON payloads via kotlinx.serialization, and more.

flowchart LR
  subgraph Ingestion
    API -->|HTTP POST| Kafka
    Files -->|batch upload| Kafka
  end
  Kafka --> SparkProcessor
  Kafka --> FlinkProcessor
  SparkProcessor --> DeltaLake
  FlinkProcessor --> Iceberg
  DeltaLake --> QueryService
  Iceberg --> QueryService
  QueryService --> ClientApp

Using both Spark and Flink in a Kappa‑style unified engine helps choose between batch or true-stream runtime at the same code-level.

Kotlin shines here thanks to interoperability and expressive code.

  • Spark – micro-batch, lazy transforms, rich SQL support
  • Flink – event-time, stateful streams, low-latency

Performance comparison:

Feature / Design ChoiceApache SparkApache Flink
Processing ModelMicro-batchTrue streaming (record-by-record)
LatencyHigher (~100s ms – seconds)Low latency (ms level)
ThroughputHigh (great for batch)Very high (great for stream processing)
Backpressure HandlingLimited / manualBuilt-in native backpressure mechanism
State ManagementBasic with checkpointingFirst-class, consistent, fault-tolerant
Event Time ProcessingLimited (structured streaming workaround)Native support
WindowingAvailable, but less flexibleVery flexible and expressive
Use Case FitBatch & semi-streaming (ETL, ML pipelines)Real-time analytics, event processing
Language SupportScala, Java, Python, RJava, Scala, Python
Ease of IntegrationWell-supported in GCP / DatabricksStrong for Kafka, Stateful Apps
Deployment ComplexityModerate (e.g. on Dataproc)Higher; needs tuned Flink clusters
Community & EcosystemMature and broadGrowing rapidly in streaming domain

Part 5 - LLM enrichment: adding AI to your data pipeline

Imagine injecting an LLM-based enrichment inside a Spark job:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
suspend fun enrichWithLLM(text: String): String = coroutineScope {
  val response = llmClient.completions.create(Prompt("Extract entities from: $text"))
  response.choices.first().text.trim()
}

fun Dataset<Event>.enrichEvents(): Dataset<Event> = mapPartitions { partition ->
  runBlocking {
    partition.map { ev ->
      ev.copy(payload = ev.payload + ("entities" to enrichWithLLM(ev.payload["text"] as String)))
    }
  }.iterator()
}
  • coroutineScope + mapPartitions avoids per-event I/O overload
  • can also be packaged into a Ktor service if isolation is desired

Part 6 - End-to-end flow: from API to lake

sequenceDiagram
  participant API
  participant Kafka
  participant Spark
  participant Enricher
  participant Lake
  participant Client

  API->>Kafka: Publish raw Event
  Kafka->>Spark: Read + transform
  Spark->>Enricher: async LLM enrich
  Enricher-->>Spark: return enriched payload
  Spark->>Lake: write to Delta
  Client->>Lake: query via Presto/Trino

Part 7 - Observability: metrics with Prometheus and OpenTelemetry

Capture stage timings using Kotlin DSL:

1
2
3
4
5
6
7
8
9
fun <T> timed(stage: String, block: () -> T): T {
  val start = System.nanoTime()
  try = block()
  finally = prometheus.histogram("stage_duration_seconds", stage).observe((System.nanoTime() - start) / 1e9)
}

Dataset<Event>.timedTransform(name: String) = map { ev ->
  timed(name) { /* your logic */; ev }
}

Include tracing with OpenTelemetry and JMX exporters for JVM-level insight.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fun <T> timed(stage: String, block: () -> T): T {
  val start = System.nanoTime()
  return block().also {
    prometheus.histogram("stage_seconds", stage)
        .observe((System.nanoTime() - start) / 1e9)
  }
}

Dataset<Event>. timedTransform(name: String) = map { ev ->
  timed(name) { ev }//placeholder
}

Part 8 - Lakehouse patterns: Iceberg and Delta Lake

1
2
val table = IcebergTable.forName("events")
table.upsert(eventsDs) // merge by id + timestamp // SCD handling built-in

Kotlin handles types, Avro/Parquet writes, and makes schema evolution expressive.

Part 9 - Config-driven pipelines: metadata over code

Pipeline stages should be pluggable:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
pipeline:
  - name: clean
  - name: enrich
    params: { model: "openai-entity-v1" }
  - name: serialize
  - name: load

pipeline-llm:
  - name: clean
  - name: llmEnrich
    params:
      model: gpt-3.5
  - name: store
    target: iceberg

Kotlin maps config to stage pipeline via DI (Koin, Dagger, Guice), making your data flow metadata-driven, not rigid.

Turn this into:

1
2
3
4
5
6
sealed interface Stage
data class Clean(): Stage
data class LlmEnrich(val model: String) : Stage
data class Store(val target: String) : Stage

fun buildPipeline(config: Config): List<suspend (Event)->Event> = TODO()

Use Koin/Dagger or reflection to bind stages—metadata-driven architecture FTW.

Part 10 - Building the API: Ktor + Kafka ingestion

1
2
3
4
5
6
7
8
9
fun Application.module() {
  routing {
    post("/ingest") {
      val ev = call.receive<Event>()
      producer.send(ProducerRecord("events", ev.id.toString(), Json.encodeToString(ev)))
      call.respond(HttpStatusCode.Accepted)
    }
  }
}

Add Ktor metrics, request logging, backpressure handling, and let downstream processes do the heavy lifting.

Part 11 - Putting it all together: complete architecture

flowchart LR
  Client --> KtorAPI --> Kafka --> Spark/Flink --> Lakehouse --> QueryEngine --> BI/CliApp
  subgraph "Observability"
    Spark/Flink --> Prometheus
    KtorAPI --> OpenTelemetry
  end

Best practices and lessons learned

What works:

  • Use Kotlin DSLs for pipeline and config management
  • Combine Spark + Flink for unified streaming/batch
  • Inject LLM stages for AI-enriched data
  • Abstract data sinks with lakehouse patterns (Iceberg/Delta)
  • Centralize monitoring with Prometheus + OpenTelemetry

Advanced tips:

  • Use ArchUnit or Structurizr for architecture validation
  • Add CI/CD pipeline generation DSL
  • Auto-generate integration tests
  • Implement backpressure handling in Kafka consumers
  • Monitor JVM metrics with JMX exporters

Common pitfalls:

  • Don’t block coroutines with blocking I/O
  • Avoid per-event LLM calls (use mapPartitions batching)
  • Don’t skip observability (you’ll regret it in production)
  • Remember to handle schema evolution in lakehouse tables

Conclusion

Kotlin isn’t just cleaner than Scala - it’s fundamentally more productive for data engineering.

After years of Scala’s implicit hell and type gymnastics, Kotlin feels like a breath of fresh air. Coroutines eliminate callback complexity. Data classes remove boilerplate. Null-safety catches bugs at compile time.

And you still get the full JVM ecosystem - Spark, Flink, Kafka, everything just works.

If you’re tired of fighting your language, give Kotlin a shot. Your future self will thank you.


TL;DR

  • The problem: Scala data pipelines are verbose, complex, and exhausting (type gymnastics + implicit hell)
  • The solution: Kotlin = 50% less code, coroutines for async, data classes for zero boilerplate, null-safety built-in
  • Pipeline pattern: Composable stages with suspend functions, fold-based transformation chains
  • Spark integration: Data classes map directly to Datasets, kotlinx.serialization handles JSON/Avro
  • Flink vs Spark: Spark = micro-batch/high throughput, Flink = true streaming/low latency - Kotlin works with both
  • LLM enrichment: Use coroutineScope + mapPartitions to batch LLM calls, avoid per-event overhead
  • Architecture: Ktor API → Kafka → Spark/Flink → Lakehouse (Iceberg/Delta) → Query engine
  • Lakehouse patterns: Iceberg/Delta for ACID, schema evolution, time travel queries
  • Config-driven: YAML-defined pipeline stages, DI (Koin/Dagger) for metadata-driven architecture
  • Observability: Prometheus metrics, OpenTelemetry tracing, JMX exporters for JVM insight
  • Production lessons: Never block coroutines, batch LLM calls, centralize monitoring, handle backpressure
  • Bottom line: Kotlin beats Scala for data engineering - same JVM power, 10x better developer experience
Vitthal Mirji profile photo

Vitthal Mirji

Staff Data Engineer @ Walmart

Mumbai, India

Staff Data Engineer & Architect from Mumbai, India. Sharing insights on Data Engineering, Functional programming, Scala, Open source, and life.

Expertise
  • Data Engineering
  • Scala
  • Apache Spark
  • Functional Programming
  • Cloud Architecture
  • GCP
  • Big Data
Next time, we'll talk about "Why Every Data Lake Turns Into a Data Swamp (Narrator: It Always Does)"