Project · 2026

Wasatext

A instant messaging web-app consisting of a REST API backend and a SPA VueJs Frontend, deployed with docker and cloudflare, both in local network and on the internet on my server.

Wasatext is a WhatsApp/Telegram-style messenger built initially as a project for the Sapienza Web and Software Architecture (WASA) course. The required stack was fixed: a Go REST API backend, a Vue 3 SPA, SQLite as the only persistent store, and a Docker deployment that works both on localhost and over LAN.

1. Ports & adapters, not layers for the sake of layers

The idea of building a university project that would later sit publicly on my GitHub, but only as “something nobody would ever really look at,” didn’t sit well with me. So I took the project seriously and made a series of decisions that would let it become an actual exposable service down the line.

The course came with constraints that make sense for teaching but not for production: a simplified login (grading can’t depend on OAuth working every time), a file-based database (a university obviously can’t run a Postgres instance per student), and so on. Rather than let those constraints leak into the design, I structured the code around Clean/Hexagonal Architecture, keeping the domain and the responsibilities of each layer clearly separated — so that swapping in “real” infrastructure later is a matter of writing new adapters, not rewriting the app.

The backend is organized around four concentric layers: a domain at the center holding the core entities (User, Message, Conversation, Reaction, Receipt) with zero external dependencies; a set of ports defining the interfaces the domain needs from the outside world; a use-case layer where all business rules live, one use case per responsibility (auth, messaging, conversations, reactions, receipts, users); and an outer layer of adapters and HTTP handlers that translate between the outside world and the use cases, without ever containing business logic themselves.

To make this concrete, think of the backend as a restaurant:

The domain is the kitchen’s recipe book. It defines what a dish is — its ingredients, its structure, the rules that never change regardless of which supplier delivered the tomatoes or which waiter is on shift. In Wasatext, this is the core entities: plain objects with zero external dependencies, describing truths that hold no matter what database or transport sits underneath.

The ports are the order slips and delivery specs. They define how the kitchen expects to receive ingredients and send out dishes — the shape of the interaction — without saying anything about who the supplier is or what truck they drive. These are the contracts the domain relies on, owned by the domain, implemented by someone else.

The usecases are the kitchen’s standard procedures — “take an order,” “plate a dish,” “close out a table.” Each one coordinates several ports to get a real piece of work done, the way sending a message coordinates storage, notifications, and authentication without knowing or caring how any of them are actually implemented.

The adapters are the actual staff and equipment — the specific waiter, the specific delivery van, the specific point-of-sale terminal — that fulfill a port’s contract using one particular technology. Today that means SQLite for storage; it could be swapped for another database tomorrow without the kitchen ever noticing.

The point of the whole exercise: the kitchen doesn’t care which supplier shows up, as long as the tomatoes arrive the way the order slip says they will.

2. REST API, boringly predictable on purpose

Flat, resource-oriented, one Bearer token for everything protected. The API is organized around the natural resources of a messaging app: sessions (login and logout), users (search and profile management), conversations (creation and renaming), messages (sending, retrieving, forwarding, and read receipts), reactions, and a single realtime endpoint that upgrades to a WebSocket connection.

Routing runs on a radix-tree router — path matching in time linear to the path length, not to how many routes exist — which is overkill at this scale but costs nothing over a regex router, so there’s no reason not to take it.

3. Realtime

An instant messaging app needs to show updates in real time. Rather than poll (which, to satisfy the course’s requirements, would have needed a fairly high rate and would have hammered the backend for no good reason), I used WebSockets instead.

A single in-memory hub owns the mapping between users and their open connections — deliberately built as an actor: only the hub’s own goroutine ever touches that map, so there’s no mutex on the data itself, only on the channels feeding it. Multi-device is free: every open tab or phone for a user gets every event.

Backpressure is handled the unglamorous way that actually matters in production: each client has a bounded send buffer. If a client can’t keep up, its connection is closed, not queued indefinitely — one slow phone on bad LTE should never stall delivery to everyone else. The client itself never sends anything after the upgrade; the server is write-only, and the read loop exists purely to detect the socket closing.

4. Data-model decisions that actually matter at scale

This is the part I’d want a senior engineer to review, because it’s where “it works” and “it doesn’t leak information / doesn’t fall over at 10x” diverge:

Two IDs per message, on purpose. An internal autoincrement ID is used only for ordering and comparisons. The only ID the API ever returns is a UUID. A sequential public ID would let anyone infer how many messages exist in total just by diffing two responses; the UUID gives away nothing.

Watermarks instead of a receipt row per message. The naive design — one row per message per user — grows without bound as both scale up. A watermark table instead keeps one row per conversation per user, tracking the last message read and delivered. Storage stays proportional to the number of users, not the number of messages, and “how many unread” becomes a single indexed lookup instead of scanning every message.

Sessions are hashed, not stored. The session identifier stored in the database is a hash of the raw token; the token itself is never persisted. A database leak doesn’t hand out reusable sessions.

Soft delete everywhere. Deleted messages and departures from a conversation are marked, never physically removed. History stays consistent for anyone who was actually in the conversation when a message was sent, and referential integrity is enforced at the database level — orphaned rows are structurally impossible, not just discouraged by convention.

None of these are exotic. They’re the difference between a schema that “passes the demo” and one that doesn’t need a redesign the first time it meets real traffic or a security review.

5. Performance work that showed up in real numbers

Phone photos land as 2–8 MB JPEGs. Left alone, opening a chat full of them on mobile data is a multi-second wait per image. Every upload goes through an image-optimization step built as one of the adapters described in section 1: automatic orientation correction baked in at encode time, resizing tuned separately for avatars versus chat attachments, and quality-adjusted re-encoding — with a safety check that keeps the original file if the optimized version somehow isn’t smaller, so the pipeline never makes things worse.

A real test avatar dropped from 2.6 MB to 53 KB. Re-running the same optimizer as a one-off tool against pre-existing uploads recovered 92% of disk space on a real dataset.

Caching is the other half: immutable, hashed frontend bundles are cached aggressively so repeat visits cost nothing, while the entry page itself is never cached so every deploy is visible instantly; uploaded media is served under permanent, unique identifiers, so a photo you’ve already seen is never fetched twice.

6. Docker Compose, and nothing else to remember

Three services make up the deployment. A backend service that isn’t reachable from outside the Docker network at all — only the frontend can talk to it — with its data and uploads kept on disk as simple files, so backing up the whole app is a single copy operation. A frontend service, the only one exposed to the host, which serves the built single-page app and forwards API and media requests to the backend behind the scenes. And an optional tunnel service, enabled only when needed, that exposes the whole thing under a single public hostname with no separate API endpoint to configure.

Both the backend and frontend images are built in multiple stages: the full toolchain needed to compile everything is only present during the build, while what actually ships is a minimal runtime image. Whichever environment I’m deploying to — my own machine, the home LAN, or a public tunnel — the command to bring it up is exactly the same one, because nothing environment-specific is baked into the images. Auth can also be toggled at deploy time: the simplified login required for the course stays available for local testing, while Google Sign-In can be enabled for a real-world deployment, all through configuration rather than code changes.

If you’d like to try the app, or if your company is looking for a self-hosted internal communication tool that keeps all data in house, feel free to reach out — I’m happy to provide a free demo.