Techadamia Backend Documentation

Techadamia Backend - Architecture Guide

Guide to the system design, layering, request lifecycle, and cross-cutting concerns. This document reflects the implementation as verified against the source tree.

Note

At a glance: a layered Go service — api (Gin handlers) → service (business logic, transactions) → sql (sqlc-generated queries) over PostgreSQL. Sessions live in Postgres; Redis is optional (cross-instance rate limiting only). Configuration is validated and migrations run automatically at startup.

Table of Contents

  1. System Overview
  2. Clean-Architecture Layering
  3. Entry Point & Startup
  4. Middleware Chain
  5. Auth, Session & CSRF Model
  6. Rate Limiting
  7. Response DTO Layer
  8. Request Lifecycle
  9. Database Design
  10. Migrations
  11. Admin Guards
  12. Configuration
  13. Graceful Shutdown
  14. Observability

System Overview

Techadamia Backend is a REST API service for a content/blog platform. It manages users (registration, authentication, roles, status lifecycle), posts (create, update, archive, soft-delete with automatic slug generation), categories, media uploads, and sessions.

Technology Stack

Concern Technology
HTTP framework Gin
Language Go
Database PostgreSQL (via pgx / pgxpool)
Query layer sqlc-generated, type-safe queries
Optional rate-limit store Redis (go-redis/v9)
Password hashing bcrypt (golang.org/x/crypto/bcrypt, default cost)
Full-text search PostgreSQL FTS (turkish config, stored generated tsvector + GIN)
Realtime push Server-Sent Events (internal/realtime in-process hub)
Transactional email SMTP via net/smtp (internal/mail; optional — dev fallback logs)
Logging log/slog (JSON or Text)
Config environment variables (optionally loaded from .env via godotenv)

Redis is optional: it is only used for cross-instance rate limiting and the readiness probe when RATE_LIMIT_REDIS_URL is set. Sessions live in Postgres, not Redis.


Clean-Architecture Layering

The codebase follows a clean-architecture layering where dependencies point inward and each layer has a single responsibility:

cmd (main)                       wiring, config, server lifecycle
   │
   ▼
internal/api                     Gin handlers, routing, request validation,
   │                             response DTOs
   ▼
internal/service                 business logic, transactions, bcrypt,
   │                             session management
   ▼
internal/sql                     sqlc-generated queries & models over a
   │                             *pgxpool.Pool
   ▼
PostgreSQL

Cross-cutting packages sit beside the chain:

  • internal/middleware - request ID, access logging, body-size cap, security headers, CORS, CSRF, auth/role guards (including optional auth), rate limiting.
  • internal/utils - config parsing (ParseCSV, ParseRateLimitSpec), the request-ID slog handler, CSRF token generation, slug generation.
  • internal/migrate - embedded SQL migrations runner.
  • internal/realtime - in-process SSE hub. The service publishes notification/message events through it (via the service.Notifier interface); the GET /events handler subscribes per user. Delivery is best-effort — every pushed event also exists as a database row.
  • internal/mail - SMTP mailer for password reset and email verification. With SMTP_HOST unset it is disabled: links are logged and readers activate without verification (development mode).

Wiring happens in cmd/main.go:

services := service.NewService(pool)      // service depends on the pool
handlers := api.NewAPIHandler(services)    // api depends on the service
router := api.NewRouter(handlers)          // router depends on handlers
router.RegisterRoutes()

The service holds both the *pgxpool.Pool (for transactions via db.Begin) and a *sql.Queries built with sql.New(db).


Entry Point & Startup

cmd/main.go (package main) performs startup in this order:

  1. healthcheck subcommand. If invoked as ./server healthcheck, it skips all normal startup, probes http://127.0.0.1:$APP_PORT/health, and exits 0 on a 200 response or 1 otherwise. This backs the container HEALTHCHECK, since the runtime image has no shell or curl.
  2. Load .env with godotenv.Load() (optional; a missing file only logs a warning).
  3. Configure slog. A concrete stderr handler is created first (slog.NewTextHandler when APP_ENV=development, otherwise slog.NewJSONHandler), then wrapped in utils.NewRequestIDHandler. The base handler is deliberately a concrete stderr handler and not slog.Default().Handler(): the default handler bridges to the standard log package and slog.SetDefault rewires log back through the new default, so wrapping it would create a Handle -> log -> Handle recursion that self-deadlocks on the log.Logger mutex on the first log call.
  4. Validate configuration from the environment (loadConfig). Invalid config exits with status 1. This also pre-validates CORS, every rate-limit spec, the Redis URL, media limits/MIME types, trusted proxies, and HSTS settings, so misconfiguration fails fast at boot.
  5. Build the connection pool (newDBPool) and Ping it.
  6. Run migrations (migrate.Run).
  7. Instantiate service, handlers, and router; register routes.
  8. Start background jobs: purge expired sessions once, then start the periodic session-cleanup goroutine and the orphan-media GC goroutine (startMediaGC, governed by MEDIA_GC_INTERVAL / MEDIA_ORPHAN_GRACE).
  9. Mark the handler ready (handlers.SetReady(true)) so /ready flips to ready.
  10. Serve HTTP and block until graceful shutdown.

newDBPool tuning

The pool is parsed from DATABASE_URL and then tuned; every value is env-overridable:

Setting Default Env override
MaxConns 20 DB_MAX_CONNS
MinConns 2 DB_MIN_CONNS
MaxConnLifetime 1h DB_MAX_CONN_LIFETIME
MaxConnIdleTime 30m DB_MAX_CONN_IDLE_TIME
HealthCheckPeriod 1m DB_HEALTH_CHECK_PERIOD

MaxConnLifetimeJitter is set to 10% of MaxConnLifetime to avoid synchronized connection churn. Bounded lifetimes prevent stale connections behind load balancers; the health check prunes dead ones.

HTTP server timeouts

srv := &http.Server{
    Addr:              ":" + config.AppPort,
    Handler:           router.Router,
    ReadHeaderTimeout: 10 * time.Second,
    ReadTimeout:       30 * time.Second,
    WriteTimeout:      30 * time.Second,
    IdleTimeout:       120 * time.Second,
    MaxHeaderBytes:    1 << 20, // 1 MiB
}

Session cleanup

startSessionCleanup runs a ticker goroutine (interval = SESSION_CLEANUP_INTERVAL, default 1h) that calls Service.DeleteExpiredSessions and Service.DeleteExpiredAuthTokens (expired password-reset and email-verification tokens share the sweep). It stops when the root context is cancelled.


Middleware Chain

Middleware is registered in api/routes.go. The global chain runs in this order for every request:

  1. gin.Recovery() - panic recovery (registered in NewRouter).
  2. RequestIDMiddleware - reads X-Request-Id or generates a UUID, stores it in the request context (so slog picks it up via the request-ID handler), and echoes it back in the X-Request-Id response header.
  3. AccessLogMiddleware - logs method, path, status, latency, and client IP after the handler completes.
  4. MaxBodyBytesMiddleware(maxRequestBodyBytes(), "/media") - caps request bodies (default 1 MiB, override MAX_REQUEST_BODY_BYTES) for POST/PUT/PATCH/DELETE. The /media route pattern is exempt so it can apply its own, larger limit (MEDIA_MAX_BYTES).
  5. SecurityHeadersMiddleware - X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy, Permissions-Policy, optional HSTS and CSP from env.
  6. CORSMiddleware(corsConfig) - allowlist of origins. Unknown origins get no CORS headers; an unknown-origin preflight (OPTIONS) is rejected with 403.
  7. CSRFMiddleware - double-submit CSRF check (see below).

Then routes are organized into groups with additional per-group middleware:

  • Public group (/): no auth. Endpoints get their own rate limiter where relevant (/register and /admin/bootstrap use the register/login limiter, /login uses the login limiter).
  • Protected group: RequireAuthMiddleware validates the session and requires user_status = active.
    • Author sub-group: RequireAuthorMiddleware (allows author or admin) plus the write rate limiter; /media additionally applies the media limiter.
    • Admin sub-group: RequireAdminMiddleware (requires admin) plus the admin rate limiter.

