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
Source: purr-api cmd/server/main.go:29-414, internal/router/router.go:49-171, directory listing of internal/ and cmd/ (origin/main bb6cfc2), read 2026-10-25. Corrected in v1.1: handler and scoring file names, new packages, middleware order.
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, redirect_handler.go, billing_handler.go, paid_link_handler.go (there is no auth_handler.go; auth is middleware) |
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 client, ClickHouse HTTP client (optional; Postgres click store fallback) |
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 | Link score algorithms | scoring.go (pawprintz, treatz, niblz); XP and level math live in internal/models/user.go and internal/service/xp_service.go |
internal/x402 | Paid anonymous create (x402) | payment.go, facilitator.go, provenance.go (smo1-025) |
internal/billing/toll | TOLL client, not yet called | client.go (smo1-014, smo1-026) |
internal/demoseed, cmd/seed-demo | Demo tenant seeding | plan.go, seed.go (smo1-029) |
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)
Global, in router.go:60-79:
flowchart LR A[CloudflareClientIP] --> B[Recovery] --> C[request logger] --> D[Metrics] --> E["OpenTelemetry, if OTEL endpoint set"] --> F[CORS] F --> G{"route group"} G -->|"/api public"| H[OptionalAuth then handler] G -->|protected| I[RequireAuth] --> J["TierBasedRateLimit, if Redis"] --> K[handler]Then per group:
/api/*:OptionalAuth(router.go:119)- protected group:
RequireAuth→TierBasedRateLimit(only if Redis connected at boot) → handler (router.go:166-172) POST /api/linkswith x402 on:PaidLink.Gate→RequireAuth→ tier limit →CreateLink(router.go:158-164)
Rate limiting runs after auth, because the limit depends on the tier resolveTier reads from users (smo1-004, smo1-011). Public routes such as /api/links/resolve and /api/analytics/track have no origin rate limit.
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: What are the five layers of purr-api, from entry to storage?
A: cmd/server (wiring), internal/router (Gin engine and middleware), internal/handler (HTTP), internal/service (business logic), internal/repository over internal/database (data access), then Postgres, Redis and, if reachable, ClickHouse.
Q: Which layer contains the business logic?
A: internal/service, for example link_service.go, analytics_service.go, billing_service.go and link_service_paid.go.
Q: Where would you add a new HTTP endpoint for link analytics?
A: Register the route in internal/router/router.go (protected group if it needs a user), add the handler method in internal/handler/link_handler.go or dashboard_handler.go, and put the query logic in analytics_service.go behind the ClickStore interface so both the ClickHouse and Postgres stores implement it.
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