Skip to content

System Overview

Tomoda is a real-time social platform for organising spontaneous in-person events. The product surfaces a friends-and-events map, real-time chat, ephemeral "moments", and discovery of nearby people and venues. It is delivered as a single Expo codebase shipping to iOS, Android, and the web, backed by a Go monolith that fronts Postgres, Redis, object storage, and a handful of third-party services.

High-level component map

Clients (one Expo codebase)
Expo iOS
Expo Android
Expo Web
Ingress / Load Balancer
Go binary (single process)
HTTP API (chi)
WebSocket Hub
Asynq Worker
Scheduler
↙   ↓   ↘
Data plane
Postgres + PostGIS
Redis
S3 / MinIO
External providers
Photon OSM
Google Places
Stripe
Resend
Google / Apple / LINE OAuth
All four runtimes share the same Go binary and the same data plane; external providers are reached only from the API and worker.

The backend is a single Go binary built from backend/cmd/server/main.go. On boot it loads layered YAML config (config.yaml plus environment-specific overrides), initialises a Zap logger, connects to Postgres (pool sized 10 idle / 100 open in backend/internal/database), applies the goose migrations in non-production environments via db.Migrate, wires the dependency graph via Wire (backend/internal/wiring/providers.go), seeds category data, then starts three concurrent runtimes inside the same process:

  • the HTTP server (chi over net/http) on :8080,
  • the WebSocket Hub goroutine (hub.Run()),
  • the Asynq worker server (background tasks) and the scheduler manager (cron-style enqueuers).

Request lifecycle (authenticated REST)

A typical authenticated request — e.g. POST /api/v1/events — traverses a deterministic middleware chain before reaching a handler:

Client request POST /api/v1/events
chi router
Middleware chain (fixed order)
Recovery
Logger
TraceID
CORS
SecurityHeaders
IPBlocker
JWTAuth
Handler parses DTO
Service business logic
Store only DB caller
Postgres
The middleware order is fixed in SetupRouter; only the JWTAuth (purple) layer is identity-gated. Handlers stay thin, DTOs in, JSON out, while services own business logic and the per-domain store is the only layer that touches the database (via sqlc-generated queries over pgx).

The middleware order is fixed in backend/internal/wiring/router.go (SetupRouter) via r.Use(...): Recovery → Logger → TraceID → Observability (metrics + tracing) → CORS → SecurityHeaders → IPBlocker.BlockMiddleware → IPBlocker.SuspiciousActivityMiddleware, with JWTAuth (and access-policy gates where applicable) applied per-group on protected routes via r.With / r.Group. Each middleware is a func(http.Handler) http.Handler. Handlers are thin: they parse DTOs, call into a service, and return JSON. Services hold business logic; the per-domain store is the only layer that touches the database, running that domain's sqlc-generated queries over pgx.

Tracing

The TraceID middleware assigns a per-request UUID and threads it through Zap log fields, making it possible to follow a single request across handler, service, store, and worker boundaries.

Real-time lifecycle

WebSocket traffic is routed through a separate top-level group:

r.Route("/ws", func(r chi.Router) {
    r.Use(middleware.JWTAuth(app.AuthService))
    r.Get("/chats/{id}", app.Handlers.ChatHandler.HandleWebSocket)
})

JWTs are passed via the token query parameter (browser WebSocket APIs cannot set headers). The handler upgrades the connection, registers a Client on the in-process Hub, and the Hub manages per-chat rooms with broadcast fan-out. See Real-time for the full picture.

Background job lifecycle

Background work uses Asynq on top of Redis. Three queues are configured in backend/internal/async/server.go:

Queue Weight Used for
critical 6 Time-sensitive notifications, webhook fan-out
default 3 Standard async work
low 1 Cleanup, purges, opportunistic jobs

The scheduler (backend/internal/async/scheduler.go) is a separate, Redis-coordinated cron runner that enqueues recurring jobs:

Job Cadence Purpose
status_update 5 min Roll events between scheduled / active / completed
redis_sync 5 min Reconcile Redis caches with Postgres
token_cleanup 1 hr Purge expired refresh tokens / sessions
account_suspension 12 hr Apply scheduled suspensions
purge 24 hr General hard-delete sweep
deactivated_acct_purge 24 hr Hard-delete accounts past their deactivation grace
moment_cleanup 5 min Soft-delete expired non-journaled moments
moment_purge 24 hr Hard-delete moments soft-deleted > 7 days
message_expiry 30 s Hard-delete disappearing messages past expires_at
Scheduler cron tick
↓ enqueue
Asynq client
↓ LPUSH
Redis queue critical / default / low
↓ BRPOP
Asynq worker
↓ dispatch
TaskHandler ack / retry
Scheduler and worker live in the same Go binary as the API; Redis is the only handoff between them.

Deployment topology

In production the same Go binary runs on GKE behind an ingress, with images built by Google Cloud Build (cloudbuild-backend.yaml, cloudbuild-frontend.yaml), pushed to Artifact Registry, and rolled out by ArgoCD from a separate devops/ repo. Postgres is Cloud SQL (PostGIS enabled), Redis is Memorystore, object storage is GCS via S3-compatible APIs (MinIO locally), and Photon runs as a sidecar service for self-hosted geocoding. The mobile client is shipped through EAS Build with three profiles — development, preview, and production — defined in frontend/eas.json.