CRUMB a card from devarno-cloud

purr-api Layered Architecture

smo1 intermediate 6 min read

ELI5

purr-api is built like a five-storey office building. The ground floor (main.go) is the reception — it opens the doors and turns on the lights. The first floor (router) is the lobby — it directs visitors to the right elevator. The second floor (handler) is the meeting rooms — it talks to visitors and takes their requests. The third floor (service) is the operations team — it makes decisions and does the real work. The basement (repository + database) is the filing cabinets — it stores and retrieves records.

Technical Deep Dive

Layer Stack

%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#e8f4f8', 'primaryTextColor': '#2d3748', 'primaryBorderColor': '#90cdf4', 'lineColor': '#718096', 'secondaryColor': '#f0fff4', 'tertiaryColor': '#fefcbf'}}}%%
flowchart TB
subgraph Entry["Entry Point"]
CMD[cmd/server/main.go]
end
subgraph Transport["Transport Layer"]
RTR[internal/router<br/>Gin engine + middleware]
end
subgraph HTTP["HTTP Layer"]
HND[internal/handler<br/>HTTP handlers]
end
subgraph Business["Business Layer"]
SVC[internal/service<br/>Business logic]
end
subgraph Data["Data Layer"]
REPO[internal/repository<br/>SQL queries]
DB[internal/database<br/>Connection wrappers]
end
subgraph Storage["Storage"]
PG[(PostgreSQL)]
RD[(Redis)]
CH[(ClickHouse)]
end
CMD -->|wire| RTR
RTR -->|route| HND
HND -->|call| SVC
SVC -->|query| REPO
REPO -->|connect| DB
DB --> PG
DB --> RD
DB --> CH

Directory Map

DirectoryResponsibilityExample files
cmd/serverApplication entry pointmain.go — init config, DB, services, router
internal/routerHTTP routing, middleware stackGin setup, CORS, OpenTelemetry, auth middleware registration
internal/handlerHTTP request/response handlinglink_handler.go, auth_handler.go, billing_handler.go
internal/serviceBusiness rules, orchestration, external callslink_service.go, analytics_service.go, kv_sync_service.go
internal/repositoryData access abstractionslink_repository.go, user_repository.go (pgx/pgxpool)
internal/databaseConnection managementPostgres pool, Redis pool, ClickHouse HTTP client
internal/middlewareReusable middlewareAuth (JWT, API key, session), rate limiting
internal/modelsDomain models + DTOsuser.go, link.go, achievement.go with validation tags
internal/configEnvironment configurationconfig.go — godotenv, validation, env helpers
internal/scoringGamification algorithmspawprintz.go, xp.go
internal/telemetryObservabilityOpenTelemetry init, OTLP gRPC exporter
migrations/Schema evolutionEmbedded SQL files, golang-migrate

Dependency Flow Rules

  1. Handlers may only call services (never repositories directly)
  2. Services may call multiple repositories and other services
  3. Repositories may only interact with database wrappers
  4. No layer may skip — a handler cannot talk to the database directly
  5. Dependency injection is manual (no DI framework) — main.go constructs the graph top-down

Middleware Stack (execution order)

Recovery → CORS → OpenTelemetry → Rate Limit → Auth → Handler
  • Recovery — catches panics, returns 500, logs stack trace
  • CORS — explicit allowed origins, exposes rate-limit headers
  • OpenTelemetry — Gin otelgin middleware for trace propagation
  • Rate Limit — Redis sliding window, tier-based (see smo1-011)
  • Auth — Multi-strategy extraction (see smo1-004)

Key Terms

  • Handler → HTTP-specific code that parses requests, calls services, and writes JSON responses
  • Service → Business logic layer; the only place where business rules and external API calls live
  • Repository → Data access layer; abstracts SQL/NoSQL specifics behind Go interfaces
  • Middleware → Reusable request/response interceptors (auth, rate limiting, tracing)
  • Dependency injection → Manual wiring of repositories → services → handlers in main.go
  • golang-migrate → Database migration tool; SQL files are embedded into the binary

Q&A

Q: Why manual dependency injection instead of a framework like Wire? A: The graph is small enough to manage by hand (~15 constructors). Manual wiring is explicit, has zero framework overhead, and compiles faster.

Q: Where would I add a new feature like “link expiration warnings”? A: Business logic in internal/service/link_service.go, triggered from an existing handler or a new scheduled job. Never in the handler or repository.

Q: Can a service call another service? A: Yes. Services are allowed to compose other services. For example, LinkService calls KVSyncService after link mutations.

Q: Why are DTOs in internal/models and not internal/dto? A: The project uses a flat models package for both domain entities and API DTOs. Validation tags (binding:"required", json:"uid") live on the same structs.

Examples

Think of ordering coffee through a delivery app:

  • Router is the app home screen that shows you the “Order Coffee” button
  • Handler is the checkout screen that collects your address and payment
  • Service is the kitchen that decides how to make the drink, checks inventory, and calls the payment processor
  • Repository is the barista who writes the order on a ticket and retrieves beans from the shelf
  • Database is the shop’s stock room and cash register
  • Middleware is the security guard who checks your ID at the door and counts how many people are inside

neighbors on the map