A real-time, multi-instance workspace messaging system built with Spring Boot, Apache Kafka, and WebSocket/STOMP. Think a minimal slice of Slack or Discord — not a production product, but a deliberate learning project that demonstrates how a horizontally scalable, event-driven backend actually works.
The core problem this project solves: Spring's built-in STOMP broker is in-memory. The moment you run two server instances, a message sent to one never reaches a client connected to the other. Kafka is the shared bus that bridges them — and the way it does so comes down to a single configuration decision about consumer group IDs.
┌─────────────┐ ┌─────────────┐
│ Client A │ │ Client B │
│ :8080/ws │ │ :8081/ws │
└──────┬──────┘ └──────┬──────┘
│ STOMP SEND │ subscribes
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ Instance A — :8080 │ │ Instance B — :8081 │
│ │ │ │
│ AuthChannelInterceptor │ │ AuthChannelInterceptor │
│ ChatController │ │ ChatController │
│ ChatService │ │ ChatService │
│ MessageRepository │ │ MessageRepository │
│ │ │ │
│ group: chat-service- │ │ group: chat-service- │
│ {uuid-A} │ │ {uuid-B} │
└───────────┬──────────────┘ └──────────────┬───────────┘
│ publish consume │ │ consume
│ ┌────────────────┐ │ │
└──────►│ Kafka │◄──────────┘ │
│ chat.messages │──────────────┘
└────────────────┘
│ save
▼
┌─────────────────┐
│ PostgreSQL │
│ messages │
│ channels │
│ users │
│ workspaces │
└─────────────────┘
Kafka delivers each message to exactly one consumer per consumer group. If both instances shared the same group ID, Kafka would treat them as competing workers and only one would receive each message — silently breaking delivery for half your clients, with no error.
By setting group-id: chat-service-${random.uuid}, each JVM generates a unique group ID at startup. Kafka treats them as completely independent consumers. Both receive every message. Both broadcast to their own locally connected clients. That is fan-out.
This is the single most important line in this project:
spring:
kafka:
consumer:
group-id: chat-service-${random.uuid}1. Client A sends STOMP SEND to /app/chat.send
│
2. AuthChannelInterceptor validates JWT
(attached at CONNECT time, not per-message)
│
3. ChatController.handleIncomingMessage()
receives ChatMessageRequest + authenticated Principal
│
4. ChatService.processIncomingMessage()
├── looks up Channel by ID
├── resolves sender from Principal (not from payload)
├── saves Message to PostgreSQL ◄── happens FIRST
└── publishes ChatMessageEvent to Kafka
│
5. Kafka broker delivers to ALL consumer groups
├── Instance A consumer receives it
│ └── broadcasts to /topic/channels/{id}/messages
│ └── Client A receives MESSAGE frame
│
└── Instance B consumer receives it
└── broadcasts to /topic/channels/{id}/messages
└── Client B receives MESSAGE frame
Why save before publish? If the save fails, no broadcast happens — clients never see a message that does not exist in history. If publish happened first, clients could see a message that was never persisted.
| Client sends to | What happens |
|---|---|
/app/chat.send |
Message saved to Postgres, published to Kafka, broadcast to all instances |
/app/chat.typing |
Typing indicator broadcast directly — no Kafka, best-effort delivery |
| Client subscribes to | What arrives |
|---|---|
/topic/channels/{id}/messages |
Chat messages for that channel |
/topic/channels/{id}/typing |
Typing indicators for that channel |
/topic/presence |
User online/offline events |
/user/queue/errors |
Validation or lookup errors, sent only to the requesting client |
| Method | Path | Description |
|---|---|---|
GET |
/api/channels/{id}/messages?page=0&size=20 |
Paginated message history, newest first |
GET |
/actuator/health |
Health check |
GET |
/actuator/metrics/chat.messages.published |
Kafka publish counter |
GET |
/actuator/metrics/chat.messages.broadcast |
STOMP broadcast counter |
GET |
/actuator/metrics/chat.messages.deduped |
Kafka duplicate delivery counter |
| Concern | Technology | Why |
|---|---|---|
| Language | Java 21 | Records, modern APIs |
| Framework | Spring Boot 3.5 | Web, JPA, WebSocket, Validation, Actuator |
| Real-time messaging | WebSocket + STOMP | Open persistent connection, pub/sub semantics |
| Cross-instance fan-out | Apache Kafka (KRaft) | Durable shared message bus, no ZooKeeper needed |
| Persistence | PostgreSQL + Spring Data JPA | Reliable, queryable message history |
| Schema management | Flyway | Versioned migrations, ddl-auto: validate |
| Authentication | JJWT 0.13 + ChannelInterceptor | JWT verified at CONNECT frame only |
| Metrics | Micrometer + Spring Boot Actuator | Custom counters, health endpoint |
| Tests | JUnit 5 + Mockito | Unit tests for service and listener logic |
| Infrastructure | Docker Compose | Postgres + Kafka, single command startup |
- Java 21+
- Docker Desktop
- Maven (the wrapper
./mvnwis included)
git clone https://github.com/Maheesh09/chatworkspace.git
cd chatworkspace/chatThis file is gitignored. Create it at src/main/resources/application-local.yml:
app:
jwt:
secret: your-secret-key-must-be-at-least-32-characters-longThe secret must be at least 32 characters — HS256 enforces a minimum key length.
docker compose up -d- PostgreSQL available at
localhost:5433 - Kafka available at
localhost:9092
Wait for Kafka to finish initializing:
docker compose logs -f kafka
# Look for: [BrokerServer id=1] Transition from STARTING to STARTED./mvnw clean package -DskipTestsOpen two terminal windows in the project root:
# Terminal 1
java -jar target/chat-0.0.1-SNAPSHOT.jar --server.port=8080
# Terminal 2
java -jar target/chat-0.0.1-SNAPSHOT.jar --server.port=8081Each instance prints its unique Kafka consumer group ID at startup. Confirm they differ:
# Instance A log:
group.id = chat-service-43647a0f-c969-466c-9990-a2bfe28e286f
# Instance B log:
group.id = chat-service-f4fe5659-60b9-48fc-93cc-9be67a472b34
Different UUIDs = independent Kafka consumers = fan-out is live.
Use a WebSocket client with raw frame support (Postman works with Binary → Hexadecimal mode).
- Tab A — connect to
ws://localhost:8080/ws - Tab B — connect to
ws://localhost:8081/ws - On both tabs: send CONNECT (with JWT in Authorization header), then SUBSCRIBE to
/topic/channels/1/messages - From Tab A only: send a SEND frame to
/app/chat.send - Watch Tab B's response pane — the same message arrives, ~100ms later, even though Tab B is connected to a completely separate JVM and never sent anything
That ~100ms is the Kafka round trip: publish → broker → consume → broadcast.
There is no registration endpoint. Seed directly into the database:
docker exec -it chat-postgres psql -U chat_user -d chatdbINSERT INTO workspaces (name, created_at) VALUES ('Test Workspace', now());
INSERT INTO users (username, display_name, created_at) VALUES ('alice', 'Alice', now());
INSERT INTO channels (name, workspace_id, created_at) VALUES ('general', 1, now());Use channelId: 1 and sub: "1" in your JWT and STOMP frames.
The project has no /auth/login endpoint. Use any JWT tool with these settings:
- Algorithm: HS256
- Secret: the value from your
application-local.yml - Claim:
sub="1"(the user ID as a string) - Expiry: 24 hours from now
Pass it in the STOMP CONNECT frame as: Authorization: Bearer <token>
AuthChannelInterceptor.preSend() fires on the STOMP CONNECT frame. It validates the JWT and attaches a StompPrincipal(userId) to the session. Spring carries this Principal for the life of the connection. Every subsequent SEND on that session is already authenticated — the client never sends the token again, and there is no way to claim a different identity mid-session.
ChatMessageRequest has no senderId field. The sender is resolved from the verified StompPrincipal — not from anything the client says in the message body. A client crafting a SEND frame to impersonate another user has no field to exploit.
Kafka adds ~50–150ms latency even on localhost. For chat messages this is fine. For typing indicators, that latency is noticeable and the UX breaks. Typing indicators broadcast directly via SimpMessagingTemplate, skipping Kafka entirely. If a client is on a different instance, they may miss the indicator — that is acceptable. Missing "Alice is typing…" for 100ms is a cosmetic glitch. Missing a chat message is data loss. Different problems, different tools.
Hibernate checks that every @Entity matches the real database tables at startup, but never creates, alters, or drops anything. V1__create_initial_schema.sql is the single source of truth for schema shape. If you change an entity without updating the migration, startup fails immediately and loudly rather than silently drifting.
Hibernate 6 maps java.time.Instant to TIMESTAMP WITH TIME ZONE. Using plain TIMESTAMP in the SQL migration would cause ddl-auto: validate to fail at startup with a column type mismatch. A version-specific detail that bites many people migrating from Hibernate 5.
Kafka's at-least-once guarantee means a message can be redelivered after a consumer crash. A bounded in-memory LRU set (last 1000 message IDs) in ChatMessageListener detects and skips duplicates. If the ID was already processed, the listener logs a warning and returns without broadcasting.
Known limitation: this set resets on restart and is per-instance only. In production, Redis with a short TTL would be the correct solution.
SimpUserRegistry tracks STOMP sessions on this JVM only. Instance A has no knowledge of who is connected to Instance B. Presence events fire correctly for clients on the same instance.
Known limitation: Redis-backed shared presence state would fix this. Deliberately out of scope — this project demonstrates Kafka fan-out, not full presence infrastructure.
ChatService.processIncomingMessage saves the message to Postgres before calling messagePublisher.publish(). If the save fails, no Kafka event is published and no client sees the message. This ordering is a correctness requirement, not a preference.
IDENTITY makes JDBC batch-inserts impossible since Hibernate needs each generated ID back immediately. SEQUENCE supports batching. IDENTITY was chosen here because messages are created one at a time as real events happen — bulk insert is never needed, so the downside of IDENTITY never materializes.
src/main/java/com/dca/chat/
├── config/ WebSocketConfig, KafkaConfig
├── domain/entity/ User, Workspace, Channel, Message
├── dto/ ChatMessageRequest, ChatMessageEvent,
│ MessageResponse, TypingIndicatorRequest,
│ TypingIndicatorEvent, PresenceEvent, ErrorResponse
├── exception/ ChannelNotFoundException, UserNotFoundException
├── messaging/
│ ├── listener/ ChatMessageListener, PresenceEventListener
│ └── publisher/ MessagePublisher (interface),
│ KafkaChatMessagePublisher
├── repository/ UserRepository, WorkspaceRepository,
│ ChannelRepository, MessageRepository
├── security/ AuthChannelInterceptor, JwtService, StompPrincipal
├── service/ ChatService, PresenceService
├── stomp/ ChatController
└── web/ ChannelHistoryController
src/main/resources/
├── application.yml main configuration
├── application-local.yml gitignored — JWT secret lives here
└── db/migration/
└── V1__create_initial_schema.sql Flyway migration
src/test/java/com/dca/chat/
├── ChatApplicationTests full Spring context load
├── service/
│ └── ChatServiceTest 4 unit tests
└── messaging/listener/
└── ChatMessageListenerTest 4 unit tests
No Testcontainers integration tests — the fan-out behavior was proven manually with two live JVM instances. Testcontainers would be the natural next step.
| Test | What it proves |
|---|---|
processIncomingMessage_whenValidRequest_savesAndPublishes |
Save and publish both called on the happy path |
processIncomingMessage_whenChannelNotFound_throwsChannelNotFoundException |
Exception thrown, save and publish never called |
processIncomingMessage_whenUserNotFound_throwsUserNotFoundException |
Exception thrown, save and publish never called |
processIncomingMessage_publishesAfterSave |
Published event carries the correct content |
| Test | What it proves |
|---|---|
consumeChatMessage_whenNewMessage_broadcastsToSubscribers |
New ID causes exactly one broadcast |
consumeChatMessage_whenDuplicateMessage_doesNotBroadcast |
Same ID twice causes only one broadcast |
consumeChatMessage_whenDifferentMessages_broadcastsBoth |
Two different IDs each broadcast once |
consumeChatMessage_whenMessageNotSeen_doesNotSkip |
Dedup is ID-specific, does not suppress other messages |
Run all tests:
./mvnw clean testExpected output: Tests run: 9, Failures: 0, Errors: 0, Skipped: 0
With the app running:
# Health
curl http://localhost:8080/actuator/health
# Message counters
curl http://localhost:8080/actuator/metrics/chat.messages.published
curl http://localhost:8080/actuator/metrics/chat.messages.broadcast
curl http://localhost:8080/actuator/metrics/chat.messages.dedupedLog correlation via MDC: every Kafka message processed includes messageId and channelId in the logging context automatically. All log lines during that message's processing carry these values.
Log format:
HH:mm:ss.SSS [thread] LEVEL logger [msgId=42 ch=1] - message text
| Excluded | Reason |
|---|---|
| User registration / login endpoint | Tokens generated manually for testing |
| Channel membership model | Any authenticated user can post to any channel |
| HTTP authentication on REST endpoints | STOMP is auth-gated; HTTP auth was out of scope |
| Redis | Needed for shared presence state and production-grade dedup; excluded to keep focus on Kafka fan-out |
| Testcontainers integration tests | Fan-out proven manually; Testcontainers is the natural next step |
| Real frontend | Postman is sufficient to demonstrate everything this project is actually about |
| Step | What was built |
|---|---|
| 1 | Single-instance STOMP chat skeleton |
| 2 | Domain model + PostgreSQL + Flyway migrations |
| 3 | Service layer, DTOs, Bean Validation, STOMP error handling |
| 4 | JWT authentication at STOMP handshake via ChannelInterceptor |
| 5 | Kafka as single-instance pass-through, round-trip proven |
| 6 | Multi-instance fan-out proven across two JVM processes |
| 7 | Paginated REST history endpoint (newest-first, indexed) |
| 8 | Typing indicators (best-effort) + presence tracking |
| 9 | Idempotent Kafka consumption + malformed message error handling |
| 10 | Unit tests — ChatService and ChatMessageListener |
| 11 | Observability: MDC correlation IDs, Micrometer counters, Actuator |
Step 6 is the milestone this project exists to demonstrate. Everything before it builds the foundation; everything after it makes it production-honest.