Mini Project 2 - Real-Time Chat Application
In this tutorial, you will learn about Mini Project 2. We cover key concepts, practical examples, and best practices to help you master this topic.
Project Overview
This mini project builds a real-time chat application using Spring Boot and WebSockets. Unlike REST APIs where the client polls the server for updates, WebSockets provide a persistent, full-duplex communication channel between client and server. The server can push messages to clients as events occur, making it ideal for chat, notifications, live updates, and collaborative applications.
You will use the STOMP protocol (Simple Text Oriented Messaging Protocol) over WebSockets, which provides a pub-sub messaging model with destinations, topics, and queues. Spring's WebSocket support handles the low-level transport, while STOMP provides the application-level messaging semantics.
flowchart TB
Browser1[Browser - User A] -->|WebSocket| Server[Spring Boot Server]
Browser2[Browser - User B] -->|WebSocket| Server
Browser3[Browser - User C] -->|WebSocket| Server
subgraph Server
WS[WebSocket Handshake]
Broker[Message Broker
SimpleBroker]
Controller[STOMP Controller]
Service[Chat Service]
Repository[Chat Repository]
end
Broker -->|Topic /topic/room.1| Browser1
Broker -->|Topic /topic/room.1| Browser2
Broker -->|Queue /queue/messages.user2| Browser2
Broker -->|User Destination| Browser3
Controller --> Service
Service --> Repository
Repository --> DB[(PostgreSQL/Redis)]
Project Setup
Dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
WebSocket Configuration
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic", "/queue", "/user");
config.setApplicationDestinationPrefixes("/app");
config.setUserDestinationPrefix("/user");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws-chat")
.setAllowedOriginPatterns("*")
.withSockJS();
}
}
This configuration:
- Enables a simple in-memory message broker for topics (broadcast) and queues (point-to-point)
- Routes messages prefixed with
/appto@MessageMappingmethods in controllers - Handles user-specific messages via
/userprefix
Chat Domain Model
@Entity
@Table(name = "chat_rooms")
public class ChatRoom {
@Id
private String id;
@Column(nullable = false)
private String name;
private String description;
@Enumerated(EnumType.STRING)
private RoomType type;
@Column(name = "created_at")
private Instant createdAt;
@PrePersist
void onCreate() {
createdAt = Instant.now();
if (id == null) id = UUID.randomUUID().toString();
}
}
enum RoomType {
PUBLIC, PRIVATE, DIRECT
}
@Entity
@Table(name = "chat_messages")
public class ChatMessage {
@Id
private String id;
@Column(nullable = false)
private String roomId;
@Column(nullable = false)
private String senderId;
@Column(nullable = false)
private String senderName;
@Column(nullable = false, length = 5000)
private String content;
@Enumerated(EnumType.STRING)
private MessageType type;
@Column(name = "created_at")
private Instant createdAt;
@PrePersist
void onCreate() {
createdAt = Instant.now();
if (id == null) id = UUID.randomUUID().toString();
}
}
enum MessageType {
CHAT, JOIN, LEAVE, TYPING, SYSTEM
}
STOMP Controller
@Controller
public class ChatController {
private final ChatService chatService;
private final SimpMessageSendingOperations messagingTemplate;
public ChatController(ChatService chatService,
SimpMessageSendingOperations messagingTemplate) {
this.chatService = chatService;
this.messagingTemplate = messagingTemplate;
}
@MessageMapping("/chat.send")
@SendTo("/topic/room/{roomId}")
public ChatMessage sendMessage(@Payload ChatMessage message,
SimpMessageHeaderAccessor headerAccessor) {
message.setSenderId(getUserId(headerAccessor));
message.setType(MessageType.CHAT);
return chatService.saveMessage(message);
}
@MessageMapping("/chat.join")
@SendTo("/topic/room/{roomId}")
public ChatMessage joinRoom(@Payload Map<String, String> payload,
SimpMessageHeaderAccessor headerAccessor) {
String roomId = payload.get("roomId");
String userId = getUserId(headerAccessor);
String userName = getUserName(headerAccessor);
// Track user session
headerAccessor.getSessionAttributes().put("roomId", roomId);
headerAccessor.getSessionAttributes().put("userId", userId);
ChatMessage joinMessage = new ChatMessage();
joinMessage.setRoomId(roomId);
joinMessage.setSenderId(userId);
joinMessage.setSenderName(userName);
joinMessage.setContent(userName + " joined the room");
joinMessage.setType(MessageType.JOIN);
return chatService.saveMessage(joinMessage);
}
@MessageMapping("/chat.typing")
public void typing(@Payload TypingIndicator indicator,
SimpMessageHeaderAccessor headerAccessor) {
indicator.setUserId(getUserId(headerAccessor));
indicator.setUserName(getUserName(headerAccessor));
messagingTemplate.convertAndSend(
"/topic/room/" + indicator.getRoomId() + "/typing",
indicator);
}
@MessageMapping("/chat.private")
public void sendPrivateMessage(@Payload PrivateMessage message,
SimpMessageHeaderAccessor headerAccessor) {
message.setSenderId(getUserId(headerAccessor));
message.setSenderName(getUserName(headerAccessor));
ChatMessage saved = chatService.saveMessage(message.toChatMessage());
// Send to recipient's user queue
messagingTemplate.convertAndSendToUser(
message.getRecipientId(),
"/queue/messages",
saved);
// Also send back to sender for confirmation
messagingTemplate.convertAndSendToUser(
message.getSenderId(),
"/queue/messages",
saved);
}
}
Session Management and Connection Events
@Component
public class WebSocketEventListener {
private final SimpMessageSendingOperations messagingTemplate;
private final ChatService chatService;
public WebSocketEventListener(SimpMessageSendingOperations messagingTemplate,
ChatService chatService) {
this.messagingTemplate = messagingTemplate;
this.chatService = chatService;
}
@EventListener
public void handleSessionConnected(SessionConnectEvent event) {
StompHeaderAccessor headers = StompHeaderAccessor.wrap(event.getMessage());
String userId = headers.getNativeHeader("userId").get(0);
System.out.println("User connected: " + userId);
}
@EventListener
public void handleSessionDisconnected(SessionDisconnectEvent event) {
StompHeaderAccessor headers = StompHeaderAccessor.wrap(event.getMessage());
String roomId = (String) headers.getSessionAttributes().get("roomId");
String userId = (String) headers.getSessionAttributes().get("userId");
String userName = (String) headers.getSessionAttributes().get("userName");
if (roomId != null && userId != null) {
ChatMessage leaveMessage = new ChatMessage();
leaveMessage.setRoomId(roomId);
leaveMessage.setSenderId(userId);
leaveMessage.setSenderName(userName);
leaveMessage.setContent(userName + " left the room");
leaveMessage.setType(MessageType.LEAVE);
messagingTemplate.convertAndSend(
"/topic/room/" + roomId, leaveMessage);
}
}
}
REST Controllers (for non-realtime operations)
@RestController
@RequestMapping("/api/rooms")
public class RoomController {
private final ChatRoomRepository chatRoomRepository;
private final ChatMessageRepository messageRepository;
public RoomController(ChatRoomRepository chatRoomRepository,
ChatMessageRepository messageRepository) {
this.chatRoomRepository = chatRoomRepository;
this.messageRepository = messageRepository;
}
@PostMapping
public ResponseEntity<ChatRoom> createRoom(@Valid @RequestBody CreateRoomRequest request) {
ChatRoom room = new ChatRoom();
room.setName(request.name());
room.setDescription(request.description());
room.setType(request.type());
return ResponseEntity.status(HttpStatus.CREATED)
.body(chatRoomRepository.save(room));
}
@GetMapping
public List<ChatRoom> listRooms(@RequestParam(defaultValue = "PUBLIC") RoomType type) {
return chatRoomRepository.findByTypeOrderByCreatedAtDesc(type);
}
@GetMapping("/{roomId}/messages")
public Page<ChatMessage> getMessages(
@PathVariable String roomId,
@RequestParam(defaultValue = "0") int page) {
return messageRepository.findByRoomIdOrderByCreatedAtDesc(
roomId, PageRequest.of(page, 50));
}
}
Client-Side (JavaScript)
// Connect to WebSocket
const socket = new SockJS('/ws-chat');
const stompClient = Stomp.over(socket);
stompClient.connect({
userId: currentUser.id,
userName: currentUser.name
}, function (frame) {
console.log('Connected: ' + frame);
// Subscribe to room topic
stompClient.subscribe('/topic/room/' + roomId, function (message) {
const chatMessage = JSON.parse(message.body);
displayMessage(chatMessage);
});
// Subscribe to typing indicators
stompClient.subscribe('/topic/room/' + roomId + '/typing', function (message) {
const indicator = JSON.parse(message.body);
showTypingIndicator(indicator);
});
// Subscribe to private messages
stompClient.subscribe('/user/queue/messages', function (message) {
const privateMessage = JSON.parse(message.body);
displayPrivateMessage(privateMessage);
});
// Announce join
stompClient.send("/app/chat.join", {}, JSON.stringify({
roomId: roomId
}));
});
// Send a message
function sendMessage() {
stompClient.send("/app/chat.send", {}, JSON.stringify({
roomId: roomId,
content: messageInput.value,
senderName: currentUser.name
}));
messageInput.value = '';
}
// Send typing indicator (throttled)
let typingTimeout;
messageInput.addEventListener('input', function() {
stompClient.send("/app/chat.typing", {}, JSON.stringify({
roomId: roomId,
isTyping: true
}));
clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => {
stompClient.send("/app/chat.typing", {}, JSON.stringify({
roomId: roomId,
isTyping: false
}));
}, 2000);
});
Testing WebSocket Controllers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ChatControllerTest {
@Autowired
private TestRestTemplate restTemplate;
private StompSession session;
@BeforeEach
void setup() throws Exception {
WebSocketStompClient stompClient = new WebSocketStompClient(
new StandardWebSocketClient());
session = stompClient
.connect("ws://localhost:" + port + "/ws-chat",
new StompSessionHandlerAdapter() {})
.get(5, TimeUnit.SECONDS);
}
@Test
@DisplayName("Should receive broadcast message after sending")
void testSendAndReceiveMessage() throws Exception {
BlockingQueue<ChatMessage> messages = new LinkedBlockingQueue<>();
session.subscribe("/topic/room/room1", new StompFrameHandler() {
@Override
public Type getPayloadType(StompHeaders headers) {
return ChatMessage.class;
}
@Override
public void handleFrame(StompHeaders headers, Object payload) {
messages.add((ChatMessage) payload);
}
});
session.send("/app/chat.send", new ChatMessage(
"room1", "user1", "Test User", "Hello!", MessageType.CHAT, null));
ChatMessage received = messages.poll(5, TimeUnit.SECONDS);
assertThat(received.getContent()).isEqualTo("Hello!");
assertThat(received.getType()).isEqualTo(MessageType.CHAT);
}
}
What You Have Built
- Real-time WebSocket communication with STOMP protocol
- Chat rooms with pub-sub messaging (broadcast to room)
- Private messaging via user destinations
- Typing indicators with throttling
- Connection lifecycle management (join/leave events)
- Message persistence with JPA
- REST endpoints for room management and message history
- WebSocket Security integration
- Test coverage for WebSocket controllers
Extension Ideas
- File and image sharing via WebSockets
- Message reactions and read receipts
- Rate Limiting for anti-spam
- Message editing and deletion
- Online presence indicators
- Voice and video calls via WebRTC
- Chat bot integration
FAQ
What's Next
You have built two substantial projects. In the next lesson, we will explore Design Patterns in Java, covering classical GoF patterns and modern Java idioms that will make you a more effective software architect.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro