DEV Community

Bejoy JBT
Bejoy JBT

Posted on

Building a Real-Time Chat Platform in Java from Scratch

Building a Real-Time Chat Platform in Java from Scratch

Most chat tutorials start with a framework that hides the interesting parts. I wanted to see what it takes to build a real-time messaging system with plain Java — so I built BroadcastHub, a terminal-based chat platform powered by WebSockets.

This article walks through the problem, architecture, protocol design, persistence, private channels, Docker deployment, and what I learned along the way.

GitHub: https://github.com/bejoy-jbt/BroadcastHub

Docker: docker pull bejoy1514/broadcasthub:latest


The Problem

I set out to build something that could:

  1. Connect multiple terminal clients to one server in real time
  2. Support channels (like Slack rooms, but in the terminal)
  3. Allow private channels with invite codes
  4. Support direct messages between users
  5. Persist channels, message history, and bans across restarts
  6. Provide basic moderation tools for an admin

The constraint: no Spring Boot, no database — keep the stack small enough to understand every layer.


Architecture

BroadcastHub follows a layered design around a single WebSocket server:

Terminal Client
        │
        ▼
WebSocket Server (BroadcastServer)
        │
 ┌────────────────┐
 │ ClientRegistry │  Maps WebSocket ↔ username
 └────────────────┘
        │
 ┌────────────────┐
 │ ChannelManager │  Channels, membership, invite codes
 └────────────────┘
        │
 ┌────────────────┐
 │ MessageHistory │  Per-channel message deque (max 100)
 └────────────────┘
        │
 ┌────────────────┐
 │  AdminManager  │  Mutes, bans, moderation state
 └────────────────┘
        │
 ┌────────────────┐
 │  Persistence   │  Jackson → data/*.json
 └────────────────┘
Enter fullscreen mode Exit fullscreen mode

Key classes:

Component Responsibility
BroadcastServer WebSocket lifecycle, registration, routing
ClientRegistry Username registration, online user lookup
CommandProcessor Slash commands (/join, /create, /msg)
ChannelManager Channel CRUD, membership, invite validation
MessageHistory In-memory history with disk backup
*Store classes JSON read/write via Jackson

Entry point:

java -jar broadcasthub.jar server   # start server
java -jar broadcasthub.jar client   # start client
Enter fullscreen mode Exit fullscreen mode

WebSocket Design

I deliberately chose a plain-text protocol instead of JSON messages. In a terminal client, you can read the wire format directly.

Registration

First message from client must be:

REGISTER|bejoy
Enter fullscreen mode Exit fullscreen mode

Server responds:

REGISTER_SUCCESS|bejoy
Enter fullscreen mode Exit fullscreen mode

or REGISTER_FAILED|reason.

Chat messages

After registration, plain text is broadcast to the user's current channel:

Hello everyone!
Enter fullscreen mode Exit fullscreen mode

Server formats and relays:

[bejoy] Hello everyone!
Enter fullscreen mode Exit fullscreen mode

Slash commands

Messages starting with / are intercepted by CommandProcessor:

/join gaming
/create java private
/msg alex Hey!
/help
Enter fullscreen mode Exit fullscreen mode

Why this works

  • Easy to test with wscat or any WebSocket client
  • No serialization overhead for a learning project
  • Clear separation: registration → commands → chat

The server uses Java-WebSocket (org.java-websocket) for both server and client.


Persistence

BroadcastHub stores data under data/:

data/
├── channels.json   # channel metadata + invite codes
├── history.json    # per-channel message history
└── bans.json       # banned usernames
Enter fullscreen mode Exit fullscreen mode

Jackson handles serialization. On startup, stores load into memory; on change, they write back to disk.

Design choices:

  • History capped at 100 messages per channel — prevents unbounded memory growth
  • Bans persisted, mutes in-memory — bans are security-sensitive; mutes are session-level
  • Channels saved on create/delete — invite codes survive restarts

Example channels.json:

{
  "general": {
    "name": "general",
    "privateChannel": false,
    "inviteCode": null
  },
  "java": {
    "name": "java",
    "privateChannel": true,
    "inviteCode": "A7KD91"
  }
}
Enter fullscreen mode Exit fullscreen mode

Private Channels

Private channels add an invite code at creation:

/create java private
Enter fullscreen mode Exit fullscreen mode

Server responds:

Private Channel Created
Code : A7KD91
Enter fullscreen mode Exit fullscreen mode

To join:

/join java A7KD91
Enter fullscreen mode Exit fullscreen mode

ChannelManager.canJoin() validates the code before switching the user's channel. On join, the server sends the last 10 messages so users have context.

Public channels skip invite validation — anyone can /join gaming.


Docker Deployment

A multi-stage Dockerfile builds with Maven and runs on JRE 17 Alpine:

# Build stage: mvn package
# Runtime stage: copy fat JAR, expose 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
CMD ["server"]
Enter fullscreen mode Exit fullscreen mode

Run server:

docker run -p 8080:8080 -v broadcasthub-data:/app/data bejoy1514/broadcasthub
Enter fullscreen mode Exit fullscreen mode

Run client:

docker run -it --rm bejoy1514/broadcasthub client host.docker.internal 8080
Enter fullscreen mode Exit fullscreen mode

Mounting /app/data keeps channels, history, and bans across container restarts.


Lessons Learned

1. Concurrent collections are non-negotiable

Multiple WebSocket threads read and write shared maps. ConcurrentHashMap and ConcurrentLinkedDeque prevented race conditions without manual locking everywhere.

2. Docker breaks stdin-based admin consoles

An admin console reading System.in fails in non-interactive containers. I added client-side admin via @@stats, @@ban user, etc. — pragmatic for Docker users.

3. Keep command routing separate

CommandProcessor owns user slash commands. BroadcastServer owns admin commands. Mixing them would have made both harder to extend.

4. File persistence is enough (for now)

For a portfolio project, JSON files beat setting up PostgreSQL. The tradeoff: no queryable history, no horizontal scaling — acceptable for v1.

5. Ship a one-command install

Publishing to Docker Hub (bejoy1514/broadcasthub) got more people trying it than "clone, mvn package, run" ever would.


What's Next

  • Web UI client
  • Secure WebSockets (WSS)
  • Admin authentication
  • Channel ownership and permissions

Try It

git clone https://github.com/bejoy-jbt/BroadcastHub.git
cd BroadcastHub
mvn clean package
java -jar target/broadcasthub-1.0-SNAPSHOT.jar server
Enter fullscreen mode Exit fullscreen mode

Or pull the Docker image:

docker pull bejoy1514/broadcasthub:latest
Enter fullscreen mode Exit fullscreen mode

Star the repo if you find it useful: https://github.com/bejoy-jbt/BroadcastHub


Questions or feedback? Leave a comment — I'd love to hear how you'd extend this.

Top comments (0)