Portfolio
BACK_TO_JOURNAL
#WebRTC#Go#WebSockets#Distributed System#Heavy Workload

Building Ethio-Peer

May 10, 2025

Why, and why a platform

The official answer to "how do CS students actually learn this stuff" at my college was lecture, assignment, exam. But what actually worked for me, and for basically everyone I studied with, was the small group. Three or four of us, one shared screen, one person explaining something they'd just figured out, the rest asking the questions no lecture ever got to.

We had no infrastructure for it though. No easy way to spin up a live session and make it public, no way to share materials across a study group, no record of what students were stuck on so an instructor could step in. So we built one. Ethio-Peer is a real-time collaborative workspace and streaming thing for peer-learning groups in Ethiopia, scoped first to HILCOE School of Computer Science, my college, but built to be pointed somewhere else later.

What it looks like

Seven services, one shared docker-compose.yml, one VPS doing absolutely everything:

gateway/             # C# + YARP, the single front door
auth-service/        # Go
peer-service/        # Go
streaming-service/   # Go + LiveKit
resource-service/    # C# + MinIO, the upload pipeline
bridge-service/      # Go, glues things together
mailing-service/      # Go

Each service owns its data, calls neighbors over gRPC when it needs a synchronous answer, and drops events on RabbitMQ for everything async. Redis sits in front of the hot reads.

The gateway is C# because YARP is the nicest reverse-proxy story I've used in any language. The resource-service is C# because ASP.NET's multipart upload plus the MinIO SDK is a couple of lines, and Go's upload story in 2025 is still a fight you don't need to have.

Everything else is Go.


The box it runs on

One VPS: 50GB of storage, 1.9GB of RAM. LiveKit self-hosted on it, RabbitMQ on it, Redis on it, all seven services on it. I did every deploy myself.

For context, LiveKit's documented minimum for a small production deployment is more memory than I had for the entire stack. RabbitMQ's defaults alone eat more RAM than I could spare. Every container assumed I had a real machine. I didn't.

So nothing ran on defaults. docker-compose.yml got explicit deploy.resources.limits on the heavy hitters: RabbitMQ capped at 1 CPU and 1GB, Redis at half a CPU and 512MB, everything else fighting over whatever was left. Not clever, just restraint. I turned off things I didn't need (RabbitMQ's management UI, Redis disk persistence, LiveKit recording), grabbed Alpine images where I could, and tuned the Go services for low idle memory.

It runs, and keeps running. When the hardware is the constraint, you stop reading about what's possible and start reading about what you can turn off.


The Go services: vertical slices

All the Go services share one internal layout:

internal/
├── broker/      # RabbitMQ producers/consumers
├── db/          # database connection + migrations
├── features/    # the actual slices: login, signup, peer, profile, ...
├── genproto/    # generated gRPC code
├── models/      # domain types
├── protobuf/    # .proto sources
└── server/      # the gRPC server wiring

That's VSA, vertical slice architecture. Instead of handlers/, services/, repositories/ sorted by kind of thing, each feature is a folder that owns its handler, its validation, its persistence, and its events. auth-service/internal/features/login/ is everything login needs. Nothing outside that folder has to know it exists.

It feels like overkill on day one. On day thirty, when you add a permission check to signup and the whole change lives inside the signup folder, you understand the peace of mind it gives you. The only files that know about anything besides themselves are the cross-cutting ones: broker, db, models.

A slice looks like this:

// features/signup/signup.go
func Signup(ctx context.Context, req *pb.SignupRequest) (*pb.SignupResponse, error) {
    // validate
    // persist
    // emit signup.created
    // return
}

Fan-out / fan-in

Some requests need to do N things in parallel and gather the results. When the streaming service starts a session, it has to provision a LiveKit room, tell the peer group, queue an analysis job for the admin dashboard, and write an audit event, all before responding.

Standard Go pattern: a sync.WaitGroup plus per-result channels, with a small dispatcher that closes them. Each fan-out worker writes to its own channel, the fan-in goroutine ranges over them with a timeout, and the function returns the merged result:

results := make(chan result, len(tasks))
var wg sync.WaitGroup

for _, t := range tasks {
    wg.Add(1)
    go func(t Task) {
        defer wg.Done()
        results <- do(t)
    }(t)
}

wg.Wait()
close(results)

Where I used it:

  • Fetching group members along with active participants from livekit after paginating, fanning out and caching the transformed data.
  • Admin analytics rollup in the bridge-service: pull per-session stats from multiple services and aggregate.

gRPC everywhere

Every service-to-service call is gRPC. The browser never talks to a service directly. It goes through the gateway, which terminates HTTP, checks the auth claim, and routes to the right gRPC backend via YARP. The .proto files live in each service's protobuf/ folder, generated code in genproto/.


The resource-service: where files live

The C# one, owns the material pipeline. REST endpoints for upload and download.

  • Client asks the gateway for a presigned upload URL.
  • Client uploads straight to MinIO. The bytes never touch the gateway.

The admin view: AI on the discussion stream

  • summarizes what topics came up,
  • highlights discussion strength and weakness

That report is what the college admin sees. A summary of what students were struggling with during the live session and what it felt like from the inside. The instructor then decides whether to follow up.


Deployment

deploy.sh at the repo root is a small loop that tags and pushes locally built images to Docker Hub under my namespace; the VPS pulls and restarts. One docker-compose.yml knows about every service and dependency.

The right architecture is the one you can actually run on the hardware you've got.


References

  • Code: github.com/barnabasSol/ethio-peer, Go + C# microservices, LiveKit + MinIO + RabbitMQ + Redis, single-VPS deployment via docker-compose.yml.
  • Pattern references: VSA / feature-folder layout for the Go services; fan-out/fan-in via sync.WaitGroup + per-task channels; gRPC for sync service calls, RabbitMQ for async events; YARP as the gateway reverse proxy.