Java 24 microservice framework for Kafka. No build tool: annotate a method, ship your .java files.
2.5K
๐ A tiny but agile microservice framework built in Java 24 with first-class support for Docker ๐ณ, Kubernetes โธ๏ธ, and Kafka ๐จ event streaming. Built for speed, scale, and simplicity.
No build tool. There is no Maven or Gradle step to ship a service. Your
.java files are copied into the image and compiled at container start.
Fetched automatically at container start โ nothing to add to your pom.xml:
| Library | Used for |
|---|---|
kafka-clientsโ | Kafka producer/consumer |
gsonโ | JSON (de)serialization |
slf4j-apiโ + slf4j-simple | Logging |
spotbugs-annotationsโ | Static-analysis annotations |
One environment variable, no pom.xml change: FLUID_METRICS=prometheus
pulls in the Prometheus client (see ๐ Metrics, below); selecting
snappy, lz4 or zstd compression needs its own jar too (see
โ๏ธ Configuration below).
pom.xmlFluid has no opinion on how a service talks to a database โ add the driver or ORM you want and it is fetched the same way everything else is. Demonstrated end to end, with Docker Compose and CI, in fluid-exampleโ :
| Backend | With an ORM | Without |
|---|---|---|
| PostgreSQL | Hibernate | raw JDBC |
| MongoDB | โ | official sync driver |
| H2 (embedded) | โ | raw JDBC |
A service is a Dockerfile, a pom.xml listing your dependencies, and your
Java files. Nothing else.
FROM maifeeulasad/fluid-builder:latest
COPY pom.xml .
COPY *.java .
At container start Fluid downloads the dependencies your pom.xml names,
compiles your sources, finds your listeners, and starts consuming.
Any class with an annotated method is picked up โ the file name does not matter.
public class MessageService {
@KafkaListener(topic = "orders", groupId = "order-processors")
public void handleOrder(String message) {
System.out.println("๐ฅ " + message);
}
}
To write a producer instead of a consumer, supply your own Fluid.java. It
replaces the framework's entry point.
public class Fluid {
public static void main(String[] args) throws InterruptedException {
try {
for (int i = 0; i < 1000; i++) {
KafkaMessenger.sendMessage("orders", "Message " + i);
}
} finally {
KafkaMessenger.shutdown();
}
}
}
@KafkaListener โ the common case@KafkaListener(topic = "orders", groupId = "order-processors")
public void handleOrder(String message) { }
| Attribute | Default | |
|---|---|---|
topic | required | topic to consume |
groupId | required | consumer group to join |
bootstrapServers | inherits | broker address; blank follows BOOTSTRAP_SERVERS |
@KafkaSubscription โ full controlSame job, with batching, dead letter routing, topic creation and consumer
tuning. Use it when you need one of those; @KafkaListener otherwise.
@KafkaSubscription(
topic = "orders",
groupId = "order-processors",
partitions = 5,
batchEnabled = true,
enableDeadLetterQueue = true,
deadLetterTopic = "order-errors",
maxPollRecords = 100
)
public void handleOrder(String message) { }
Some attributes are accepted but not yet honoured โ each is marked not yet honoured in its javadoc, so a setting that does nothing says so rather than pretending.
A method may carry @KafkaListener or @KafkaSubscription, not both:
each drives its own consumer, so the handler would run twice per record.
Startup rejects it.
@SendTo โ forward the result@KafkaListener(topic = "orders", groupId = "order-processors")
@SendTo(topic = "processed-orders")
public String handleOrder(String message) {
return "Processed: " + message;
}
@ShortCircuit โ route failures@KafkaListener(topic = "orders", groupId = "order-processors")
@ShortCircuit(topic = "order-errors")
public void handleOrder(String message) {
if (message.contains("fail")) {
throw new RuntimeException("bad message");
}
}
| Variable | Default | |
|---|---|---|
BOOTSTRAP_SERVERS | kafka:9092 | broker address for everything that does not set one explicitly |
KAFKA_COMPRESSION_TYPE | gzip | producer codec |
FLUID_METRICS | unset (off) | metrics backend โ currently prometheus |
FLUID_METRICS_PORT | 9400 | scrape endpoint port |
FLUID_METRICS_PATH | /metrics | scrape endpoint path |
FLUID_METRICS_PUSHGATEWAY | unset | Pushgateway address, e.g. pushgateway:9091 |
FLUID_METRICS_JOB | hostname | job label on pushed metrics |
gzip is the default because it is the only compressing codec that works
with the dependency set Fluid downloads. snappy, lz4 and zstd need
third-party libraries that kafka-clients does not bundle โ select one and
Fluid tells you which library to add rather than failing later at send time.
Off by default. Set one variable and the backend's jars are fetched at
container start โ nothing to add to your pom.xml, and an image that does not
use metrics stays exactly as small as before.
environment:
- FLUID_METRICS=prometheus
Then scrape http://your-service:9400/metrics.
Everything is named fluid_*, and the prefix is enforced rather than merely
documented:
fluid_messages_consumed_total{topic,group,handler}
fluid_messages_produced_total{topic}
fluid_handler_duration_seconds{handler}
fluid_handler_failures_total{handler,topic}
fluid_partition_rewinds_total{topic,partition}
A pull-based scrape cannot observe a process that has already stopped. A batch producer that sends and exits may never be scraped at all.
Point it at a Pushgateway and it pushes on shutdown, whatever entry point it uses:
environment:
- FLUID_METRICS=prometheus
- FLUID_METRICS_PUSHGATEWAY=pushgateway:9091
Scrape the gateway with honor_labels: true, or Prometheus rewrites every
pushed series to the gateway itself and all your producers collapse into one.
At start-up Fluid works out every route your service can take, before it
takes any โ from @KafkaListener, @SendTo and @ShortCircuit, and by
parsing your sources for direct KafkaMessenger.sendMessage("topic", ...)
calls.
fluid_route_declared{from,to,type,dynamic} 1 for every possible route
fluid_route_traversed_total{from,to,type,dynamic} times it actually carried a message
A route with declared=1 and no traversals is a path that exists but has never
been used. A topic computed at runtime cannot be known in advance, so it is
marked dynamic="true" and appears only once used โ which is why the two cases
are distinguishable rather than both looking like absence.
[Your Producer] โโโถ KafkaMessenger โโโถ [Kafka Broker] โโโถ MessageConsumer โโโถ [Your Listener]
MessageProducer KafkaProcessor
Run it the way it ships:
java DependencyDownloader.java # populates lib/
java -cp "lib/*" Fluid.java
Run the tests:
mvn test
Maven exists for tests, linting and CI only โ its output is never shipped,
and it compiles the same flat sources in place. See DEVELOPMENT.md.
Every tag published to Docker Hub (latest, the date tag, and the commit
tag) carries build provenance and an SBOM, attached as attestation
manifests alongside the image rather than documented separately โ so they
can't silently go stale. The publish workflow refuses to push an image that
fails its own smoke test first.
Verify either with Buildx, no pull required:
docker buildx imagetools inspect maifeeulasad/fluid-builder:latest
or inspect the SBOM's actual package list with Docker Scoutโ :
docker scout sbom maifeeulasad/fluid-builder:latest
Runnable, docker-composed examples โ Kafka producers/consumers, and database read/write with and without an ORM โ live in fluid-ecosystem/fluid-exampleโ , built against this image.
fluid.yaml@KafkaSubscription accepts the attributes, not yet honouredpreserveOrder, not yet honouredPRs are welcome! Open an issue or suggest an improvement โ let's make microservices fun and fast again ๐งช
MIT License ยฉ 2026 Maifee Ul Asad
Content type
Image
Digest
sha256:cf5b8c468โฆ
Size
501.5 MB
Last updated
9 days ago
docker pull maifeeulasad/fluid-builder