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:
- Upload images and create albums
- Receive real-time updates when new albums are created
- 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));
}
}
⚠️ 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);
}
};
}
}
Code explanation
corsRegistry.addMapping("/**")
Applies this CORS configuration to all routes in your application (i.e.,
/**
).
.allowedOrigins("http://localhost:5173")
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")
Specifies which HTTP methods are allowed from cross-origin requests.
.allowedHeaders("*")
Allows all HTTP headers in cross-origin requests, including custom headers like
Authorization
.
.allowCredentials(true);
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();
}
}
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");
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");
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")
This exposes a WebSocket endpoint at
/ws
that clients will use to establish a connection with the backend.
.setAllowedOrigins("http://localhost:5173")
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();
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 + "]";
}
}
🔖 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);
}
}
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;
-
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);
}
- 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);
}
- 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();
}
}
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;
- 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 inWebSocketConfig
). - When the frontend sends a message to this route, the method
handleGetAlbumsRequest()
is invoked.
handleGetAlbumsRequest()
public void handleGetAlbumsRequest() {
webSocketService.sendAllAlbumsToClients();
}
- 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
- The frontend (e.g., a React app) sends a message to
/app/albums
— usually to request all existing albums. - This controller method is triggered.
- It uses
AlbumWebSocketServices
to fetch and send all albums via WebSocket to/topic/albums
. - 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.
Top comments (0)