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:
| |
Part 2 - Data classes + Spark integration (zero boilerplate)
| |
Kotlin data classes + map closures = Spark pipelines without boilerplate. You could also easily integrate custom UDFs, JSON payloads via kotlinx.serialization, and more.
Part 3 - Architecture overview: Kafka → Spark/Flink → Lakehouse
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.
Part 4 - Spark vs Flink: choosing the right tool
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 Choice | Apache Spark | Apache Flink |
|---|---|---|
| Processing Model | Micro-batch | True streaming (record-by-record) |
| Latency | Higher (~100s ms – seconds) | Low latency (ms level) |
| Throughput | High (great for batch) | Very high (great for stream processing) |
| Backpressure Handling | Limited / manual | Built-in native backpressure mechanism |
| State Management | Basic with checkpointing | First-class, consistent, fault-tolerant |
| Event Time Processing | Limited (structured streaming workaround) | Native support |
| Windowing | Available, but less flexible | Very flexible and expressive |
| Use Case Fit | Batch & semi-streaming (ETL, ML pipelines) | Real-time analytics, event processing |
| Language Support | Scala, Java, Python, R | Java, Scala, Python |
| Ease of Integration | Well-supported in GCP / Databricks | Strong for Kafka, Stateful Apps |
| Deployment Complexity | Moderate (e.g. on Dataproc) | Higher; needs tuned Flink clusters |
| Community & Ecosystem | Mature and broad | Growing rapidly in streaming domain |
Part 5 - LLM enrichment: adding AI to your data pipeline
Imagine injecting an LLM-based enrichment inside a Spark job:
| |
- 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:
| |
Include tracing with OpenTelemetry and JMX exporters for JVM-level insight.
| |
Part 8 - Lakehouse patterns: Iceberg and Delta Lake
| |
Kotlin handles types, Avro/Parquet writes, and makes schema evolution expressive.
Part 9 - Config-driven pipelines: metadata over code
Pipeline stages should be pluggable:
| |
Kotlin maps config to stage pipeline via DI (Koin, Dagger, Guice), making your data flow metadata-driven, not rigid.
Turn this into:
| |
Use Koin/Dagger or reflection to bind stages—metadata-driven architecture FTW.
Part 10 - Building the API: Ktor + Kafka ingestion
| |
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
