This episode builds your own Flink extensions: custom sources and sinks, custom serializers and codecs, understanding the operator lifecycle and checkpoint hooks, and a guide to contributing to the connector ecosystem.

Flink's connector ecosystem is vast, but it doesn't cover every system. Sometimes you have to integrate with internal protocols, third-party APIs, or special data formats. Episode 18 teaches how to extend Flink yourself — building custom sources and sinks, writing serializers, and understanding the operator lifecycle.
We'll create a custom source, learn how to build a sink, write a custom serializer, dissect the operator lifecycle and checkpoint hooks, and close with a guide to contributing to the official connector ecosystem. After this episode, no system is truly "impossible" to connect to Flink.
The easiest way to build a source is to extend RichSourceFunction:
import org.apache.flink.streaming.api.functions.source.RichSourceFunction;
import org.apache.flink.streaming.api.functions.source.SourceFunction;
public class CustomSource extends RichSourceFunction<String> {
private volatile boolean running = true;
@Override
public void run(SourceFunction.SourceContext<String> ctx) throws Exception {
while (running) {
ctx.collect("hello-" + System.currentTimeMillis());
Thread.sleep(1000);
}
}
@Override
public void cancel() {
running = false;
}
}ctx.collect sends records into the pipeline, and cancel() is called when the job stops — this is where you close connections. The volatile boolean running guarantees the flag is visible across threads when cancelled.
For production, use the newer Source interface, which supports checkpoints, position recovery, and batching. SourceFunction is still valid and often used for quick integrations, but the Source interface is Flink's long-term direction.
A custom sink extends RichSinkFunction:
import org.apache.flink.streaming.api.functions.sink.RichSinkFunction;
public class HttpSink extends RichSinkFunction<Order> {
@Override
public void invoke(Order value, Context ctx) throws Exception {
HttpClient.post("/orders", value);
}
}invoke is called for every record. For high throughput, batch records in state and send them periodically — calling HTTP per record will make the sink the bottleneck.
A sink that supports two-phase transactions implements TwoPhaseCommitSinkFunction — the pattern the Kafka sink uses for exactly-once. This covers the beginTransaction, preCommit, and commit methods. A full implementation requires transaction support from the destination system; if that isn't available, use an idempotent sink instead.
When Flink doesn't yet recognize your data type, implement TypeSerializer:
import org.apache.flink.api.common.typeutils.TypeSerializer;
import org.apache.flink.core.memory.DataInputView;
import org.apache.flink.core.memory.DataOutputView;
public class PointSerializer extends TypeSerializer<Point> {
@Override
public void serialize(Point value, DataOutputView target) throws IOException {
target.writeDouble(value.getX());
target.writeDouble(value.getY());
}
@Override
public Point deserialize(Point reuse, DataInputView source) throws IOException {
return new Point(source.readDouble(), source.readDouble());
}
}serialize writes the object to a buffer, and deserialize reads it back. An efficient serializer reduces data transfer overhead between operators — one of the often-overlooked performance levers.
If you use a non-standard wire format (not JSON, Avro, Protobuf), write a codec as a SerializationSchema and DeserializationSchema. Both are plugged into Kafka and similar connectors, so a custom format can be used without writing an entire connector.
All rich functions (Rich*) have a lifecycle:
open: initialization, called once before processing.close: cleanup, called when the job stops.snapshotState: stores state to a checkpoint.initializeState: loads state from a checkpoint or savepoint.@Override
public void snapshotState(FunctionSnapshotContext context) throws Exception {
bufferState.clear();
for (String item : buffer) {
bufferState.add(item);
}
}
@Override
public void initializeState(FunctionInitializationContext context) throws Exception {
bufferState = context.getOperatorStateStore()
.getListState(new ListStateDescriptor<>("buffer", String.class));
if (context.isRestored()) {
for (String item : bufferState.get()) {
buffer.add(item);
}
}
}snapshotState stores the buffer into operator state, and initializeState loads it back on restore. The combination makes your custom operator equal to built-in operators in terms of fault tolerance.
If your connector is generally useful, consider contributing to apache/flink-connectors. The steps:
mvn clean package -DskipTests
./bin/flink run -d target/custom-connector.jarThe mvn clean package command builds the connector JAR, and ./bin/flink run -d tests it on a local cluster before you share it with the community.
Episode 18 taught you to extend Flink: building custom sources and sinks, writing serializers and codecs for special formats, leveraging the operator lifecycle and checkpoint hooks, and the steps to contribute to the connector ecosystem.
The key takeaways:
cancel and invoke are where you manage connections in sources and sinks.snapshotState and initializeState make custom operators fault-tolerant.In the next episode, episode 19, we'll discuss operational readiness & runbooks — writing runbooks for job failures, responding to checkpoint failures and job crashes, backup and disaster recovery, and chaos testing for streaming resilience. You'll be ready for the night when production screams.