You're building a chat room. Or a notification system. Or anything that needs to broadcast messages to multiple subscribers. Five minutes in, you're already knee-deep in managing collections of actor references, handling subscription lifecycles, and wondering why this simple concept requires so much plumbing.
The publish-subscribe pattern solves this elegantly—when it's done right. But implementing distributed pub/sub from scratch means dealing with cluster coordination, message routing, and subscriber cleanup. Not exactly a fun afternoon.
This is why spring-boot-starter-actor includes a topic feature that gives you distributed pub/sub with three lines of code.
What You Usually End Up With
Here's the manual approach most people start with:
@Component
public class ChatRoomActor {
private final Set<ActorRef<Message>> subscribers = new HashSet<>();
public void addUser(ActorRef<Message> userActor) {
subscribers.add(userActor);
}
public void broadcast(Message msg) {
for (ActorRef<Message> subscriber : subscribers) {
subscriber.tell(msg);
}
}
}
Works fine until:
- You need to scale across multiple nodes (subscribers are on different machines)
- An actor dies and leaves a dead reference in your set
- You forget to unsubscribe someone and cause a memory leak
- You realize you're reinventing pub/sub poorly
What It Should Look Like
With topics, broadcasting becomes trivial:
@Service
public class ChatService {
private final SpringTopicManager topicManager;
public void sendMessage(String roomId, String userId, String message) {
SpringTopicRef<ChatMessage> roomTopic = topicManager
.topic(ChatMessage.class)
.withName("chat-room-" + roomId)
.getOrCreate();
roomTopic.publish(new ChatMessage(userId, message));
}
}
Three lines. No loops. No subscriber tracking. Works in both local mode and across a distributed cluster.
How It Works Under the Hood
The topic feature is built on three layers:
1. Topic Lifecycle Management
When you call topicManager.topic(MessageType.class).withName("name").getOrCreate(), the SpringTopicManager asks the RootGuardian actor to create or retrieve a topic. The RootGuardian maintains all top-level actors in your system and ensures topics are created exactly once.
Topics are identified by both name and message type:
// From DefaultRootGuardian.java
private String buildTopicActorName(String topicName, Class<?> messageType) {
return "topic-" + topicName + "-" + messageType.getName().replace(".", "_");
}
This means topic(String.class).withName("chat") and topic(Integer.class).withName("chat") are completely different topics. Type safety at the infrastructure level.
2. Pekko's Distributed Pub/Sub
Under the hood, each topic is a Pekko Topic actor. Pekko handles:
- Distributing messages across cluster nodes
- Cleaning up dead subscribers automatically
- Routing messages efficiently
- Maintaining subscriber lists per node
You publish a message. Pekko makes sure every subscriber gets it, no matter which node they're on.
3. Spring-Friendly API
SpringTopicRef wraps the Pekko topic with a clean API:
public class SpringTopicRef<T> {
public void publish(T message) { ... }
public void subscribe(SpringActorRef<T> subscriber) { ... }
public void unsubscribe(SpringActorRef<T> subscriber) { ... }
}
The generic type <T> prevents you from subscribing the wrong actor type to a topic at compile time. If your topic publishes ChatMessage, you can't accidentally subscribe an actor that expects UserMessage.
Putting It Together: Distributed Chat
Here's a complete chat room implementation using sharded actors and topics:
@Component
public class ChatRoomActor implements SpringShardedActor<ChatRoomActor.Command> {
private final SpringTopicManager topicManager;
public ChatRoomActor(SpringTopicManager topicManager) {
this.topicManager = topicManager;
}
@Override
public SpringShardedActorBehavior<Command> create(SpringShardedActorContext<Command> ctx) {
String roomId = ctx.getEntityId();
// Create a topic for this room
SpringTopicRef<UserActor.Command> roomTopic = topicManager
.topic(UserActor.Command.class)
.withName("chat-room-" + roomId)
.create();
return SpringShardedActorBehavior.builder(Command.class, ctx)
.withState(behaviorCtx -> new ChatRoomBehavior(behaviorCtx, roomTopic))
.onMessage(JoinRoom.class, ChatRoomBehavior::onJoinRoom)
.onMessage(SendMessage.class, ChatRoomBehavior::onSendMessage)
.build();
}
private static class ChatRoomBehavior {
private final SpringTopicRef<UserActor.Command> roomTopic;
private Behavior<Command> onJoinRoom(JoinRoom msg) {
// Subscribe the user actor to the room's topic
SpringActorRef<UserActor.Command> userRef =
new SpringActorRef<>(ctx.getSystem().scheduler(), msg.userActorRef);
roomTopic.subscribe(userRef);
// Broadcast join notification to all subscribers
roomTopic.publish(new UserActor.JoinRoomEvent(msg.userId));
return Behaviors.same();
}
private Behavior<Command> onSendMessage(SendMessage msg) {
// Broadcast the message to all subscribers
roomTopic.publish(new UserActor.SendMessageEvent(msg.userId, msg.message));
return Behaviors.same();
}
}
}
Each chat room actor creates its own topic. Users subscribe to the topic when they join. Messages get broadcast to everyone. That's it.
The chat room doesn't track user references. It doesn't loop through subscribers. It just publishes to a topic, and Pekko handles the rest—including routing messages across cluster nodes if your rooms are distributed.
When Topics Make Sense
Use topics when you need:
- One-to-many broadcasting - chat rooms, notifications, live feeds
- Decoupled publishers and subscribers - neither side knows about the other
- Dynamic subscriptions - actors join and leave freely without manual tracking
- Cluster-wide messaging - seamlessly works across distributed nodes
Skip topics for:
- Point-to-point messaging - just use actor references directly
- Guaranteed delivery - topics are at-most-once delivery
-
Request-response - use the
askpattern instead - Exactly-once semantics - you'll need additional infrastructure
The Honest Trade-Offs
Topics give you simplicity and distribution at the cost of delivery guarantees. Messages are delivered at-most-once—network issues or node failures can lose messages. There's also a small overhead compared to direct actor messaging.
For most broadcast use cases (chat, notifications, event streams), this is fine. You're trading perfect reliability for much simpler code that scales horizontally without thinking about it.
Getting Started
Add the dependency to your project:
dependencyManagement {
imports {
mavenBom("com.fasterxml.jackson:jackson-bom:2.17.3")
}
}
// Spring Boot 3.2.x
implementation 'io.github.seonwkim:spring-boot-starter-actor_3:0.6.3'
Enable actor support:
@SpringBootApplication
@EnableActorSupport
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}
Inject SpringTopicManager and start publishing:
@Service
public class NotificationService {
private final SpringTopicManager topicManager;
public void broadcast(Notification notification) {
topicManager.topic(Notification.class)
.withName("system-notifications")
.getOrCreate()
.publish(notification);
}
}
Try the Chat Example
The library includes a working distributed chat application:
# Start a 3-node cluster
$ sh cluster-start.sh chat io.github.seonwkim.example.SpringPekkoApplication 8080 2551 3
# Run the frontend
$ cd example/chat/frontend
$ npm run dev
Send messages between users across the cluster. Open the browser console to see which node each room lives on—rooms are distributed, but messages flow seamlessly.
Resources
- Documentation: https://seonwkim.github.io/spring-boot-starter-actor/
- Pub/Sub Guide: Topic Documentation
- GitHub: spring-boot-starter-actor
- Chat Example: Distributed Chat
Final Thoughts
Pub/sub topics aren't magic. They're Pekko's distributed Topic actor wrapped in a Spring-friendly API with lifecycle management through the RootGuardian. But that's all you need to stop writing manual subscription tracking code and let the framework handle distribution for you.
If you're building broadcast features—chat rooms, notifications, live feeds—topics will save you from a lot of error-prone plumbing. The code gets simpler, scales across nodes automatically, and you can focus on your actual business logic instead of debugging why subscriber cleanup isn't working.
Give it a try and see how much simpler your broadcast messaging becomes.
Top comments (0)