DEV Community

Cover image for Spring Boot with WebSocket
Matheus Bernardes Spilari
Matheus Bernardes Spilari

Posted on

Spring Boot with WebSocket

In this post, I’ll walk you through the backend of a real-time album-sharing application that I built using Spring Boot, WebSocket, and LocalStack.

This was a great opportunity to build a hands-on POC (proof of concept) that mimics how modern real-time apps interact, while keeping things affordable and dev-friendly.

Tech Stack Used

  • Java + Spring Boot: Backend framework to handle REST and WebSocket communication
    • Spring Web(spring-boot-starter-web).
  • Spring WebSocket (STOMP): For real-time communication with the frontend
    • WebSocket(spring-boot-starter-websocket).
  • S3 (via LocalStack): To store and retrieve uploaded images
    • Spring Cloud AWS S3(spring-cloud-aws-starter-s3)
  • DynamoDB (via LocalStack): To persist album metadata
    • Spring Cloud DynamoDB(spring-cloud-aws-starter-dynamodb)
  • LocalStack: A local AWS cloud emulator (explained in my other post)

To use Spring Cloud AWS follow these docs here


🎯 The Goal

Users can:

  1. Upload images and create albums
  2. Receive real-time updates when new albums are created
  3. View all existing albums — including their images

The key part: everything happens in real time, powered by WebSocket using the STOMP protocol.


Configs

AWS Config file

First, let's write a AWS config file:

@Configuration
public class AwsServicesConfigs {

    private static final Logger logger = LoggerFactory.getLogger(AwsServicesConfigs.class);

    @Value("${spring.cloud.aws.credentials.access-key}")
    private String accessKey;

    @Value("${spring.cloud.aws.credentials.secret-key}")
    private String secretKey;

    @Value("${spring.cloud.aws.region.static}")
    private String region;

    @Value("${spring.cloud.aws.s3.endpoint}")
    private String cloudEndpoint;

    @Value("${spring.cloud.aws.s3.bucket-name}")
    private String bucketName;

    @Value("${spring.cloud.aws.dynamodb.table-name}")
    private String tableName;

    @Bean
    public S3Client s3Client() {

        return S3Client.builder()
                .endpointOverride(URI.create(cloudEndpoint))
                .region(Region.of(region))
                .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)))
                .serviceConfiguration(S3Configuration.builder()
                        .pathStyleAccessEnabled(true) // Força compatibilidade de caminho
                        .build())
                .build();
    }

    @Bean
    public DynamoDbClient dynamoDbClient() {
        return DynamoDbClient.builder()
                .endpointOverride(URI.create(cloudEndpoint))
                .region(Region.of(region))
                .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)))
                .build();
    }

    @Bean
    public DynamoDbEnhancedClient dynamoDbEnhancedClient(DynamoDbClient dynamoDbClient) {
        return DynamoDbEnhancedClient.builder()
                .dynamoDbClient(dynamoDbClient)
                .build();
    }

    @Bean
    public CommandLineRunner initializeAwsServices(S3Client s3Client, DynamoDbClient dynamoDbClient) {
        return args -> {
            createTableIfNotExists(dynamoDbClient);
            createBucketIfNotExists(s3Client);
        };
    }

    private void createBucketIfNotExists(S3Client s3Client) {
        logger.info("Trying to create a bucket on S3...");
        if (!doesBucketExist(s3Client)) {
            logger.info("Creating bucket with name: " + bucketName);
            s3Client.createBucket(CreateBucketRequest.builder().bucket(bucketName).build());
        } else {
            logger.info("Bucket " + bucketName + " already exists !");
        }
    }

    private boolean doesBucketExist(S3Client s3Client) {
        ListBucketsResponse listOfBuckets = s3Client.listBuckets();
        return listOfBuckets.buckets().stream().anyMatch(bucket -> bucket.name().equals(bucketName));
    }

    private void createTableIfNotExists(DynamoDbClient dynamoDbClient) {
        logger.info("Trying to create a table in DynamoDb...");
        if (!doesTableExist(dynamoDbClient)) {

            CreateTableRequest createTableRequest = CreateTableRequest.builder()
                    .tableName(tableName)
                    .keySchema(KeySchemaElement.builder()
                            .attributeName("album_id")
                            .keyType(KeyType.HASH)
                            .build())
                    .attributeDefinitions(AttributeDefinition.builder()
                            .attributeName("album_id")
                            .attributeType(ScalarAttributeType.S)
                            .build())
                    .billingMode(BillingMode.PAY_PER_REQUEST)
                    .build();
            logger.info("Creating a table with name: " + tableName + " in DynamoDb.");
            dynamoDbClient.createTable(createTableRequest);
        } else {
            logger.info("Table with name: " + tableName + " already exists.");
        }
    }

    private boolean doesTableExist(DynamoDbClient dynamoDbClient) {
        ListTablesResponse listOfTables = dynamoDbClient.listTables();

        return listOfTables.tableNames().stream().anyMatch(name -> name.equalsIgnoreCase(tableName));
    }
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Note: In this project, we are programmatically creating the AWS resources (S3 bucket and DynamoDB table) using the AWS SDK inside a Spring @Configuration class. This approach is totally fine for educational purposes, quick prototyping, or proof of concepts (POCs), especially when working with tools like LocalStack.

