A hobby-focused social media backend built as a set of small, independent Go microservices behind an Nginx gateway, communicating over REST and asynchronous NATS events.
SocialPlatform lets people register, build profiles around their hobbies, follow each other, publish tagged posts with image/video media, chat in real time (direct and group chats), and receive a ranked feed (global popular+recent, prioritizing followed authors) and live notifications.
- Overview
- Tech Stack
- Architecture
- Repository Layout
- Getting Started
- Local Development
- Testing
- Observability
- Service & Port Reference
- API Documentation
- Troubleshooting
- Contributing
Each capability is owned by a dedicated service with its own PostgreSQL database (database-per-service). Services never reach into one another's database; they collaborate two ways:
- Synchronously via REST/JSON through the Nginx gateway.
- Asynchronously via NATS core pub/sub — publishers emit events, interested services maintain their own local copies of whatever data they need.
This keeps services independently deployable and testable, and means a slow or down service degrades gracefully rather than cascading.
| Layer | Technology |
|---|---|
| Language | Go 1.22 |
| HTTP router | chi v5 |
| Datastore | PostgreSQL 16 (via pgx/v5), one DB per service |
| Cache / rate limiting | Redis 7 |
| Messaging | NATS 2 (core pub/sub) |
| Object storage | MinIO (S3-compatible) for media |
| Auth | JWT (HS256, access/refresh) + Google OAuth2 |
| Real-time | WebSockets (gorilla/websocket) |
| Migrations | golang-migrate v4 |
| Metrics | Prometheus + Grafana |
| Gateway | Nginx |
| Orchestration | Docker Compose |
┌─────────────┐
:8080 │ Nginx │ (single entry point)
client ───────────────▶ gateway │
└──────┬──────┘
┌───────────────┬───────┼───────┬───────────────┐
▼ ▼ ▼ ▼ ▼
┌─────────┐ ┌──────────┐ ... ┌─────────┐ ┌──────────────┐
│ auth │ │ post │ │ feed │ │ notification │
│ :8081 │ │ :8082 │ │ :8085 │ │ :8086 │
└────┬────┘ └────┬─────┘ └────┬────┘ └──────┬───────┘
│ │ ▲ ▲
│ publish │ publish │ subscribe │ subscribe
└─────────────┴──────────▶ ┌────┴───────────────┴────┐
│ NATS │
└─────────────────────────┘
each service ──▶ its own PostgreSQL │ Redis │ MinIO │ (shared infra)
| Service | Port | Responsibility |
|---|---|---|
| auth | 8081 | Registration, login, JWT refresh, Google OAuth2, user profiles, follow graph. Publishes user.*. |
| post | 8082 | Posts (CRUD), comments, likes, tags + tag search. Publishes post.*, comment.created. |
| media | 8087 | Authenticated image, video and audio upload to MinIO with per-type MIME/size validation (images ≤10 MiB, video ≤100 MiB, audio ≤20 MiB). |
| chat | 8083 | Direct (deduped per pair) & group chats with text + up to 3 image/voice-note attachments per message, chat-list previews with unread counts, read receipts, and real-time WebSocket delivery (message/typing/presence/read). Publishes chat.message.sent. |
| feed | 8085 | Ranked feed (global popular+recent, strongly boosting followed authors) + hobby-based suggestions, built entirely from local projections fed by NATS. |
| notification | 8086 | Turns events into notifications (REST history + live WebSocket push). |
| marketplace | 8089 | Buyer-first ("reverse") marketplace: buyers post requests, sellers submit offers, buyer accepts + marks done, both leave reviews. Own DB; chat/notifications reuse existing services. Publishes offer.*, request.fulfilled, review.created. |
| ai | 8088 | Stateless LLM gateway owning the host Ollama. Serializes inference through a priority queue + single worker. Consumes ai.request, replies on the caller's result_subject. First consumer: post auto-tagging — a fast vision model (qwen3.5:0.8b) captions the image, then the smart model (gemma4:e4b) turns text + caption into tags (text-only, fast). Best-effort — being down never blocks callers. |
NATS subjects are the contract between services. Core pub/sub is used (not JetStream); publishes are best-effort and never block a user request.
| Subject | Published by | Consumed by | Purpose |
|---|---|---|---|
user.created / user.updated |
auth | feed | mirror user identity + hobbies |
user.followed / user.unfollowed |
auth | feed, notification | follow graph + "new follower" notice |
post.created |
post | feed | denormalize post into the timeline |
post.liked / post.unliked |
post | feed, notification | like counters + "liked your post" notice |
comment.created |
post | feed, notification | comment counters + "commented" notice |
chat.message.sent |
chat | notification | "sent you a message" notice |
offer.created / offer.accepted / offer.rejected |
marketplace | notification | "new offer" / "offer accepted" / "offer declined" notice |
request.fulfilled / review.created |
marketplace | notification | "leave a review" / "review received" notice |
ai.request |
any service | ai | generic LLM job (task, input, media_ids, result_subject, priority) |
ai.result.post.tag |
ai | post | auto-tag result (ok, output.tags, optional output.embedding); post-service applies tags + stores embedding when present |
Two consequences worth understanding:
- The Feed service owns projection tables (
feed_users,feed_follows,feed_posts,feed_likes) that mirror just enough data to render and rank a feed. It answersGET /api/feedpurely from its own database — no cross-service calls at read time. The defaultfor_youscope ranks every post byfollow_boost + log-scaled engagement − recency decay, so a user who follows nobody still sees popular/recent content while followed authors are strongly prioritized;?scope=followingrestricts to own + followed authors, newest first. - The Notification service subscribes directly to the source events and composes the human-readable message itself; there is no central "notification.create" producer.
- Core NATS is at-most-once. Fresh projection databases need a backfill/reseed operation from service-owned APIs or seed tooling; do not fix this with JetStream or cross-DB feed queries.
SocialPlatform/
├── docker-compose.yml # full stack (datastores, infra, 7 services, gateway)
├── docker-compose.dev.yml # overlay: publishes service & infra ports to host
├── nginx/nginx.conf # gateway routing (REST + WebSocket)
├── prometheus.yml # scrape config (all services on :9091)
├── .env.example # configuration template
├── go.mod / go.sum # single Go module: "socialplatform"
├── pkg/ # shared libraries (no business logic)
│ ├── config/ # env helpers (GetEnv / GetEnvInt / GetEnvDuration)
│ ├── db/ # pgx pool + DSN + connect-with-retry
│ ├── httputil/ # responses, errors, middleware, rate limiter, health
│ ├── jwt/ # token manager (access/refresh token types)
│ ├── nats/ # core pub/sub client
│ └── metrics/ # Prometheus middleware + /metrics server
├── services/<name>-service/
│ ├── main.go # wiring: config, DB, migrations, routes, shutdown
│ ├── Dockerfile # multi-stage build (built from repo root context)
│ ├── migrations/ # golang-migrate SQL
│ └── internal/<domain>/ # model, repository/service, handler (+ tests)
└── docs/
├── api/api_spec.md # canonical REST + WebSocket contract (frontend source of truth)
├── api/swagger.yaml # OpenAPI 3.0 artifact (secondary)
└── plans/ # the implementation plan this repo was built from
- Docker and Docker Compose v2
- Go 1.22.x (only needed for running tests/builds outside containers)
# 1. Create your local environment file
cp .env.example .env
# 2. Build images and start the full stack
docker compose up -d --build
# 3. Wait for everything to report healthy
docker compose psThe stack uses health checks and readiness gating: application services wait for their PostgreSQL / Redis / NATS dependencies to be healthy, and Nginx waits for all services. Once docker compose ps shows everything healthy, the API is live at http://localhost:8080.
A quick smoke test:
# Register a user (returns the user plus access/refresh tokens)
curl -s -X POST localhost:8080/api/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"a@example.com","username":"alice","password":"pass1234"}'
# Health of any service through its container
docker compose exec auth-service wget -qO- http://localhost:8081/healthVerify the headline features end-to-end (all through the gateway at :8080):
# Save a token from register/login, then:
TOKEN=<access_token>
# Ranked feed — content appears even before you follow anyone
curl -s localhost:8080/api/feed -H "Authorization: Bearer $TOKEN"
curl -s "localhost:8080/api/feed?scope=following" -H "Authorization: Bearer $TOKEN"
# Chat list with last-message preview + unread counts
curl -s localhost:8080/api/chats -H "Authorization: Bearer $TOKEN"
# Upload an image or a video (gateway allows up to 100 MiB)
curl -s -X POST localhost:8080/api/media \
-H "Authorization: Bearer $TOKEN" -F file=@clip.mp4
# Live channels (token in the query string, not a header):
# ws://localhost:8080/ws/chat/<chatID>?token=$TOKEN
# ws://localhost:8080/ws/notifications?token=$TOKENSee docs/api/api_spec.md for every request/response shape.
Tear everything down (containers + volumes are removed with -v):
docker compose down # stop & remove containers
docker compose down -v # also delete database/MinIO volumesAll configuration lives in .env (copied from .env.example). Key groups:
| Variable group | Example | Notes |
|---|---|---|
POSTGRES_USER / POSTGRES_PASSWORD |
socialplatform / changeme |
Shared superuser for every per-service DB. |
*_DB_HOST / *_DB_NAME / *_DB_USER / *_DB_PASSWORD |
AUTH_DB_HOST=postgres-auth |
One block per service. |
JWT_SECRET |
dev-secret-change-in-production |
Change in production. |
JWT_ACCESS_EXPIRY / JWT_REFRESH_EXPIRY |
15m / 168h |
Parsed as Go durations. |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET |
— | Optional; enables Google OAuth2. |
MINIO_* / MEDIA_BUCKET |
minioadmin / social-media |
MinIO credentials & bucket (auto-created). |
NATS_URL |
nats://nats:4222 |
Eventing is best-effort if NATS is down. |
Run a single service against a throwaway PostgreSQL — useful for fast iteration:
# Start a local Postgres
docker run -d --name pg-dev \
-e POSTGRES_USER=socialplatform -e POSTGRES_PASSWORD=changeme \
-e POSTGRES_DB=auth_service -p 5432:5432 postgres:16-alpine
# Point the service at it. MIGRATIONS_PATH must be set when running outside Docker,
# because migrations are resolved relative to the working directory.
export AUTH_DB_HOST=localhost AUTH_DB_PORT=5432 \
AUTH_DB_USER=socialplatform AUTH_DB_PASSWORD=changeme AUTH_DB_NAME=auth_service \
JWT_SECRET=dev MIGRATIONS_PATH=services/auth-service/migrations
go run ./services/auth-serviceMigrations run automatically on startup and are fatal on failure — a service never starts with a missing schema.
To expose service and infrastructure ports on the host (for debugging the full stack), add the dev overlay:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build# Unit tests across the whole module (fast; DB-backed tests are skipped)
go test -short ./...
# With the race detector (as CI runs it)
go test -short -race ./...
# Build everything
go build ./...Repository integration tests are gated on a real database. Provide a DSN to enable them:
export TEST_DB_DSN="postgres://socialplatform:changeme@localhost:5432/auth_service?sslmode=disable"
go test ./services/auth-service/internal/auth/...Every service follows the same testing approach: pure validation and service logic are unit-tested with mocks or nil-pool guards; database access is covered by TEST_DB_DSN-gated integration tests; WebSocket hubs have behavioral tests for register/broadcast/unregister.
| Tool | URL (default) | Notes |
|---|---|---|
| Prometheus | http://localhost:9090 |
Scrapes every service's /metrics on port 9091. |
| Grafana | http://localhost:3000 |
Default login admin / admin. |
Each service exposes custom metrics via a middleware: http_requests_total and http_request_duration_seconds, labelled by service, method, templated route, and status.
| Component | Internal port | Published in dev overlay |
|---|---|---|
| Nginx gateway | 80 | 8080 (always) |
| auth-service | 8081 | 8081 |
| post-service | 8082 | 8082 |
| chat-service | 8083 | 8083 |
| feed-service | 8085 | 8085 |
| notification-service | 8086 | 8086 |
| media-service | 8087 | 8087 |
| marketplace-service | 8089 | 8089 |
| (every service) metrics | 9091 | — |
| Prometheus | 9090 | 9090 |
| Grafana | 3000 | 3000 |
| MinIO (API / console) | 9000 / 9001 | 9000 / 9001 |
| NATS (client / monitor) | 4222 / 8222 | 4222 / 8222 |
The canonical, hand-maintained contract — every REST endpoint plus the full
WebSocket event protocols — lives in docs/api/api_spec.md.
This is the file the frontend builds against. An OpenAPI 3.0 artifact also exists
at docs/api/swagger.yaml (kept in sync with contract changes).
A few conventions:
- All responses are JSON wrapped in a
{ "data": ... }envelope; errors return{ "error": "message" }. - Protected endpoints require
Authorization: Bearer <access_token>. - Demo tokens are currently stored by the SPA in
localStorage; production deployments should move refresh tokens toHttpOnly; Secure; SameSite=Laxcookies, keep access tokens in memory, and enforce CSP. - WebSocket endpoints (
/ws/chat/{chatID},/ws/notifications) authenticate via a?token=<access_token>query parameter, because browsers cannot set headers on the WebSocket handshake. Chat frames are typed envelopes (message/typing/presence/read) — see the API spec.
| Symptom | Likely cause & fix |
|---|---|
| A service container restarts repeatedly | Its database wasn't ready. The built-in connect-retry usually recovers; check docker compose logs <service>. |
relation "..." does not exist |
Migrations didn't run. Outside Docker, set MIGRATIONS_PATH=services/<svc>/migrations. |
go get fails with "requires go >= 1.2x" |
A dependency's latest release needs a newer Go. Pin a 1.22-compatible version (see go.mod). |
Editing nginx/nginx.conf has no effect |
The single-file bind mount keeps the original inode after an overwrite. Run docker compose up -d --force-recreate nginx. |
| Feed/notifications look empty after an action | NATS delivery is asynchronous; allow a moment for projections to update. |
| Feed is empty after recreating its database in an existing stack | Core NATS does not replay old events. Re-run seed/backfill tooling or replay from service-owned exports; do not query another service's database directly. |
A React + TypeScript SPA (services/frontend-service/) is implemented and served through the Nginx gateway at the site root, consuming every service — auth, profiles, follow graph, posts/comments/likes/tags, media, real-time chat, feed + suggestions, and live notifications (REST + WebSocket).
Built with Vite, Tailwind CSS, and shadcn/ui (Radix primitives). The current UI uses a warm editorial shell; older cinematic-dark docs are historical and should not override the implemented component structure.
Redesign architecture
- Responsive app shell (
src/components/layout/) —Header,Sidebar(≥md), mobileBottomNav, and an inlineRightRail(≥lg) hidden on chat routes. - Warm editorial visual language — HSL CSS-variable design tokens, restrained borders/shadows, compact cards, and
framer-motionentrance/press animations (calmed underprefers-reduced-motionviaMotionConfig reducedMotion="user"). - Server-state via React Query (
src/api/{queries,mutations}.ts) — infinite-scroll feeds/comments/notifications, optimistic like / follow / join / comment / mark-read, and live WebSocket messages/notifications merged straight into the query cache (deduped by id) with unread-badge invalidation. - Auth — email/password plus "Continue with Google" (
/api/auth/googleredirect →/auth/callbacktoken hydration); auth routes render outside the shell.
Theming — next-themes + CSS-variable tokens
- Provider
src/components/ThemeProvider.tsx(attribute="class",defaultTheme="dark",enableSystem) with themes Light, Dark, System, Emerald, Rose. Every color is an HSL CSS variable insrc/index.cssunder:root/.dark/.emerald/.rose, exposed as Tailwind semantic utilities. The theme switcher lives inline in the Sidebar user menu. - Rule: components use semantic token utilities only (
bg-card,text-muted-foreground, …); never hardcode colors, so all themes work without touching components.
Internationalization — react-i18next + RTL
- Config
src/i18n/index.ts(i18next + language detector,fallbackLng:'en'). Localessrc/i18n/locales/{en,fr,ar}.jsonare kept key-for-key in sync.applyDirection()flips<html dir="rtl">for Arabic on load and on language change; layouts use logical Tailwind utilities (start/end,ps/pe,ms/me) so the sidebar, right rail, and bubbles mirror correctly in RTL. The 🌐 switcher is also in the Sidebar user menu. - Rule: no hardcoded user-facing strings — every label/placeholder/message is a
t('ns.key')present in all locale files.
The original design + backend-contract document lives at docs/plans/plan_design.md. The frontend quality gate is npx tsc --noEmit + npm run build (plus npm test for the vitest suite), run from services/frontend-service/. With the stack running, open http://localhost:8080. For local UI development: cd services/frontend-service && npm install && npm run dev (Vite proxies /api and /ws to :8080).
Working on this repository as an AI agent? Read AGENTS.md first — it documents the non-negotiable architectural rules and the development workflow this codebase was built with. For the upcoming UI work, also read docs/plans/plan_design.md.