Pulse
Slack-style workspaces, channels and live messages, with one backend and three clients: Angular, Next.js and Ionic.
In 30 seconds
- Category
- Full stack · Real-time communication
- Status
- Backend and Angular web complete · Next.js and Ionic partial · no public demo
- Stack
- Spring Boot 3 · WebSocket/STOMP · PostgreSQL · Redis · S3 (RustFS)
- Clients
- Angular 22 (complete) · Next.js 16 + React 19 · Ionic 9 + Capacitor
- Tests
- 117 backend tests with real PostgreSQL, Redis and RustFS · 8 in Angular · 10 in Next.js
- Delivery
- Docker Compose with 5 services and healthchecks · CI with 5 jobs
- Live demo
- No public demo
Overview
- What it is
- A real-time communication platform: communities (workspaces) with public and private channels, live messages, direct messages, notifications, files and search.
- The problem it solves
- A team chat has to be instant without being unreliable: no message may get lost, appear twice or reach someone without access to the channel.
- Who it is for
- Teams and communities organised into workspaces and channels, with moderation roles.
- Goal
- Show that one REST API and one WebSocket can serve three different clients under the same security rules.
Preview


Features
Real-time messaging
Messages, edits, deletions, reactions, the "is typing" indicator and online/offline presence reach channel subscribers instantly.
Why it matters: The conversation feels live without reloading anything.

Workspaces with roles and moderation
Five roles (OWNER, ADMIN, MODERATOR, MEMBER, GUEST). You can only kick, ban or change the role of someone below you, and announcement channels only accept messages from moderators.
Why it matters: Each community governs itself and nobody can escalate their permissions.
Pending assetPending screenshot: members and audit log in Angular
Search and command palette
Partial-text search with combinable filters (workspace, channel, author, dates, mentions) in Angular. In Next.js, a Ctrl+K palette to jump between channels and workspaces.
Why it matters: Find a message or a channel without scrolling through lists.

Three clients on the same API
Angular is the complete client. Next.js covers sign-in, sign-up, workspaces and real-time chat. The Ionic app covers daily use: workspaces, channels, chat with reactions, direct messages and notifications.
Why it matters: It shows that the API does not depend on any client.

Files validated by their content
Images, PDF, txt, docx and xlsx up to 10 MB. The type is checked by the first bytes, the storage key is generated by the server and every download checks channel access.
Why it matters: Renaming an .exe to .png does not work, and knowing a file id does not grant access to it.
Gallery
Pending assetPending video: two users chatting in real time
Stack
Backend
- Java 21 + Spring Boot 3.3
- REST API and WebSocket in one application
- Spring WebSocket + STOMP
- Per-channel topics and individually authorised subscriptions
- Spring Security + jjwt
- 15-minute access token and revocable refresh token
- MapStruct + Lombok
- Entity → DTO mapping without boilerplate
- springdoc-openapi
- Swagger UI
Data
- PostgreSQL + Flyway
- 13 migrations; search with a trigram index (
pg_trgm) - Redis
- Presence and rate limiting
- RustFS (API S3) + cliente MinIO
- File storage
Clients
- Angular 22 + signals + RxJS
- Complete web app
- Next.js 16 + React 19
- Second web app, App Router and CSS Modules
- Ionic 9 + Capacitor 8
- Mobile app (Android)
- @stomp/stompjs
- Real time in all three clients
Testing
- JUnit 5 + MockMvc
- API behaviour tests
- Testcontainers
- Real PostgreSQL, Redis and RustFS
- Vitest
- Interceptor, API client and chat logic
Infrastructure
- Docker Compose
- 5 services with healthchecks
- nginx
- Static files and proxy for /api and /ws
- GitHub Actions
- Backend, Angular, Next.js, mobile and images
Architecture
A modular Spring Boot monolith with eleven domain modules, three clients and three data stores, each used for what it does best.
- Modules (
identity,workspaces,channels,messages,realtime…) use each other only through their services, never through their repositories. - Side effects are domain events:
messagespublishesUserMentionedEventand notifications, audit and real time listen. - PostgreSQL stores what is permanent; Redis what is ephemeral (presence, counters); RustFS the files.
- Angular goes through nginx; Next.js forwards
/apiwith rewrites and opens the WebSocket directly to the backend; Ionic talks to the API directly.
Engineering decisions
Each decision with the problem, the solution, the reason and the exact place in the code where it lives.
Write through REST, broadcast after commit
- Problem
- If messages are saved through the WebSocket, there are two paths for validation and authorisation. If they are broadcast before the transaction commits, a message that is later rolled back may be announced.
- Solution
- Messages are saved through REST, and a listener with
@TransactionalEventListener(AFTER_COMMIT)broadcasts them over STOMP. The client drops duplicates by id. - Why
- A single write path, and the WebSocket is never the source of truth: if it drops, nothing is lost.
- Code
- MessageEventListener.java:26 (opens in a new tab)backend/src/main/java/com/pulse/realtime/listener/MessageEventListener.java
- Test
“messageSentOverRest_arrivesInRealTimeToChannelSubscribers”
WebSocketIT.java:74 (opens in a new tab)
Every subscription is authorised
- Problem
- Validating the token on connect is not enough: an authenticated user could subscribe to someone else’s private channel topic just by knowing its id.
- Solution
- A
ChannelInterceptorvalidates the JWT onCONNECTand checks access on everySUBSCRIBE; any other destination is rejected. - Why
- The same access rules as REST apply to real time.
- Code
- WebSocketAuthInterceptor.java:48 (opens in a new tab)backend/src/main/java/com/pulse/realtime/config/WebSocketAuthInterceptor.java
- Test
“userOutsideTheWorkspace_doesNotReceiveChannelMessages”
WebSocketIT.java:89 (opens in a new tab)
Presence in Redis with expiry
- Problem
- If the server crashes without closing sessions, users stay "online" forever. And one person can be connected from the web and a phone at the same time.
- Solution
- One Redis sorted set per user, with one entry per session and its expiry as score, renewed every 30 s. The user is online while any session is still alive.
- Why
- Sessions from a crashed server expire on their own, and it works with several backend instances.
- Code
- PresenceService.java:73 (opens in a new tab)backend/src/main/java/com/pulse/realtime/service/PresenceService.java
- Test
“userWithTwoSessions_staysOnlineUntilTheLastOneCloses”
WebSocketIT.java:146 (opens in a new tab)
File type from its first bytes
- Problem
- The MIME type and extension are chosen by the client, so they cannot be trusted.
- Solution
- The content signatures (PNG, JPEG, PDF, Office ZIP…) are read and the file is rejected if they do not match what was declared. Uploads go through the backend instead of presigned URLs.
- Why
- It allows validating the real content and checking permissions on every download, at the cost of files going through the server.
- Code
- FileTypeValidator.java:22 (opens in a new tab)backend/src/main/java/com/pulse/files/service/FileTypeValidator.java
- Test
“fileWhoseContentDoesNotMatchItsDeclaredType_isRejected”
FileControllerIT.java:99 (opens in a new tab)
A modular monolith, not microservices
- Problem
- Splitting into microservices adds network calls between services, eventual consistency and multiple deployments.
- Solution
- One application organised by domain, with dependency rules between modules and events for side effects.
- Why
- At this size, microservices would only add cost. The module boundaries make it possible to extract one later if needed.
What is missing
- Next.js has no direct messages, search or administration; Ionic has no search, editing, attachments or moderation.
- With several backend instances, the in-memory STOMP broker should move to an external one (RabbitMQ or a relay).
- Files uploaded but never sent are not cleaned up automatically, and avatar upload is missing.
- End-to-end tests for the clients are missing.