However, in a real-world production scenario, the most professional and maintainable way to provision AWS infrastructure is by using Infrastructure as Code (IaC) tools like Terraform. In an upcoming post, I’ll walk you through how to replace this manual setup with a proper Terraform configuration — giving you more control, versioning, and automation in your infrastructure deployment.

Cors Configs

Now, let's write the cors configuration:

package dev.MSpilari.webSocketBackend.configs;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class CorsConfigs {

    @Bean
    public WebMvcConfigurer corsConfigurer() {
        return new WebMvcConfigurer() {

            @Override
            public void addCorsMappings(CorsRegistry corsRegistry) {
                corsRegistry.addMapping("/**")
                        .allowedOrigins("http://localhost:5173")
                        .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
                        .allowedHeaders("*")
                        .allowCredentials(true);
            }
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Code explanation

corsRegistry.addMapping("/**")
Enter fullscreen mode Exit fullscreen mode

Applies this CORS configuration to all routes in your application (i.e., /**).

.allowedOrigins("http://localhost:5173")
Enter fullscreen mode Exit fullscreen mode

Allows cross-origin requests only from http://localhost:5173, which is your front-end running on Vite during development.

⚠️ This value is hardcoded and not ideal for different environments (dev, staging, prod).

.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
Enter fullscreen mode Exit fullscreen mode

Specifies which HTTP methods are allowed from cross-origin requests.

.allowedHeaders("*")
Enter fullscreen mode Exit fullscreen mode

Allows all HTTP headers in cross-origin requests, including custom headers like Authorization.

.allowCredentials(true);
Enter fullscreen mode Exit fullscreen mode

Enables cookies and authentication headers (like tokens) to be included in CORS requests.

WebSocket Config

import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic");
        config.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws").setAllowedOrigins("http://localhost:5173").withSockJS();
    }
}
Enter fullscreen mode Exit fullscreen mode

Code explanation

@EnableWebSocketMessageBroker

Enables WebSocket message handling, backed by a message broker. It tells Spring that we want to use STOMP over WebSockets to send and receive messages.

configureMessageBroker()
config.enableSimpleBroker("/topic");
Enter fullscreen mode Exit fullscreen mode

This enables a simple in-memory message broker and sets the destination prefix /topic. This is where the client can subscribe to receive messages.

For example, your front-end might subscribe to /topic/albums.

config.setApplicationDestinationPrefixes("/app");
Enter fullscreen mode Exit fullscreen mode

This defines the prefix used for messages that are sent from the client to the server. When your front-end sends a message to /app/albums, it will be routed to a corresponding method in a Spring controller (like @MessageMapping("/albums")).

registerStompEndpoints()
registry.addEndpoint("/ws")
Enter fullscreen mode Exit fullscreen mode

This exposes a WebSocket endpoint at /ws that clients will use to establish a connection with the backend.

.setAllowedOrigins("http://localhost:5173")
Enter fullscreen mode Exit fullscreen mode

Allows cross-origin requests from your front-end (running on Vite at port 5173).

💡 As mentioned in the CORS section, it’s better to extract this into a configuration property for production environments.

.withSockJS();
Enter fullscreen mode Exit fullscreen mode

Enables SockJS as a fallback option. SockJS allows the client to simulate a WebSocket connection using HTTP when WebSockets are not supported by the browser or blocked by a firewall.


Model with DynamoDB Mapping in Spring Boot

Here’s the full class:

package dev.MSpilari.webSocketBackend.models;

import java.util.List;

import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute;
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean;
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey;

@DynamoDbBean
public class Album {

    private String albumId;
    private String albumName;
    private List<String> picturesUrl;

    public Album() {
    }

    public Album(String albumId, String albumName, List<String> picturesUrl) {
        this.albumId = albumId;
        this.albumName = albumName;
        this.picturesUrl = picturesUrl;
    }

    @DynamoDbPartitionKey
    @DynamoDbAttribute("album_id")
    public String getAlbumId() {
        return albumId;
    }

    public void setAlbumId(String albumId) {
        this.albumId = albumId;
    }

    @DynamoDbAttribute("album_name")
    public String getAlbumName() {
        return albumName;
    }

    public void setAlbumName(String albumName) {
        this.albumName = albumName;
    }

    @DynamoDbAttribute("pictures_url")
    public List<String> getPicturesUrl() {
        return picturesUrl;
    }

    public void setPicturesUrl(List<String> picturesUrl) {
        this.picturesUrl = picturesUrl;
    }

    @Override
    public String toString() {
        return "Album [albumId=" + albumId + ", albumName=" + albumName + ", picturesUrl=" + picturesUrl + "]";
    }
}
Enter fullscreen mode Exit fullscreen mode

🔖 Annotations Explained

@DynamoDbBean

Declares that this class can be used with DynamoDB Enhanced Client. It tells the SDK how to serialize and deserialize the object.

@DynamoDbPartitionKey

Specifies the primary key of the DynamoDB table. In this case, albumId is the partition key for the album data.

@DynamoDbAttribute("album_id")

Maps the Java field albumId to the DynamoDB attribute named "album_id". This is useful when your DynamoDB field names follow snake_case, while your Java fields use camelCase.

The same logic applies to:

  • albumName"album_name"
  • picturesUrl"pictures_url"

This mapping is super helpful for keeping your backend clean and idiomatic while remaining compatible with database naming conventions.


WebSocket Service Layer

In this part of our backend, we're using Spring’s WebSocket support to send data in real-time from the server to all connected clients. The class below is responsible for managing the real-time communication of our albums via WebSockets.

package dev.MSpilari.webSocketBackend.services;

import java.util.List;

import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;

import dev.MSpilari.webSocketBackend.models.Album;
import dev.MSpilari.webSocketBackend.repositories.AlbumRepository;

@Service
public class AlbumWebSocketServices {

    private final SimpMessagingTemplate messagingTemplate;
    private final AlbumRepository albumRepository;

    public AlbumWebSocketServices(SimpMessagingTemplate messagingTemplate, AlbumRepository albumRepository) {
        this.albumRepository = albumRepository;
        this.messagingTemplate = messagingTemplate;
    }

    public void sendAllAlbumsToClients() {
        List<Album> allAlbums = albumRepository.getAllAlbums();
        messagingTemplate.convertAndSend("/topic/albums", allAlbums);
    }

    public void broadcastNewAlbum(Album album) {
        messagingTemplate.convertAndSend("/topic/albums", album);
    }
}
Enter fullscreen mode Exit fullscreen mode

What This Class Does

This is a Spring @Service component that handles broadcasting data over WebSockets using SimpMessagingTemplate. It communicates with both the repository layer (to fetch album data) and the WebSocket broker (to send messages to clients).

Dependencies

private final SimpMessagingTemplate messagingTemplate;
private final AlbumRepository albumRepository;
Enter fullscreen mode Exit fullscreen mode
  • SimpMessagingTemplate is a Spring utility for sending messages to WebSocket clients.
  • AlbumRepository is where we fetch album data from DynamoDB (or any data source backing our albums).

sendAllAlbumsToClients()

public void sendAllAlbumsToClients() {
    List<Album> allAlbums = albumRepository.getAllAlbums();
    messagingTemplate.convertAndSend("/topic/albums", allAlbums);
}
Enter fullscreen mode Exit fullscreen mode
  • Fetches all albums from the repository.
  • Sends them to all clients subscribed to /topic/albums.

This is useful, for example, when a client connects and you want to immediately push all existing albums to them.

broadcastNewAlbum(Album album)

public void broadcastNewAlbum(Album album) {
    messagingTemplate.convertAndSend("/topic/albums", album);
}
Enter fullscreen mode Exit fullscreen mode
  • Sends just one album to all clients.
  • Called when a new album is created and you want to broadcast it in real-time to all listeners.

Messaging Destination: /topic/albums

  • This matches what we defined in our WebSocketConfig class (/topic is the broker prefix).
  • Any client subscribed to this topic will receive the messages (e.g., your frontend with SockJS and STOMP).

WebSocket Controller

This controller acts as the entry point for WebSocket messages sent by the client. It's responsible for listening to incoming messages and delegating the logic to the appropriate service.

Let’s break down what this class does:

package dev.MSpilari.webSocketBackend.controllers;

import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.stereotype.Controller;

import dev.MSpilari.webSocketBackend.services.AlbumWebSocketServices;

@Controller
public class AlbumWebSocketController {

    private final AlbumWebSocketServices webSocketService;

    public AlbumWebSocketController(AlbumWebSocketServices webSocketService) {
        this.webSocketService = webSocketService;
    }

    @MessageMapping("/albums")
    public void handleGetAlbumsRequest() {
        webSocketService.sendAllAlbumsToClients();
    }
}
Enter fullscreen mode Exit fullscreen mode

What Is This?

This is a Spring @Controller that specifically listens to WebSocket messages (not HTTP requests).

When a client sends a message to the destination /app/albums (based on the prefix defined in your WebSocket configuration), this controller receives it and triggers the appropriate action.

Dependencies

private final AlbumWebSocketServices webSocketService;
Enter fullscreen mode Exit fullscreen mode
  • This is the service responsible for sending album data back to the WebSocket clients.
  • It was explained in detail in the previous post (AlbumWebSocketServices).

@MessageMapping("/albums")

This annotation works similarly to @PostMapping or @GetMapping in regular REST controllers — but it's used for WebSockets.

  • It maps to messages sent to /app/albums (because /app is the application prefix you defined in WebSocketConfig).
  • When the frontend sends a message to this route, the method handleGetAlbumsRequest() is invoked.

handleGetAlbumsRequest()

public void handleGetAlbumsRequest() {
    webSocketService.sendAllAlbumsToClients();
}
Enter fullscreen mode Exit fullscreen mode
  • This method delegates the logic to the service layer.
  • It fetches all albums from the database and broadcasts them to every connected WebSocket client subscribed to /topic/albums.

Real-World Usage Flow

  1. The frontend (e.g., a React app) sends a message to /app/albums — usually to request all existing albums.
  2. This controller method is triggered.
  3. It uses AlbumWebSocketServices to fetch and send all albums via WebSocket to /topic/albums.
  4. Every client listening to /topic/albums receives the data in real time.

💡 Pro Tip

If you plan to expand this project, this is the perfect place to add more WebSocket endpoints, such as:

  • Creating an album via WebSocket.
  • Listening to album deletion events.
  • Handling private user notifications or activity streams.

Conclusion

This backend was built with scalability and modularity in mind. It’s ideal for:

  • Learning how WebSocket works in Spring Boot
  • Working with AWS services without real cloud dependencies
  • Experimenting with real-time systems

If you want a clean, full-stack example of WebSocket + AWS emulation, this backend setup is a solid foundation.

See the GitHub Repository of the code here.


📍 Reference

💻 Project Repository

👋 Talk to me

Top comments (0)