Skip to content

Mini Project 2 - Real-Time Chat Application

DodaTech Updated 2026-06-28 6 min read

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 /app to @MessageMapping methods in controllers
  • Handles user-specific messages via /user prefix

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 is the difference between WebSocket and STOMP?

WebSocket is a low-level protocol for full-duplex communication. STOMP is a higher-level messaging protocol that runs on top of WebSocket, providing destinations, subscriptions, and message framing similar to JMS.

How does the simple broker compare to a full message broker like RabbitMQ?

The simple broker is an in-memory message broker built into Spring. It works for single-instance applications. For clustered deployments, use a full broker (RabbitMQ, ActiveMQ) with the STOMP relay.

Can WebSockets work with load balancers?

Yes, but you need sticky sessions (session affinity) or a distributed message broker (RabbitMQ) so that clients connected to different server instances receive messages from all rooms.

How do I secure WebSocket connections?

Authenticate during the HTTP handshake (before WebSocket upgrade) using Spring Security. Verify tokens in the SessionConnectEvent. Validate user permissions when subscribing to destinations.

Should I persist chat messages?

Persist messages for history, search, and compliance. Use a database and cache recent messages in Redis. For ephemeral chat (e.g., video call chat), in-memory storage may suffice.

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