Skip to content
PG
All projects

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

pulse · preview
Demo data. A channel chat in the Next.js web app: channel list on the left, messages with reactions and replies, and the message input.
Demo data. The Next.js web app at 390 px wide, with the chat in full screen.

Features

Implemented

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.

Demo data. The same chat in dark mode, with reactions and a replied message.
Implemented

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

Implemented

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.

Demo data. Ctrl+K palette open over the chat, with channels and workspaces filtered by the typed text.
Partial

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.

Demo data. The Ionic mobile app showing a channel with messages.
Implemented

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.

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.

Pulse architectureThree clients (Angular, Next.js and Ionic) use the same REST API and STOMP WebSocket from Spring Boot. Angular goes through nginx; Next.js forwards /api with rewrites. The application stores data in PostgreSQL, presence and counters in Redis and files in S3-compatible RustFS.ClientsAngular 22complete clientNext.js 16React 19Ionic 9CapacitorHTTPS · WSEdgenginx · rewritesIonic calls directlyREST · STOMPSpring Boot 3REST + STOMP11 modules · eventsidentitymessagesrealtimefilesJPA · Redis · S3DataPostgreSQLFlyway · pg_trgmRedispresence · rate limitRustFSfiles (S3)
  • Modules (identity, workspaces, channels, messages, realtime…) use each other only through their services, never through their repositories.
  • Side effects are domain events: messages publishes UserMentionedEvent and 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 /api with 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
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 ChannelInterceptor validates the JWT on CONNECT and checks access on every SUBSCRIBE; any other destination is rejected.
Why
The same access rules as REST apply to real time.
Code
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
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
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.