Routes

Method Path Group Notes
POST /register public register limiter; role=author|reader
POST /login public login limiter
POST /password-reset/request · /password-reset/confirm public login limiter
POST /verify-email · /verify-email/resend public login limiter
POST /admin/bootstrap public login limiter, X-Bootstrap-Token
GET /health · /ready public liveness / readiness (DB + optional Redis)
GET /recent public recent published posts (+category/author/sort filters)
GET /search public full-text search
GET /posts/:slug public published post by slug (optional auth → liked_by_me)
GET /posts/:slug/comments public comment list
GET /categories public list categories
GET /users/:user (+/posts /followers /following) public public profiles (optional auth → following)
GET /media/:key public media limiter
POST /logout protected
GET /me · /me/posts protected own profile / own posts incl. drafts
PUT /me protected write limiter
PUT /me/password protected login limiter; rotates sessions
GET /events protected SSE stream (notifications, messages)
GET/PUT /notifications (+/read, /:id/read) protected inbox + read state
POST/PUT/DELETE /posts/:slug/comments, /comments/:id protected write limiter
PUT/DELETE /posts/:slug/like protected write limiter, idempotent
PUT/DELETE /users/:user/follow protected write limiter, idempotent
GET/POST/PUT /messages (+/:user, /:user/read) protected DMs; write limiter on send
POST /media protected media limiter (avatars too)
POST /posts author write limiter
PUT /posts/:slug author write limiter
DELETE /posts/:slug author write limiter
GET /users · /users/pending admin admin limiter
DELETE /users/:user admin admin limiter
PUT /users/:user/status admin admin limiter; activation notifies the user
PUT /posts/archive/:id admin admin limiter
POST /categories admin admin limiter
PUT /comments/:id/hide admin admin limiter (moderation)

The /users path parameter is named :user everywhere because gin requires one parameter name per path position; public routes interpret it as a username, admin routes as a user UUID.


Auth, Session & CSRF Model

Roles and status

  • user_role_enum: admin, author, reader.
  • user_status_enum: pending, active, suspended, deleted.

Self-service registration creates an author in pending status by default (an admin activates via PUT /users/:user/status; active admins are notified of the new pending account). With role=reader, the account activates through email verification — or immediately when SMTP is not configured. Readers get the social surface (comments, likes, follows, messages, avatar upload) but cannot author posts.

Login

Service.Login (internal/service/auth.go):

  1. Look up the user by email. On pgx.ErrNoRows it still runs a bcrypt compare against a pre-computed dummy hash so a "user not found" response takes the same time as "wrong password", returning ErrInvalidCredentials either way.
  2. bcrypt.CompareHashAndPassword against the stored hash.
  3. Reject non-active users with ErrUserNotActive.
  4. In a single transaction: DeleteAllUserSessions (so login is single-session per user), AddSession (UUID v4 session id, TTL 7 days), and UpdateLastLogin.

The handler then sets two cookies:

  • session_id - HttpOnly, Max-Age = session TTL, Secure/SameSite from config.
  • csrf_token - readable by JS (not HttpOnly), same lifetime, for the double-submit pattern.

It responds with a LoginResponse (user_id, username, user_role).

Session validation

RequireAuthMiddleware extracts the session id from either the session_id cookie or an Authorization: Bearer <session_id> header, parses it as a UUID, and calls Service.GetSessionAndUser (a join that also enforces expires_at > NOW()). It rejects non-active users with 403, then stores user_id, user_role, user_status, and session_id in the Gin context for downstream handlers.

CSRF

CSRFMiddleware implements double-submit:

  • Safe methods (GET, HEAD, OPTIONS) skip the check.
  • Bearer-authenticated requests skip CSRF (no ambient cookie credential).
  • For cookie-authenticated state-changing requests, it requires a csrf_token cookie and a matching X-CSRF-Token header, compared with crypto/subtle.ConstantTimeCompare. A missing or mismatched token yields 403.
  • If no session_id cookie is present, the check is skipped (nothing to protect).

Rate Limiting

Rate limiting is a fixed-window counter keyed by client IP. Both implementations satisfy the middleware.Limiter interface:

type Limiter interface {
    Allow(ctx context.Context, key string) (remaining int, retryAfter time.Duration, allowed bool, err error)
    Limit() int
}
  • In-memory (middleware.RateLimiter, the default): a mutex-guarded map of per-key counters with periodic cleanup of expired entries. Per-instance only.
  • Redis (middleware.RedisRateLimiter): used when RATE_LIMIT_REDIS_URL is set. A small Lua script does INCR then PEXPIRE on first hit and reads PTTL, so the window is shared across all instances.

buildLimiter in routes.go picks the Redis limiter when a Redis client is configured, otherwise the in-memory one. Limits are parsed from env specs of the form count/duration (e.g. 10/1m); defaults are login 10/1m, register 10/1m, media 30/1m, admin 60/1m, write 60/1m.

RateLimitMiddleware always sets X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset. On a denied request it adds Retry-After and returns 429. If the limiter itself errors (e.g. Redis down) it returns 503.


Response DTO Layer

internal/api/responses.go defines the JSON response shapes and the converters that map sqlc rows into them. Handlers always return these DTOs, never raw sqlc rows, so the wire format is stable and clean.

The DTOs use uuid.UUID and *time.Time so JSON renders a UUID string and an RFC3339 timestamp (or is omitted), rather than pgtype's struct representation. Two converters bridge the gap:

func toUUID(p pgtype.UUID) uuid.UUID      // pgtype.UUID -> uuid string (uuid.Nil if invalid)
func toTime(t pgtype.Timestamptz) *time.Time // pgtype.Timestamptz -> *time.Time (nil if invalid)

DTO types: UserResponse, PostResponse, CategoryResponse, MediaResponse, LoginResponse, and PaginationMeta (limit, offset, total omitempty, count). Row-specific converters include userFromListRow, userFromPendingRow, categoryFromRow/categoriesFromRows, postFromRow/postsFromRows, postFromBySlug (which also unmarshals the JSON-aggregated media array from the GetPostBySlug query), and postFromBySlugAny.


Request Lifecycle

Login (POST /login)

Request
  → RequestID → AccessLog → MaxBodyBytes → SecurityHeaders → CORS → CSRF
  → login rate limiter
  → Handler.Login: read email/password form fields
  → Service.Login:
        bcrypt verify (dummy hash on unknown email to flatten timing)
        tx { DeleteAllUserSessions; AddSession (7d TTL); UpdateLastLogin }
  → set session_id (HttpOnly) + csrf_token cookies
  → 200 OK with LoginResponse

Create post (POST /posts)

Request
  → global chain (CSRF enforced for cookie auth)
  → RequireAuth (active session) → RequireAuthor (author|admin) → write limiter
  → Handler.CreatePost:
        read title/md_content/status, validate via ValidateCreatePostParams
        parse + dedupe category_ids / media_ids from the form
  → Service.CreatePost (single tx):
        CreatePost (slug auto-generated from title via utils.GenerateSlug; the
            DB trigger also dedupes/stamps)
        LinkPostUser (author_role = owner)
        validate + LinkPostCategories (CountCategoriesByIDs guards invalid ids)
        validate + LinkPostMediaBulk (CountMediaByUser guards ownership)
  → 201 Created with post_id

Database Design

Schema lives in internal/sql/schema.sql; sqlc generates models in internal/sql/models.go and queries in internal/sql/query.sql.go.

