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 --> CHDirectory Map
| Directory | Responsibility | Example files |
|---|---|---|
cmd/server | Application entry point | main.go — init config, DB, services, router |
internal/router | HTTP routing, middleware stack | Gin setup, CORS, OpenTelemetry, auth middleware registration |
internal/handler | HTTP request/response handling | link_handler.go, auth_handler.go, billing_handler.go |
internal/service | Business rules, orchestration, external calls | link_service.go, analytics_service.go, kv_sync_service.go |
internal/repository | Data access abstractions | link_repository.go, user_repository.go (pgx/pgxpool) |
internal/database | Connection management | Postgres pool, Redis pool, ClickHouse HTTP client |
internal/middleware | Reusable middleware | Auth (JWT, API key, session), rate limiting |
internal/models | Domain models + DTOs | user.go, link.go, achievement.go with validation tags |
internal/config | Environment configuration | config.go — godotenv, validation, env helpers |
internal/scoring | Gamification algorithms | pawprintz.go, xp.go |
internal/telemetry | Observability | OpenTelemetry init, OTLP gRPC exporter |
migrations/ | Schema evolution | Embedded SQL files, golang-migrate |
Dependency Flow Rules
- Handlers may only call services (never repositories directly)
- Services may call multiple repositories and other services
- Repositories may only interact with database wrappers
- No layer may skip — a handler cannot talk to the database directly
- Dependency injection is manual (no DI framework) —
main.goconstructs 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
otelginmiddleware 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
- IRIS Ecosystem Overview explaining IRIS to a new teammate