Skip to content

Repository files navigation

relay-backend

Versión en español

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.

Architecture

relay-backend architecture diagram

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
Loading

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.

Detailed architecture

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
Loading

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.

Services

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.

API usage flow

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, ...)
Loading

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-gateway validates it once and forwards X-User-Id to the domain services (see api-gateway.md).
  • channel-service won't let you create a channel in a workspace that doesn't exist — it validates against workspace-service before accepting. Same fail-closed pattern in messaging-service/file-service against channel-service for membership.
  • A message arrives twice, through two different paths: the REST response of the POST is 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.eventsnotification-service) and sit waiting in the inbox (GET /api/v1/notifications) until the client checks it.
  • file-service (attachment upload/download) and presence-service (online/offline, "typing...", its own WebSocket at /presence-ws) follow the same auth pattern but aren't in the diagram above for clarity — see file-service.md and presence-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".

Stack

  • 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

Bringing everything up

Requires Docker with the daemon running.

docker compose up -d --build

This 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 ps

Everything 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 down

Add -v to also delete the volumes.

Running a single service (without Docker)

Every service has its own Gradle wrapper:

cd auth-service
./gradlew bootRun

Services 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 bootRun

Tests

cd <service>
./gradlew test

Every 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.

Design decisions and documented technical debt

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.

About

Microservices backend for Relay, a real-time Slack-style chat app. Built with Java, Spring Boot, WebSocket (STOMP), Kafka/RabbitMQ and Redis.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages