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
- System Overview
- Clean-Architecture Layering
- Entry Point & Startup
- Middleware Chain
- Auth, Session & CSRF Model
- Rate Limiting
- Response DTO Layer
- Request Lifecycle
- Database Design
- Migrations
- Admin Guards
- Configuration
- Graceful Shutdown
- 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 theservice.Notifierinterface); theGET /eventshandler 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. WithSMTP_HOSTunset 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:
healthchecksubcommand. If invoked as./server healthcheck, it skips all normal startup, probeshttp://127.0.0.1:$APP_PORT/health, and exits 0 on a 200 response or 1 otherwise. This backs the containerHEALTHCHECK, since the runtime image has no shell or curl.- Load
.envwithgodotenv.Load()(optional; a missing file only logs a warning). - Configure
slog. A concrete stderr handler is created first (slog.NewTextHandlerwhenAPP_ENV=development, otherwiseslog.NewJSONHandler), then wrapped inutils.NewRequestIDHandler. The base handler is deliberately a concrete stderr handler and notslog.Default().Handler(): the default handler bridges to the standardlogpackage andslog.SetDefaultrewireslogback through the new default, so wrapping it would create aHandle -> log -> Handlerecursion that self-deadlocks on thelog.Loggermutex on the first log call. - 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. - Build the connection pool (
newDBPool) andPingit. - Run migrations (
migrate.Run). - Instantiate service, handlers, and router; register routes.
- Start background jobs: purge expired sessions once, then start the
periodic session-cleanup goroutine and the orphan-media GC goroutine
(
startMediaGC, governed byMEDIA_GC_INTERVAL/MEDIA_ORPHAN_GRACE). - Mark the handler ready (
handlers.SetReady(true)) so/readyflips toready. - 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:
gin.Recovery()- panic recovery (registered inNewRouter).RequestIDMiddleware- readsX-Request-Idor generates a UUID, stores it in the request context (soslogpicks it up via the request-ID handler), and echoes it back in theX-Request-Idresponse header.AccessLogMiddleware- logs method, path, status, latency, and client IP after the handler completes.MaxBodyBytesMiddleware(maxRequestBodyBytes(), "/media")- caps request bodies (default 1 MiB, overrideMAX_REQUEST_BODY_BYTES) for POST/PUT/PATCH/DELETE. The/mediaroute pattern is exempt so it can apply its own, larger limit (MEDIA_MAX_BYTES).SecurityHeadersMiddleware-X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy,Permissions-Policy, optional HSTS and CSP from env.CORSMiddleware(corsConfig)- allowlist of origins. Unknown origins get no CORS headers; an unknown-origin preflight (OPTIONS) is rejected with 403.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 (/registerand/admin/bootstrapuse the register/login limiter,/loginuses the login limiter). - Protected group:
RequireAuthMiddlewarevalidates the session and requiresuser_status = active.- Author sub-group:
RequireAuthorMiddleware(allowsauthororadmin) plus the write rate limiter;/mediaadditionally applies the media limiter. - Admin sub-group:
RequireAdminMiddleware(requiresadmin) plus the admin rate limiter.
- Author sub-group:
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
/userspath parameter is named:usereverywhere 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):
- Look up the user by email. On
pgx.ErrNoRowsit still runs a bcrypt compare against a pre-computed dummy hash so a "user not found" response takes the same time as "wrong password", returningErrInvalidCredentialseither way. bcrypt.CompareHashAndPasswordagainst the stored hash.- Reject non-
activeusers withErrUserNotActive. - In a single transaction:
DeleteAllUserSessions(so login is single-session per user),AddSession(UUID v4 session id, TTL 7 days), andUpdateLastLogin.
The handler then sets two cookies:
session_id- HttpOnly,Max-Age= session TTL,Secure/SameSitefrom 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_tokencookie and a matchingX-CSRF-Tokenheader, compared withcrypto/subtle.ConstantTimeCompare. A missing or mismatched token yields 403. - If no
session_idcookie 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 whenRATE_LIMIT_REDIS_URLis set. A small Lua script doesINCRthenPEXPIREon first hit and readsPTTL, 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(defaultpending),password_hash, optionalphone_number, profile fieldsdisplay_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(defaultdraft),published_at,updated_at,archived_at,deleted_at, andsearch_vector— a stored generatedtsvector(turkishconfig, title weightedAover bodyB) with a GIN index backingGET /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; indexidx_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), nullableparent_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 viaON CONFLICT DO NOTHING. - follows - PK
(follower_id, followee_id),created_at, CHECK against self-follows. - notifications -
notification_id(UUID PK), recipientuser_id(cascade), nullableactor_id(SET NULL),type, nullablepost_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_atthe first time a post enterspublished; - bumps
updated_atwhenmd_contentchanges on update.
Migrations
internal/migrate embeds migrations/*.sql with //go:embed and runs them on
startup (migrate.Run):
- Acquire one pooled connection and hold it for the whole run.
- Take a session-level
pg_advisory_lockon 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. - Ensure a
schema_migrations(version, applied_at)table exists and load already-applied versions. - 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 thedeletedstatus value.002_fix_post_automation_trigger.sql- trigger fix.003_streamline_post_trigger.sql- trigger simplification.004_add_reader_role.sql- adds thereaderrole 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_vectorgenerated column + GIN index.
Admin Guards
- Bootstrap (
Service.BootstrapAdmin): creates the very first admin only if none exists. TheCountAdminscheck and the insert run in one transaction, so concurrent bootstrap attempts cannot both succeed; the new admin is activated immediately. The handler additionally requires aX-Bootstrap-TokenmatchingBOOTSTRAP_TOKEN(constant-time compare). Once an admin exists it returns 409 (ErrAdminAlreadyExists). - Last-admin protection (
guardLastAdmin/ErrLastAdmin): deleting or deactivating (any non-activestatus) an admin is rejected with 409 if it would leave zero active admins. Used by bothDeleteUserandUpdateUserStatus. - 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:
RequestIDMiddlewareplus theutils.RequestIDHandlerslog wrapper attach arequest_idattribute to every log emitted within a request's context. - Access logs:
AccessLogMiddlewarelogs method, path, status, latency, and client IP per request. - Health/readiness:
GET /healthis a static liveness check;GET /readyreportsstartinguntil the handler is marked ready, then pings the database (and Redis, if configured).
Last Updated: 2026-07-01