ADR-0048 — Route Domains & Service Extraction (P17)
Status: Accepted
Date: 2026-06-12
Supersedes: Inline validation + article logic in cmd/vayupress/main.go
Context
After P16, main.go was 1,824 lines of package main. While all handlers were methods on *App and state was explicit, the file was still a composition monolith: article CRUD logic, admin handlers, metrics exposition, search, middleware, and route registration were all co-located with func main(). This made navigation, review, and future extraction harder than necessary.
Simultaneously, business logic (validation, DB queries, queue dispatch) was duplicated between handleCreateArticle and handleBulkCreateArticles, and fully embedded in handler bodies rather than in a testable service layer.
Decision
1. internal/api package — ArticleService
Introduced ArticleService struct:
type ArticleService struct {
DB *sql.DB
Enqueue func(art db.Article, op string) error
}
Methods: Create, BulkCreate, Update, Delete, Get, List, ListTags.
Extracted from handlers:
- Validation (
ValidateArticleInput,IsValidSlug,SplitTags) — now ininternal/api/validate.go - Slug conflict detection, quota checks, DB queries, queue dispatch
MakeEnqueueFn(db) provides a concrete Enqueue implementation; handlers can inject a stub for unit tests.
2. internal/api validation helpers exported
ValidateArticleInput, IsValidSlug, SplitTags are exported from internal/api and used by both the service and the cmd/vayupress handlers. Eliminates the duplicate validateArticleInput/splitTags in main.go.
3. cmd/vayupress/main.go split into domain files
Same package main, multiple files:
| File | Content |
|---|---|
main.go |
func main(), version, immutable regexes, response helpers, feed generators |
app.go |
App struct, constructor, meilisearch methods, plugin hook methods, admin metrics collectors |
middleware.go |
SSRF helpers, HTTP middleware, security headers |
handlers_articles.go |
Article CRUD + search + tags handlers |
handlers_infra.go |
Stats, Prometheus metrics, queue status/replay handlers |
handlers_admin.go |
Vacuum, pprof, backup, cache-purge, article page, smoke test, ADR, benchmark, admin dashboard |
routes.go |
(a *App).registerRoutes(r, staticDir) — all route registration |
main() calls a.registerRoutes(r, staticDir) — route wiring is no longer inline in bootstrap.
4. Package benchmarks
Added go test -bench=. benchmarks:
internal/metrics:BenchmarkHistogramRecord,BenchmarkHistogramPercentile,BenchmarkCacheHitRatiointernal/api:BenchmarkValidateArticleInput,BenchmarkSplitTags,BenchmarkIsValidSlug
Results (Intel Xeon 2.8GHz):
- Histogram record: ~20 ns/op
- Histogram P95: ~37 ns/op
- ValidateArticleInput: ~282 ns/op
- SplitTags (10 tags): ~1723 ns/op
Consequences
ArticleServiceprovides an independently testable business logic layermain.goreduced from 1,824 lines to ~500 lines- Route registration is a single, reviewable function
- Validation logic has one canonical location (
internal/api) - Handlers call service methods; duplication between create and bulk-create eliminated
- Dependency chain:
logging → config → metrics → db → auth → render → queue → health → api → plugins → main