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

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 --> 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, redirect_handler.go, billing_handler.go, paid_link_handler.go (there is no auth_handler.go; auth is middleware)
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 client, ClickHouse HTTP client (optional; Postgres click store fallback)
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/scoringLink score algorithmsscoring.go (pawprintz, treatz, niblz); XP and level math live in internal/models/user.go and internal/service/xp_service.go
internal/x402Paid anonymous create (x402)payment.go, facilitator.go, provenance.go (smo1-025)
internal/billing/tollTOLL client, not yet calledclient.go (smo1-014, smo1-026)
internal/demoseed, cmd/seed-demoDemo tenant seedingplan.go, seed.go (smo1-029)
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)

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/links with 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