Backend for a Slack-style chat app built as microservices in Java 21 / Spring Boot 3, with workspaces, channels, real-time messaging, threads, presence, notifications and attachments.
Detailed documentation for every service (domain model, endpoints, events, configuration, patterns applied): docs/README.md. If you're integrating this from a frontend, start with docs/frontend-integration.md.
A single entry point, seven independent domain services behind it, each with its own store, decoupled from each other via events.
Same diagram, Mermaid version (in case the SVG doesn't load)
flowchart LR
client([Client / browser])
gw[api-gateway]
subgraph domain["Domain services"]
direction TB
auth[auth-service]
workspace[workspace-service]
channel[channel-service]
messaging[messaging-service]
notification[notification-service]
file[file-service]
presence[presence-service]
end
kafka[(Kafka)]
data[(Postgres · Redis · MinIO)]
client -->|REST + WebSocket| gw
gw --> domain
domain --> data
domain <-.events.-> kafka
Each service validates against the one before it in the chain before acting (channel → workspace → user), they discover each other via Eureka, and share configuration from config-server — the full detail of those connections is in the expanded diagram below.
flowchart TB
client([Client / browser])
subgraph infra["Shared infrastructure"]
eureka[discovery-server\n:8761]
config[config-server\n:8888]
kafka[(Kafka)]
end
gw[api-gateway\n:8080]
auth[auth-service\n:8081]
workspace[workspace-service\n:8084]
channel[channel-service\n:8082]
messaging[messaging-service\n:8083]
notification[notification-service\n:8085]
file[file-service\n:8086]
presence[presence-service\n:8087]
authdb[(auth_db)]
workspacedb[(workspace_db)]
channeldb[(channel_db)]
messagingdb[(messaging_db)]
notificationdb[(notification_db)]
filedb[(file_db)]
redis[(Redis)]
minio[(MinIO / S3)]
client -->|REST + WebSocket| gw
gw --> auth & workspace & channel & messaging & notification & file & presence
workspace -->|validates owner| auth
channel -->|validates workspace| workspace
messaging -->|validates membership| channel
file -->|validates membership| channel
auth --- authdb
workspace --- workspacedb
channel --- channeldb
messaging --- messagingdb
notification --- notificationdb
file --- filedb
file --- minio
presence --- redis
auth -.publishes events.-> kafka
workspace -.publishes events.-> kafka
channel -.publishes events.-> kafka
messaging -.publishes events.-> kafka
file -.publishes events.-> kafka
kafka -.consumes.-> notification
auth -.registers with.-> eureka
workspace -.registers with.-> eureka
channel -.registers with.-> eureka
messaging -.registers with.-> eureka
notification -.registers with.-> eureka
file -.registers with.-> eureka
presence -.registers with.-> eureka
gw -.registers with.-> eureka
auth -.config.-> config
workspace -.config.-> config
channel -.config.-> config
messaging -.config.-> config
notification -.config.-> config
file -.config.-> config
presence -.config.-> config
gw -.config.-> config
Every domain service has its own database (database-per-service) — no cross-service joins, no shared data. Synchronous inter-service communication (REST, via RestClient with @LoadBalanced + Eureka) is only used when something needs to be validated on the other side before acting (e.g. channel-service checks the workspace exists before creating a channel); everything else is decoupled through Kafka events.
| Service | Port | Role | DB / Store |
|---|---|---|---|
discovery-server |
8761 | Service registry (Eureka) | — |
config-server |
8888 | Centralized configuration | — |
api-gateway |
8080 | Single entry point, JWT, rate limiting | — |
auth-service |
8081 | Registration, login, JWT (RS256) | auth_db |
workspace-service |
8084 | Workspaces and memberships | workspace_db |
channel-service |
8082 | Channels and memberships | channel_db |
messaging-service |
8083 | Messages, threads, reactions, WebSocket | messaging_db |
notification-service |
8085 | Notifications (consumes Kafka events) | notification_db |
file-service |
8086 | Attachments (S3/MinIO) | file_db |
presence-service |
8087 | Online/offline, "typing..." | Redis |
Shared infrastructure: Postgres (one instance per service), Kafka (KRaft single-node), Redis, MinIO.
Everything comes in through api-gateway (http://localhost:8080) — a domain service is never called directly from outside. Real order for a typical client, from zero to sending a message:
sequenceDiagram
participant C as Client
participant GW as api-gateway
participant A as auth-service
participant W as workspace-service
participant CH as channel-service
participant M as messaging-service
participant K as Kafka
participant N as notification-service
C->>GW: POST /api/v1/auth/register
GW->>A: (public route)
A-->>C: 201 user created
A-)K: UserRegisteredEvent
K-)N: consumes → WELCOME notification
C->>GW: POST /api/v1/auth/login
GW->>A: (public route)
A-->>C: 200 {accessToken, refreshToken}
C->>GW: POST /api/v1/workspaces (Bearer)
GW->>GW: validates JWT, sets X-User-Id
GW->>W: creates workspace + OWNER member
W-->>C: 201 workspace
C->>GW: POST /api/v1/channels (Bearer)
GW->>CH: creates channel
CH->>W: validates the workspace exists
CH-->>C: 201 channel
CH-)K: ChannelMemberAddedEvent
K-)N: consumes → CHANNEL_INVITE notification
C->>GW: WS CONNECT /ws (Authorization: Bearer in the STOMP frame)
GW->>M: upgrade to WebSocket
M-->>C: CONNECTED
C->>M: SUBSCRIBE /topic/channels/{id}
M->>CH: validates membership before accepting the subscription
C->>GW: POST /api/v1/channels/{id}/messages (Bearer)
GW->>M: saves the message
M->>CH: validates membership
M-->>C: 201 message
M--)C: broadcast over WS to /topic/channels/{id}
M-)K: MessageSentEvent
C->>GW: GET /api/v1/notifications (Bearer)
GW->>N: reads the inbox
N-->>C: 200 notifications (WELCOME, CHANNEL_INVITE, ...)
Key points about that order:
- The JWT from login is the only one used from then on — as an
Authorization: Bearer <token>header on every REST request, and as a native header of the same name on the first STOMP frame (CONNECT) for WebSocket.api-gatewayvalidates it once and forwardsX-User-Idto the domain services (seeapi-gateway.md). channel-servicewon't let you create a channel in a workspace that doesn't exist — it validates againstworkspace-servicebefore accepting. Same fail-closed pattern inmessaging-service/file-serviceagainstchannel-servicefor membership.- A message arrives twice, through two different paths: the REST response of the
POSTis the confirmation to whoever sent it; the WebSocket broadcast to/topic/channels/{id}is what other already-subscribed members receive in real time — subscribing before sending is what makes that second path meaningful. - Notifications aren't explicitly requested in the moment — they're generated on their own via Kafka (
auth.events,channel.events,workspace.events→notification-service) and sit waiting in the inbox (GET /api/v1/notifications) until the client checks it. file-service(attachment upload/download) andpresence-service(online/offline, "typing...", its own WebSocket at/presence-ws) follow the same auth pattern but aren't in the diagram above for clarity — seefile-service.mdandpresence-service.md.
To try each step with curl without writing a client, see the example in "Bringing everything up" below, or use the combined Swagger UI (http://localhost:8080/swagger-ui.html) to try each REST endpoint with "Try it out".
- Language/runtime: Java 21, Spring Boot 3.3.4, Spring Cloud 2023.0.3
- Build: Gradle (Kotlin DSL) + per-service wrapper — every service builds independently, no root aggregator
- Persistence: PostgreSQL + Spring Data JPA + Flyway (versioned migrations, one per service)
- Async messaging: Apache Kafka
- Real-time: WebSocket + STOMP (messaging-service, presence-service)
- Cache/ephemeral state: Redis (presence-service, and the rate limiter's buckets in api-gateway)
- Object storage: MinIO (S3-compatible) via AWS SDK v2 (file-service)
- Authentication: JWT RS256 (auth-service issues, api-gateway validates against auth-service's JWKS)
- Resilience: Resilience4j (circuit breaker, retry, rate limiter)
- Gateway: Spring Cloud Gateway (WebFlux)
- API documentation: OpenAPI/Swagger (
springdoc-openapi), aggregated into a single Swagger UI via api-gateway - Testing: JUnit 5, Mockito, Testcontainers (Postgres, Kafka, MinIO)
- Containers: Docker multi-stage builds + Docker Compose
Requires Docker with the daemon running.
docker compose up -d --buildThis brings up 19 containers (6 Postgres + Kafka + Redis + MinIO + discovery-server + config-server + 8 application services), respecting dependency order via healthchecks. Takes a few minutes the first time (each service's Gradle build).
Check that everything is healthy:
docker compose psEverything comes in through the gateway at http://localhost:8080. Full flow example (same order as the "API usage flow" diagram):
# 1. Register
curl -X POST http://localhost:8080/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","username":"alice","password":"s3cur3-password","displayName":"Alice"}'
# 2. Login — save the accessToken from the response
curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"s3cur3-password"}'
TOKEN="<accessToken from the response above>"
# 3. Create a workspace — save the id from the response
curl -X POST http://localhost:8080/api/v1/workspaces \
-H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-d '{"name":"Acme Corp"}'
WORKSPACE_ID="<id from the response above>"
# 4. Create a channel inside that workspace — save the id from the response
curl -X POST http://localhost:8080/api/v1/channels \
-H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-d "{\"workspaceId\":\"$WORKSPACE_ID\",\"name\":\"general\",\"type\":\"PUBLIC\"}"
CHANNEL_ID="<id from the response above>"
# 5. Send a message to the channel
curl -X POST "http://localhost:8080/api/v1/channels/$CHANNEL_ID/messages" \
-H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-d '{"content":"Hello team!"}'
# 6. Check notifications generated on their own via Kafka (WELCOME, CHANNEL_INVITE, ...)
curl http://localhost:8080/api/v1/notifications -H "Authorization: Bearer $TOKEN"Step 5 only confirms the REST send — to see the message arrive in real time you need a WebSocket/STOMP client connected to /ws and subscribed to /topic/channels/{id} before sending it (curl doesn't speak WebSocket; see messaging-service.md for the full contract).
Bring everything down (keeps the data volumes):
docker compose downAdd -v to also delete the volumes.
- Eureka dashboard: http://localhost:8761
- MinIO console: http://localhost:9001 (
minioadmin/minioadmin) - Combined Swagger UI (all 7 domain services, real spec generated by
springdoc-openapifrom the controllers): http://localhost:8080/swagger-ui.html
Every service has its own Gradle wrapper:
cd auth-service
./gradlew bootRunServices assume their dependencies (Postgres/Kafka/Redis/discovery-server/etc.) are available on localhost at the default ports — the simplest approach is to bring up only the infrastructure with Docker Compose and run the service(s) you're working on from your IDE:
docker compose up -d auth-db kafka discovery-server config-server
cd auth-service && ./gradlew bootRuncd <service>
./gradlew testEvery service has unit tests (no Spring, fast) and integration tests with Testcontainers (real Postgres/Kafka/MinIO/Redis, no mocks) — the latter need Docker available locally.
Non-trivial decisions (why a local transaction instead of a Saga, why the rate limiter in api-gateway moved from in-memory Resilience4j to Redis, etc.) are documented as comments in the code, right where the decision is made — and repeated in docs/README.md next to each service. No known functional gaps left open at the moment — the last one (file.events had no consumer) was closed by wiring notification-service to fan a FILE_UPLOADED notification out to every channel member except the uploader, resolving membership via a live call to channel-service.