Tables

  • users - user_id (UUID PK), username/email (unique), user_role, user_status (default pending), password_hash, optional phone_number, profile fields display_name/bio/avatar_media_id (FK to media, ON DELETE SET NULL), email_verified_at, created_at, last_login_at, deleted_at.
  • posts - post_id (UUID PK), title, slug (unique), md_content, status (default draft), published_at, updated_at, archived_at, deleted_at, and search_vector — a stored generated tsvector (turkish config, title weighted A over body B) with a GIN index backing GET /search.
  • media - media_id (UUID PK), user_id (FK, ON DELETE CASCADE), storage_key (unique), mime_type, created_at. Media referenced as a user avatar is exempt from the orphan-GC sweep.
  • categories - category_id (UUID PK), name, slug (unique).
  • users_posts - join of users/posts with author_role (owner | contributor), PK (user_id, post_id).
  • posts_media - join of posts/media, PK (post_id, media_id).
  • posts_categories - join of posts/categories, PK (post_id, category_id).
  • sessions - session_id (UUID PK), user_id (FK, ON DELETE CASCADE), created_at, expires_at; index idx_sessions_user_id.
  • password_reset_tokens / email_verification_tokens - token_hash (SHA-256 hex, PK), user_id (FK, cascade), expires_at, created_at. Only hashes are stored; expired rows are purged by the cleanup sweep.
  • comments - comment_id (UUID PK), post_id/user_id (FKs, cascade), nullable parent_comment_id (self-FK, cascade — threading), body, status (visible/hidden/deleted), is_ai (reserved for labelled AI-generated comments), created_at, edited_at.
  • likes - PK (user_id, post_id), created_at; idempotent via ON CONFLICT DO NOTHING.
  • follows - PK (follower_id, followee_id), created_at, CHECK against self-follows.
  • notifications - notification_id (UUID PK), recipient user_id (cascade), nullable actor_id (SET NULL), type, nullable post_id/comment_id (cascade), read_at, created_at; partial index on unread rows.
  • messages - message_id (UUID PK), sender_id/recipient_id (FKs, cascade, CHECK against self-messages), body, created_at, read_at. Conversations are derived (latest message per peer), not stored.

Enums: user_role_enum (admin/author/reader), user_status_enum, post_status_enum (draft/published/archived/deleted), author_role_enum, comment_status_enum (visible/hidden/deleted), notification_type_enum (comment/reply/like/follow/new_post/ new_pending_user/account_activated).

Soft deletes

Deletes are soft: users and posts move to the deleted status with a deleted_at timestamp rather than being removed, which keeps related content intact. Sessions, however, are hard-deleted on logout/expiry.

Post automation trigger

A BEFORE INSERT OR UPDATE trigger trg_posts_automation runs process_post_automation() which:

  • auto-generates a unique slug from the title when the incoming slug is null or blank (appending a numeric suffix on collision);
  • stamps published_at the first time a post enters published;
  • bumps updated_at when md_content changes on update.

Migrations

