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¶
graph LR
subgraph Client
iOS[Expo iOS]
Android[Expo Android]
Web[Expo Web<br/>react-native-web]
end
subgraph Edge
LB[Load Balancer / Ingress]
end
subgraph Backend
API[Go / Gin API]
WS[WebSocket Hub]
Worker[Asynq Worker]
Sched[Scheduler]
end
subgraph Data
PG[(Postgres + PostGIS)]
Redis[(Redis)]
S3[(S3 / MinIO)]
end
subgraph External
Photon[Photon OSM]
Places[Google Places]
Stripe[Stripe]
Resend[Resend Email]
OAuth[Google / Apple / LINE]
end
iOS --> LB
Android --> LB
Web --> LB
LB --> API
LB --> WS
API --> PG
API --> Redis
API --> S3
API --> Photon
API --> Places
API --> Stripe
API --> Resend
API --> OAuth
Worker --> PG
Worker --> Redis
Sched --> Redis
WS --> Redis
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), auto-migrates models in non-production environments, 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 (Gin) 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:
sequenceDiagram
participant App as Client (Expo)
participant Gin as Gin Router
participant MW as Middleware Chain
participant H as Handler
participant S as Service
participant R as Repository
participant DB as Postgres
App->>Gin: POST /api/v1/events (Bearer JWT)
Gin->>MW: Recovery → Logger → TraceID → CORS → SecurityHeaders → IPBlocker → SuspiciousActivity → JWTAuth
MW->>H: c.Set("user_id", ...); Next()
H->>S: EventService.Create(req, userID)
S->>R: EventRepository.Create(event)
R->>DB: INSERT ... RETURNING *
DB-->>R: row
R-->>S: *models.Event
S-->>H: response DTO
H-->>App: 201 JSON
The middleware order is fixed in backend/cmd/server/main.go: Recovery → Logger → TraceID → CORS → SecurityHeaders → IPBlocker.BlockMiddleware → IPBlocker.SuspiciousActivityMiddleware, with JWTAuth (and AdminAuth where applicable) applied per-group on protected routes. Handlers are thin — they parse DTOs, call into a service, and return JSON. Services hold business logic; repositories are the only layer that touches GORM.
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, repository, and worker boundaries.
Real-time lifecycle¶
WebSocket traffic is routed through a separate top-level group:
ws := r.Group("/ws")
ws.GET("/chat/:eventId", middleware.JWTAuth(app.AuthService), 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-eventId 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/worker/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/scheduler/manager.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 |
sequenceDiagram
participant Sched as Scheduler
participant Client as Asynq Client
participant Redis as Redis Queue
participant Worker as Asynq Worker
participant Handler as TaskHandler
Sched->>Client: EnqueueTask("message_expiry")
Client->>Redis: LPUSH queue
Worker->>Redis: BRPOP queue
Worker->>Handler: Handle(ctx, task)
Handler-->>Worker: ack / retry
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.