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

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.