Real-time data processing has evolved from a complex, imperative coding challenge into a more structured, declarative discipline. For data engineers, the ability to express complex stream logic concisely is no longer just a luxury; it is a necessity for maintaining scalable architectures. Two of the leading frameworks, Apache Flink and Kafka Streams, have embraced this shift by offering powerful declarative interfaces: Flink SQL and the Kafka Streams DSL (Domain-Specific Language).
This guide explores how to leverage these declarative approaches to write cleaner, more maintainable stream processing applications. We will compare the semantic capabilities of both tools and provide practical code examples to illustrate their distinct strengths.
The Power of Declarative Logic
Imperative stream processing often forces developers to manage state, time semantics, and windowing manually. This low-level control is powerful but comes at the cost of readability and maintainability. Declarative logic, conversely, focuses on what the data should look like rather than how to transform it step-by-step.
By abstracting away the complexity of state management and event time processing, declarative interfaces allow teams to focus on business logic. This is particularly beneficial in large organizations where data pipelines must be auditable and easily understood by data analysts who may not have deep Java or Scala expertise.
Apache Flink SQL: The Unified Query Layer
Apache Flink has positioned SQL as a first-class citizen, treating streaming and batch processing uniformly. Flink SQL allows you to define tables that represent Kafka topics, HDFS files, or JDBC databases, and then query them as if they were static relational tables. The engine automatically handles the streaming semantics under the hood.
Consider a scenario where you need to calculate the average latency of API requests over a sliding 5-minute window. In Flink SQL, this is straightforward:
CREATE TABLE api_logs (
request_id STRING,
user_id STRING,
latency_ms INT,
timestamp TIMESTAMP(3),
WATERMARK FOR `timestamp` AS `timestamp` - INTERVAL '5' SECOND
) WITH (
'connector' = 'kafka',
'topic' = 'api-logs',
'properties.bootstrap.servers' = 'kafka-broker:9092',
'format' = 'json'
);
CREATE VIEW avg_latency_view AS
SELECT
user_id,
TUMBLE_END(`timestamp`, INTERVAL '5' MINUTE) AS window_end,
AVG(latency_ms) AS avg_latency
FROM api_logs
GROUP BY
user_id,
TUMBLE(`timestamp`, INTERVAL '5' MINUTE);
INSERT INTO sink_table SELECT * FROM avg_latency_view;
Note the use of the WATERMARK definition. Flink SQL automatically manages event time progress, allowing for accurate, late-data-tolerant aggregations without explicit code for state TTLs or timeout handling.
Kafka Streams DSL: Java-Centric Flexibility
While Flink SQL is excellent for complex joins and multi-table transformations, Kafka Streams DSL offers a different value proposition: tight integration with the Java ecosystem and fine-grained control over the stream topology. The DSL is method-chain based, allowing for a readable, fluent interface that remains strongly typed.
Here is how you might express a similar filtering and aggregation logic in Kafka Streams DSL:
KStream logs = builder.stream("api-logs",
Consumed.with(Serdes.String(), Serdes.Serializer.of(ApiLog.class)));
KTable aggregated = logs
.filter((key, value) -> value.getLatencyMs() > 0)
.groupByKey(Grouped.with(Serdes.String(), Serdes.Serializer.of(ApiLog.class)))
.windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(5)))
.aggregate(
Aggregation.with(
() -> new AggregatedLatency(0, 0),
(key, value, agg) -> agg.add(value),
Serdes.Serializer.of(AggregatedLatency.class)
)
)
.toStream();
aggregated.to("aggregated-latency", Produced.with(Serdes.String(), Serdes.Serializer.of(AggregatedLatency.class)));
The Kafka Streams DSL shines when you need custom state stores, interactive queries, or complex side outputs that are not easily expressible in SQL. The fluent API makes it easy to build multi-stage pipelines while maintaining type safety.
Choosing the Right Tool
The choice between Flink SQL and Kafka Streams DSL often depends on the specific requirements of your data pipeline:
- Use Flink SQL when:
- You need complex multi-table joins.
- Your team includes data analysts who prefer SQL.
- You want to unify batch and stream processing logic.
- Schema evolution and catalog management are critical.
- Use Kafka Streams DSL when:
- You require tight integration with existing Java services.
- You need custom state store implementations.
- Interactive queries or key-value lookups from external systems are frequent.
- You need fine-grained control over exactly-once semantics at the record level.
Conclusion
Declarative stream processing is not about removing control; it is about elevating the level of abstraction to match the complexity of modern data architectures. Both Flink SQL and Kafka Streams DSL offer robust, production-ready tools for building real-time applications. By understanding the strengths of each, data engineers can build pipelines that are not only performant but also maintainable and scalable. As the industry continues to move toward "data fabrics" and unified data management, the ability to switch seamlessly between declarative and imperative paradigms will be a key skill for any data professional.