internal/migrate embeds migrations/*.sql with //go:embed and runs them on startup (migrate.Run):

  1. Acquire one pooled connection and hold it for the whole run.
  2. Take a session-level pg_advisory_lock on a fixed app key so only one process applies migrations at a time. This makes simultaneous replica boot safe. The lock is released (pg_advisory_unlock) on return.
  3. Ensure a schema_migrations(version, applied_at) table exists and load already-applied versions.
  4. List embedded files, sort by filename, and apply each unapplied one in order, recording its version.

Each migration runs inside a transaction unless its file contains the marker -- +no-transaction, in which case it runs outside one (needed for ALTER TYPE ... ADD VALUE, which cannot run in a transaction block).

Migration files:

  • 000_initial_schema.sql - idempotent base schema + automation trigger.
  • 001_add_deleted_status.sql - adds the deleted status value.
  • 002_fix_post_automation_trigger.sql - trigger fix.
  • 003_streamline_post_trigger.sql - trigger simplification.
  • 004_add_reader_role.sql - adds the reader role value (no-transaction).
  • 005_profiles_and_auth_tokens.sql - profile columns + reset/verification token tables.
  • 006_social.sql - comments, likes, follows, notifications.
  • 007_messages.sql - direct messages.
  • 008_post_search.sql - search_vector generated column + GIN index.

Admin Guards

  • Bootstrap (Service.BootstrapAdmin): creates the very first admin only if none exists. The CountAdmins check and the insert run in one transaction, so concurrent bootstrap attempts cannot both succeed; the new admin is activated immediately. The handler additionally requires a X-Bootstrap-Token matching BOOTSTRAP_TOKEN (constant-time compare). Once an admin exists it returns 409 (ErrAdminAlreadyExists).
  • Last-admin protection (guardLastAdmin / ErrLastAdmin): deleting or deactivating (any non-active status) an admin is rejected with 409 if it would leave zero active admins. Used by both DeleteUser and UpdateUserStatus.
  • Self-target guard: in the admin handlers an admin cannot delete or change the status of their own account (returns 400).

Deactivating a user (status other than active) also deletes that user's sessions in the same transaction.


Configuration

All configuration comes from environment variables (optionally seeded from .env). Validation happens at startup. Notable variables:

Variable Purpose
DATABASE_URL Postgres DSN (required)
APP_PORT listen port (required)
APP_ENV development toggles Text logs; production forces secure cookie
MEDIA_STORAGE_DIR upload directory (required, must be writable)
SESSION_COOKIE_SECURE required boolean; must be true in production
SESSION_COOKIE_SAMESITE lax/strict/none (none requires secure)
SESSION_COOKIE_DOMAIN cookie domain
SESSION_CLEANUP_INTERVAL expired-session purge interval (default 1h)
CORS_ALLOWED_ORIGINS required allowlist; wildcard rejected with credentials
CORS_ALLOWED_METHODS / CORS_ALLOWED_HEADERS / CORS_ALLOW_CREDENTIALS CORS tuning
RATE_LIMIT_LOGIN / _REGISTER / _MEDIA / _ADMIN / _WRITE count/duration specs
RATE_LIMIT_REDIS_URL switches limiter to Redis when set
MAX_REQUEST_BODY_BYTES global body cap (default 1 MiB)
MEDIA_MAX_BYTES / MEDIA_ALLOWED_MIME media upload limits
TRUSTED_PROXIES CSV of IPs/CIDRs for client-IP resolution
SMTP_HOST / SMTP_PORT / SMTP_USERNAME / SMTP_PASSWORD / SMTP_FROM transactional email; unset host = dev mode (links logged, readers auto-activate)
APP_PUBLIC_URL base URL for emailed links (defaults to the first CORS origin)
HSTS_MAX_AGE / HSTS_INCLUDE_SUBDOMAINS / HSTS_PRELOAD HSTS header
REFERRER_POLICY / PERMISSIONS_POLICY / CONTENT_SECURITY_POLICY security headers
BOOTSTRAP_TOKEN enables /admin/bootstrap
DB_MAX_CONNS / DB_MIN_CONNS / DB_MAX_CONN_LIFETIME / DB_MAX_CONN_IDLE_TIME / DB_HEALTH_CHECK_PERIOD pool tuning

Graceful Shutdown

main runs under signal.NotifyContext for SIGINT/SIGTERM. A goroutine waits on context cancellation and then calls srv.Shutdown with a 10-second timeout, letting in-flight requests drain before the process exits. The pgxpool.Pool is closed via defer.


Observability

  • Structured logging via slog (JSON in non-development, Text in development), written to stderr.
  • Request correlation: RequestIDMiddleware plus the utils.RequestIDHandler slog wrapper attach a request_id attribute to every log emitted within a request's context.
  • Access logs: AccessLogMiddleware logs method, path, status, latency, and client IP per request.
  • Health/readiness: GET /health is a static liveness check; GET /ready reports starting until the handler is marked ready, then pings the database (and Redis, if configured).

Last Updated: 2026-07-01

Generated from ARCHITECTURE.md on 2026-08-17 23:37 CEST. Edit the Markdown source and re-run task docs to update.