60-Day Kafka 4 Learning Plan · Week 9 — Capstone & Career Stack: Spring Boot 3 · Kafka 4 KRaft · STOMP WebSocket · STOMP · Flutter
Goal
Build the production-grade capstone: a REST API (labs-api) produces events to Kafka, labs-socket consumes them and pushes in real time over WebSocket to Flutter clients — end-to-end latency under 200ms — and fix a genuine cross-language compile-time bug plus a WebSocket authorization gap that re-surfaces a pattern from Day 20 in this concrete full-stack setting.
Prerequisites
- Java 21
- Spring Boot 3.5+
- Docker + Docker Compose v2
- Flutter 3.24+ / Dart 3.4+
- Maven 3.9+
- A Kafka 4 (KRaft) cluster — this article reuses the TLS + SASL_SSL +
ACL-secured docker-compose setup from the prior kafka-secure-cluster-
capstone article
Overview
A REST call lands on labs-api. 200 milliseconds later, a Flutter app on a phone renders it. In between: Kafka, a partition key decision that affects ordering guarantees, a STOMP session that has to know who it’s talking to before it can push anything private, and a Dart compiler that will refuse to build the app if the UI doesn’t handle every event type the backend can send.
This is the capstone for the labs.events series: labs-api produces to Kafka, labs-socket consumes and pushes over authenticated WebSocket, Flutter renders it live. It reuses the secured Kafka 4 KRaft cluster from the prior article — TLS, SASL_SSL, ACLs — and builds the actual product on top of it: a live event feed with a real per-user privacy boundary and a real latency budget.
Two things in the source design for this capstone don’t survive contact with a second client language and a real authorization requirement. Both get fixed in this article, in the actual shipped code, not just described.
Concept
Three pieces move independently and have to agree on one contract:
labs-api— a stateless REST producer. It never talks to a client directly after accepting a request; it hands the event to Kafka and returns202 Accepted.- Kafka — the durability and ordering layer. The partition key decision made here is what determines whether a user’s events can ever arrive out of order downstream.
labs-socket— a Kafka consumer that is also a STOMP/WebSocket server. It’s the only piece in the chain that knows about live client connections, and the only piece that can enforce who gets to see what.
STOMP (Simple/Streaming Text Oriented Messaging Protocol) sits on top of the WebSocket transport and gives you destinations, subscriptions, and frame semantics — SEND, SUBSCRIBE, CONNECT — instead of raw byte frames. Spring’s SimpMessagingTemplate and @EnableWebSocketMessageBroker build a small in-process message broker over it. That’s the layer where “can this client see this data” either gets enforced or doesn’t.
What We’re Building
Flutter client (STOMP over native WS) Browser (STOMP over SockJS)
│ SUBSCRIBE /user/queue/events │ SUBSCRIBE /topic/events/all
│ CONNECT Authorization: Bearer <jwt> │ CONNECT Authorization: Bearer <jwt>
▼ ▼
labs-socket (:8090)
┌─────────────────────────────┐
│ StompAuthChannelInterceptor │ verifies JWT on CONNECT,
│ → sets session Principal │ sets StompPrincipal(userId)
├─────────────────────────────┤
│ @KafkaListener(concurrency=6)│ one thread per partition
│ → WebSocketBroadcastService │
│ convertAndSendToUser(...)│ routes by session Principal,
│ convertAndSend(/all) │ not by client-supplied string
└──────────────▲───────────────┘
│ 6 partitions, key = userId
Kafka 4 (KRaft) — labs.events
▲
┌──────────────┴───────────────┐
│ labs-api (:8080) │
│ POST /api/events │
│ { userId, type, payload } │
│ KafkaTemplate.send(topic, │
│ userId, event) │
└────────────────────────────────┘
userId shows up three times in this diagram and it’s doing a different job each time: as the Kafka partition key (ordering), as the JWT subject (identity), and as the argument to convertAndSendToUser (routing). None of those three uses trust a client-supplied string — they’re all derived server-side, which is the point.
Implementation
Step 1 — labs-api: partition key is userId, not eventId
The event contract grows a userId field, @NotBlank-validated:
// labs-api/src/main/java/com/boottechsolutions/labsapi/dto/EventRequest.java
public record EventRequest(
@NotBlank String userId,
@NotNull EventType type,
@NotBlank String payload,
String source
) {}
EventPublisherService keys the Kafka send on userId, not on the randomly generated eventId:
// labs-api/src/main/java/com/boottechsolutions/labsapi/service/EventPublisherService.java
CompletableFuture<SendResult<String, LabEvent>> future =
kafkaTemplate.send(topic, event.userId(), event);
Kafka guarantees order only within a partition, and a key deterministically maps to one partition. Key by eventId (effectively random) and events spread evenly across all 6 partitions — great for throughput, but two events from the same user, produced milliseconds apart, can land on different partitions and be consumed out of order. Key by userId and every event for that user lands on the same partition, so labs-socket‘s per-partition consumer thread processes them in produce order. That’s what a live per-user feed needs: ORDER_CREATED must never render after PAYMENT_PROCESSED when it’s the same order.
The tradeoff: a user who fires events far more often than everyone else concentrates traffic on one of the 6 partitions — a hot partition — instead of the even spread a random key would give you. At this capstone’s traffic level that’s a non-issue. In production, watch per-partition consumer lag; if one tenant’s volume dominates, you’re back to the ordering-vs-throughput tradeoff with no free lunch.
Step 2 — labs-api: producer tuning that doesn’t fight the latency budget
// labs-api/src/main/java/com/boottechsolutions/labsapi/config/KafkaProducerConfig.java
props.put(ProducerConfig.ACKS_CONFIG, "all");
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
props.put(ProducerConfig.LINGER_MS_CONFIG, 5);
props.put(ProducerConfig.BATCH_SIZE_CONFIG, 65_536);
props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "lz4");
linger.ms=5 is a 5ms window to batch outgoing records before sending — negligible against a 200ms budget, and it buys real throughput headroom. acks=all + idempotence is the safe combination (leader and all in-sync replicas must acknowledge; the broker deduplicates retried sends by producer ID + sequence number). None of this is new versus a throughput-oriented producer — the point is that at these values, none of it meaningfully taxes the latency budget either. The same can’t be said for one setting on the consumer side — see Step 4.
That’s lz4, not the snappy a first draft of this config used. Worth saying why, because it’s not a style preference: labs-api runs on eclipse-temurin:21-jre-alpine, and xerial’s snappy-java — the library backing Kafka’s snappy codec — ships a JNI native library linked against glibc. Alpine is musl-libc; there’s no ld-linux-x86-64.so.2 on the image for that .so to bind against. The producer builds and starts cleanly, because KafkaTemplate‘s producer is lazy — it doesn’t touch Snappy until the first real send(). Then every request 500s with UnsatisfiedLinkError: ... Error loading shared library ld-linux-x86-64.so.2 buried in the stack trace under a generic KafkaException. lz4 is Kafka’s other built-in codec, implemented in pure Java, no native dependency, and close enough to snappy’s compression ratio for JSON payloads this size that there’s no reason to carry an Alpine-specific failure mode for it.
Step 3 — labs-socket: the STOMP authorization gap
The original design for this service looked like this:
// what NOT to ship
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS();
}
// what NOT to ship
ws.convertAndSend("/topic/events/" + event.userId(), event);
No authentication anywhere — setAllowedOriginPatterns("*") controls CORS, not identity, and nothing else in that config touches who’s connecting. Combine that with pushing to a destination built from event.userId(), and the “privacy” of a user’s feed rests entirely on an attacker not knowing or guessing their userId. Any STOMP client can SUBSCRIBE /topic/events/user-1, then /topic/events/user-2, then iterate. /topic/* is a broadcast destination by design — Spring’s simple broker delivers to every subscriber, with zero per-subscriber filtering. Embedding an identifier in the topic name doesn’t make it private; it makes it guessable.
This is the STOMP-specific shape of a pattern that shows up anywhere a “private” channel is really just a public one with an obscure name: the server has to decide who’s allowed to see a message, and that decision can’t live in a string the client controls.
The fix has two parts. First, authenticate the STOMP session at CONNECT time:
// labs-socket/src/main/java/com/boottechsolutions/labssocket/security/StompAuthChannelInterceptor.java
@Component
@RequiredArgsConstructor
public class StompAuthChannelInterceptor implements ChannelInterceptor {
private final JwtService jwtService;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor =
MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (accessor == null || accessor.getCommand() != StompCommand.CONNECT) {
return message;
}
String header = accessor.getFirstNativeHeader("Authorization");
if (!StringUtils.hasText(header) || !header.startsWith("Bearer ")) {
throw new MessagingException("Missing or malformed Authorization header on STOMP CONNECT");
}
String token = header.substring("Bearer ".length());
try {
String userId = jwtService.verifyAndExtractUserId(token);
accessor.setUser(new StompPrincipal(userId));
} catch (JwtException ex) {
throw new MessagingException("Invalid or expired token", ex);
}
return message;
}
}
Throwing from preSend on a CONNECT frame is turned into a STOMP ERROR frame by StompSubProtocolHandler, and the session is closed before the client can ever send a SUBSCRIBE — an unauthenticated client never reaches a state where guessing destination names is possible.
Second — and this is the part that actually closes the gap, not just adds a checkpoint in front of the old design — stop encoding identity in a destination string at all:
// labs-socket/src/main/java/com/boottechsolutions/labssocket/service/WebSocketBroadcastService.java
messagingTemplate.convertAndSendToUser(event.userId(), "/queue/events", event);
messagingTemplate.convertAndSend("/topic/events/all", event);
convertAndSendToUser doesn’t build /topic/events/user-42 and hope nobody subscribes to it who shouldn’t. It goes through UserDestinationMessageHandler, which maps /user/queue/events to the one physical queue belonging to the STOMP session whose Principal.getName() equals the first argument — the identity StompAuthChannelInterceptor set at CONNECT, not anything derived from what the client sends afterward. A client can SUBSCRIBE /user/queue/events all day; there is no destination string it could send that resolves to somebody else’s session, because the mapping isn’t driven by the destination string at all. /topic/events/all stays a genuine broadcast — every authenticated session sees it — which is correct for an ops dashboard and would be wrong for anything user-specific.
// labs-socket/src/main/java/com/boottechsolutions/labssocket/config/WebSocketConfig.java
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic", "/queue");
registry.setApplicationDestinationPrefixes("/app");
registry.setUserDestinationPrefix("/user");
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(stompAuthChannelInterceptor);
}
Two STOMP endpoints are registered, both behind the same interceptor: /ws with SockJS fallback for browsers, /ws-native without SockJS for Flutter — stomp_dart_client speaks native WebSocket STOMP framing directly and has no SockJS support to speak of, so giving it a SockJS endpoint would just fail to connect.
For local development, labs-socket mints its own tokens:
// labs-socket/src/main/java/com/boottechsolutions/labssocket/controller/DevTokenController.java
@GetMapping("/dev/token")
public TokenResponse issueToken(@RequestParam @NotBlank String userId) {
String token = jwtService.mint(userId, TOKEN_TTL);
return new TokenResponse(userId, token, TOKEN_TTL.toSeconds());
}
This endpoint has no login step and no password check — it exists only so the demo runs end-to-end without standing up a full identity provider. JwtService uses HS256 with a shared secret from JWT_SECRET; a real deployment verifies tokens issued by whatever OAuth2/OIDC provider already sits in front of your API (Spring Authorization Server, Keycloak, your existing login flow) and validates against its published keys, not a secret this service also holds. Ship /dev/token to production and you’ve built a service that will happily authenticate as anyone who asks.
/dev/token also needs its own CORS mapping, separately from the STOMP endpoints:
// labs-socket/src/main/java/com/boottechsolutions/labssocket/config/CorsConfig.java
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/dev/token")
.allowedOriginPatterns("*")
.allowedMethods("GET");
}
WebSocketConfig‘s setAllowedOriginPatterns("*") governs the STOMP handshake only — it has no effect on a plain @GetMapping. Open client/index.html straight off disk (a file:// URL) and the browser sends Origin: null on its fetch() to /dev/token; without this mapping, that request dies with a CORS error in the console before the STOMP connection is ever attempted. Same root cause as the authorization gap this article opened with, opposite direction: there, a boundary that should have checked identity didn’t; here, a boundary that has nothing to do with identity (CORS is about which origins may read a response, not who the caller is) still has to be configured deliberately, or the browser enforces its own default — deny.
Step 4 — labs-socket: the fetch.min.bytes trap
// labs-socket/src/main/java/com/boottechsolutions/labssocket/config/KafkaConsumerConfig.java
factory.setConcurrency(6);
factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);
Concurrency matches partition count — one consumer thread per partition, the ceiling for parallelism on this topic. Two things worth flagging that don’t show up by just reading the happy path:
spring.kafka.listener.ack-mode in application.yml does nothing here. This service builds ConcurrentKafkaListenerContainerFactory as a manual @Bean, so Spring Boot’s autoconfiguration — the thing that would read that YAML property — never touches this factory. The ack mode that actually takes effect is the one set in code, above. The YAML property is left in as documentation of intent, but relying on it to do anything would be a mistake; if you ever swap this for Boot’s autoconfigured factory, this becomes live wiring instead of a comment.
fetch.min.bytes tuned for throughput actively fights a latency SLA. A natural-looking config for a Kafka consumer is fetch.min.bytes: 1048576 (1 MiB) — ”don’t bother responding to a fetch request until there’s a meaningful amount of data.” Combined with the default fetch.max.wait.ms of 500ms, that setting tells the broker: wait up to 500ms for 1 MiB to accumulate before returning, even if there’s less. At low-to-moderate event rates — which is most of this topic’s traffic in the demo, and plausibly in early production — the broker will hit that 500ms ceiling far more often than it hits the byte threshold. Half a second of consumer-side delay, on its own, is more than double the entire 200ms end-to-end budget. This service leaves fetch.min.bytes at its default of 1 byte — return whatever’s available immediately — because consumption latency, not per-fetch network overhead, is what this service exists to optimize for. Revisit only if produce volume grows enough that per-fetch overhead actually becomes the bottleneck; that’s a real tradeoff, just not the one that matters for a live per-user feed.
Step 5 — Flutter: the cross-language compile bug
labs-api‘s EventType enum grows a sixth value for this capstone:
// labs-api/src/main/java/com/boottechsolutions/labsapi/model/EventType.java
public enum EventType {
USER_REGISTERED,
ORDER_CREATED,
PAYMENT_PROCESSED,
INVENTORY_UPDATED,
NOTIFICATION_SENT,
SYSTEM_ALERT
}
labs-socket‘s mirror of this type stays a String, deliberately — see the comment in its LabEvent record. That decoupling means labs-socket forwards "SYSTEM_ALERT" without caring whether it’s a value it’s ever seen before, and without needing a redeploy when labs-api adds a seventh. Nothing breaks. Nothing warns you either.
The Flutter client makes a different choice. EventType is a genuine Dart enum, and the widget that picks a Material icon per event type uses a switch expression — a Dart 3 feature, distinct from a switch statement — over it:
// what happens if you add SYSTEM_ALERT on the Java side
// and forget to update this file
IconData get _icon => switch (event.type) {
EventType.userRegistered => Icons.person_add_alt_1,
EventType.orderCreated => Icons.shopping_cart_checkout,
EventType.paymentProcessed => Icons.payments,
EventType.inventoryUpdated => Icons.inventory_2,
EventType.notificationSent => Icons.notifications_active,
// no case for EventType.systemAlert yet
};
Dart’s analyzer requires a switch expression over an enum to be exhaustive. Add systemAlert to the EventType enum and leave this switch unmodified, and flutter analyze — and flutter build — fail before the app runs, with an error to the effect of:
error: The type 'EventType' is not exhaustively matched by the switch
cases since it doesn't match 'EventType.systemAlert'. Try adding a
default case or cases that match 'EventType.systemAlert'.
That’s a genuine compile-time failure, not a lint warning you can ignore. The fix shipped in this article’s EventTile (see Step 6’s full listing) adds the missing arm:
EventType.systemAlert => Icons.warning_amber_rounded,
The instructive part isn’t the one-line fix — it’s the asymmetry. The same class of change (a new enum member the wire format now carries) is silent in labs-socket because type is modeled as a String there, and a hard build failure in Flutter because type is modeled as an enum there. Both choices are defensible, but they’re not equivalent: the String choice optimizes for “never blocks a deploy,” the enum choice optimizes for “never ships an incomplete UI.” A live event feed’s icon picker is exactly the kind of place you want the second guarantee — a missing icon is a user- visible bug, and Dart’s exhaustiveness check turns “missing icon” into “doesn’t compile,” which is a much cheaper place to catch it than a support ticket.
One more asymmetry worth internalizing: EventType.fromWire(), which parses the raw JSON string into the enum, switches on a String, not an EventType — and Dart does not enforce exhaustiveness there, because a String‘s domain isn’t a fixed, known set of values the way an enum’s is. That switch needs an explicit wildcard _ case (which this codebase turns into a FormatException, handled at runtime in SocketService). This is the boundary: parsing untrusted wire data can only ever be checked at runtime, no matter the language: but once it’s inside your own type system as an enum, the compiler can hold every downstream consumer of that type accountable for staying exhaustive. Push the runtime check to the narrowest possible boundary — the parse step — and let everything after it benefit from compile-time guarantees.
Step 6 — Flutter: subscribing to the private queue
// labs-flutter/lib/services/socket_service.dart
_client = StompClient(
config: StompConfig(
url: wsUrl, // ws://.../ws-native
stompConnectHeaders: {'Authorization': 'Bearer $bearerToken'},
webSocketConnectHeaders: {'Authorization': 'Bearer $bearerToken'},
onConnect: (StompFrame connectFrame) {
onConnectionChange(true);
_client!.subscribe(
destination: '/user/queue/events',
callback: (StompFrame frame) => _handleFrame(frame, onEvent, onError),
);
},
reconnectDelay: const Duration(seconds: 5),
),
);
_client!.activate();
The client subscribes to /user/queue/events — never to anything containing its own userId, because it doesn’t need to supply one. The server already knows who this session is from the JWT presented at CONNECT. This is the client-side mirror of the server-side fix: neither side puts identity in a destination string.
Testing
Three layers, each catching a different class of regression:
// labs-socket/src/test/java/.../security/StompAuthChannelInterceptorTest.java
@Test
void missingAuthorizationHeader_rejectsConnect() {
Message<byte[]> connect = connectFrame(null);
assertThatThrownBy(() -> interceptor.preSend(connect, channel))
.isInstanceOf(MessagingException.class);
}
This test — and its siblings for a malformed header and a token signed with the wrong key — is the regression guard for the authorization fix itself. If someone later “simplifies” the interceptor and it stops rejecting bad tokens, this fails immediately, in CI, not in a security review.
// labs-socket/src/test/java/.../EventConsumerIntegrationTest.java
@EmbeddedKafka(partitions = 6, topics = {"labs.events", "labs.events.DLT"}, ...)
@Test
void consumedMessage_isBroadcastOverWebSocket() {
producer.send("labs.events", event.userId(), event);
await().atMost(Duration.ofSeconds(10))
.untilAsserted(() -> verify(broadcastService).broadcast(any(LabEvent.class)));
}
EmbeddedKafka doesn’t support SASL_SSL, so this runs against a plaintext in-process broker (application-test.yml overrides security.protocol) — it verifies the consumer wiring and the Kafka→broadcast path, not TLS/ACL negotiation. That gets validated against the real docker-compose stack.
// labs-flutter/test/event_type_test.dart
test('every EventType value has a non-empty label', () {
for (final type in EventType.values) {
expect(type.label, isNotEmpty);
}
});
This test is almost redundant with the compiler — if it compiles, the exhaustive switch backing label already covers every member. It’s here to document that guarantee for a reader who doesn’t know Dart 3’s exhaustiveness rules, not because the test itself is doing enforcement work the compiler isn’t already doing.
End-to-end smoke test, against the real stack:
1. Configure secrets
cp .env.example .env
# edit .env with real passwords and a JWT_SECRET (32+ bytes)
2. Generate TLS material
set -a; source .env; set +a
./secrets/generate-certs.sh
Same Kafka TLS + SASL_SSL + ACL setup as the prior secure-cluster article — see that article if you need the listener/ACL rationale. All generated material is gitignored; regenerate locally, never commit it.
3. Bring the stack up
docker compose up -d --build
Startup order, enforced by depends_on:
broker— starts and passes its healthcheck.kafka-bootstrap— seeds SCRAM users, createslabs.events(6 partitions) /labs.events.DLT, applies ACLs, then exits0.labs-api(:8080),labs-socket(:8090) — start once bootstrap completes successfully.
4. Get a token and publish an event
# labs-socket mints this for the demo — see DevTokenController.
# Never ship this endpoint to a real environment.
TOKEN=$(curl -s "http://localhost:8090/dev/token?userId=user-42" | jq -r .token)
curl -s -X POST http://localhost:8080/api/events \
-H "Content-Type: application/json" \
-d '{"userId":"user-42","type":"ORDER_CREATED","payload":"first order"}'curl -s -X POST http://localhost:8080/api/events \
-H "Content-Type: application/json" \
-d '{"userId":"user-42","type":"ORDER_CREATED","payload":"first order"}'
5. Watch it arrive
- Ops dashboard (public feed, any authenticated session): open
client/index.html— it fetches its own dev token and subscribes to/topic/events/all.

- Per-user feed (private, this exact userId only): run the Flutter app.
cd labs-flutter
flutter pub get
flutter run \
--dart-define=LABS_SOCKET_HTTP_URL=http://10.0.2.2:8090 \
--dart-define=LABS_SOCKET_WS_URL=ws://10.0.2.2:8090/ws-native
10.0.2.2 is the Android emulator’s alias for the host’s localhost — use localhost on iOS Simulator / desktop / web, or your machine’s LAN IP on a physical device.
Fire a few more events for user-42 and a couple for a different userId (e.g. user-99) — only user-42‘s events reach the Flutter app running as user-42. That’s the authorization fix in StompAuthChannelInterceptor / WebSocketBroadcastService doing its job; see the article for what breaks without it.


Performance Considerations
Where the 200ms budget actually goes, roughly, on a local docker-compose stack:

Locally, that totals well under 200ms with headroom. The two levers that can blow the budget are both things this article deliberately did not do the “obvious” way: leaving fetch.min.bytes at 1 MiB (adds up to 500ms of consumer-side wait), or running acks=all against a multi-broker cluster with slow replication (adds broker round-trip time proportional to your weakest in-sync replica). WebSocketBroadcastService records a labs.events.e2e.latency Micrometer timer — the delta between event.timestamp() (stamped in labs-api at publish time) and Instant.now() at broadcast — with p50/p95/p99 percentiles, specifically so this budget is something you can watch in production, not just estimate once in a blog post.
A few other things worth knowing before this leaves a laptop:
- RF=1 in this demo, RF=3 in the design spec. A single-broker dev cluster can’t do RF=3 —
kafka-topics.shwould refuse it. Run the 3-broker cluster from the prior secure-cluster article before treating this topic’s durability, or itsacks=alllatency characteristics, as representative of production. - The in-process simple broker doesn’t scale horizontally.
enableSimpleBroker("/topic", "/queue")keeps all STOMP session state inlabs-socket‘s own JVM. Run two instances behind a load balancer and a client connected to instance A never receives a broadcast triggered by an event that instance B’s consumer thread picked up — there’s no shared state between them. Scaling this out requires either sticky sessions (defeats the point of horizontal scaling) or swapping the simple broker for a full STOMP broker relay (RabbitMQ, ActiveMQ) that all instances connect to — see Alternative Approaches. convertAndSendis synchronous. If a slow subscriber causes the server-side per-session send queue to back up, the calling thread — the Kafka consumer thread, in this design — blocks with it. A bounded queue (configurable via the STOMP session’s outbound capacity) turns “one lagging browser tab” into “bounded backpressure” instead of “unbounded memory growth,” but it does mean a sufficiently pathological subscriber can slow down Kafka consumption for everyone sharing that partition’s consumer thread. Monitorlabs.events.e2e.latency‘s p99, not just p50, to catch this.
Common Pitfalls
- Trusting a destination string as an authorization boundary. This is the headline bug in this article, but it generalizes past STOMP: any time “privacy” is implemented as “the identifier is hard to guess,” it isn’t privacy, it’s obscurity with an expiration date measured in how motivated the first curious user is.
- Copying a throughput-tuned Kafka config onto a latency-sensitive service.
fetch.min.bytes: 1048576is a perfectly reasonable value — for a batch analytics consumer that doesn’t care if a fetch takes an extra 400ms. It is not reasonable on a service whose entire job is “under 200ms.” Config values don’t carry their intent with them; know what you’re optimizing for before copying a number from a tutorial (including this one). - Assuming a YAML property does something because Spring Boot usually autoconfigures it.
spring.kafka.listener.ack-modeis real, Boot does read it — for the container factory Boot builds. The moment you declare your ownConcurrentKafkaListenerContainerFactory@Bean, as this service does (needed for the customJsonDeserializerand dead-letter wiring), that autoconfiguration path is bypassed entirely. Grep for where a property is actually consumed before trusting that setting it in YAML is sufficient. - Modeling a cross-service enum as an enum on both sides. It feels more “type-safe” to mirror
EventTypeas a Dart enum and keeplabs-socket‘s Java side as an enum too. Do that and every new event type becomes a coordinated three-repo deploy —labs-api,labs-socket,labs-flutter— withlabs-socketunable to forward a value it doesn’t compile against. Decouplinglabs-socket‘s copy to aStringwas a deliberate choice to keep the Kafka consumer a dumb, deploy-independent relay; keeping Flutter’s copy as a real enum was an equally deliberate choice to make the UI layer refuse to ship incomplete. Match the type choice to what that specific service needs to guarantee, not to a blanket “always use enums” rule. - Forgetting the DLT producer needs its own ACL.
labs-socketproduces tolabs.events.DLTafter retries are exhausted (DeadLetterPublishingRecoverer) — that’s aWriteoperation the broadlabs.*prefixedReadACL doesn’t cover. Miss it and dead-letter publishing fails with a swallowedTopicAuthorizationException, and you find out during an incident instead of during setup. - Trusting a JNI-backed codec on an Alpine base image. Confirmed while building this:
compression.type=snappyoneclipse-temurin:*-alpinebuilds and starts without a single warning, then fails everysend()at runtime withUnsatisfiedLinkError: ... ld-linux-x86-64.so.2— Alpine is musl-libc, xerial’ssnappy-javanative library is glibc-linked, and the producer doesn’t touch it until the first real message.lz4is Kafka’s pure-Java alternative codec; anything shipping a native.so— compression codecs, some metrics/tracing agents, certain crypto providers — deserves the same question before it lands on an Alpine image: does this have a JNI dependency, and does Alpine carry what it’s linked against?
Key Takeaways
- A “private” WebSocket channel that’s really a broadcast destination named after an identifier isn’t private — authorization has to be enforced server-side, from an identity established at
CONNECT, never from a string the client supplies. - Config values tuned for one goal (
fetch.min.bytesfor throughput) can silently sabotage a different goal (latency) on the exact same consumer — know which one your service is actually optimizing for. - The same architectural choice (model a cross-service field as an enum vs. a String) trades compile-time safety for deploy independence in opposite directions on each side of a service boundary — pick per service, not by rule of thumb.
The complete source code is available on GitHub.
Support me through GitHub Sponsors.
Thank you for Reading !! See you in the next post.
Next
➡️ Day 59: Interview scenarios — KRaft design patterns, Q&A
References
- Spring Framework — WebSocket STOMP messaging
- Spring —
UserDestinationMessageHandler/ user destinations - Apache Kafka — Producer configs (
linger.ms,batch.size,acks) - Apache Kafka — Consumer configs (
fetch.min.bytes,fetch.max.wait.ms) - Dart language — Patterns and exhaustiveness checking
- jjwt — Java JWT library
- stomp_dart_client on pub.dev