Backend API Design — Quick Reference
URL Patterns
GET /api/v1/employees # List (plural)
GET /api/v1/employee # Get one (?id=xxx)
POST /api/v1/employee # Create
POST /api/v1/employee/update # Update (?id=xxx)
POST /api/v1/employee/delete # Soft delete (?id=xxx)
POST /api/v1/employee/restore # Restore (?id=xxx)
POST /api/v1/sync/employees # Action
Hard Rules
- NO path params — always
@QueryValue, never @PathVariable
- Singular for single resource —
/employee not /employees/{id}
- Plural for collections —
/employees
- Verb sub-paths for actions —
/delete, /restore, /sync
Layered Architecture
Controller → thin, just delegates
↓
Manager → business logic, transactions, Either returns
↓
Repository → data access only, no business logic
Controller: Thin Wrapper
- Parse query params with defaults
- Delegate to manager
- Unwrap Either with
.throwOrValue()
- NO business logic, NO repository access
Manager: Business Logic
- Returns
Either<ClientException, T>
- Wraps DB ops in
transaction(db.primary) { }
- Orchestrates multiple repositories
- Validates business rules
Repository: Data Access
- Returns entities or null
db.replica for reads, db.primary for writes
- Always checks
deletedAt.isNull()
Quick Code Reference
The core controller delegation pattern:
@Get("/employee")
suspend fun getEmployee(@QueryValue id: UUID): EmployeeResponse {
return employeeManager.findById(id).throwOrValue()
}
- Response models —
companion object { fun from(entity) } in module-client/response/{domain}/
- Pagination — offset-based, manager returns
EmployeeListResponse with items, total, page, limit, hasMore
- Errors — return
ClientError.NOT_FOUND.asException().left() from managers, never throw
- Factory beans —
@Factory class with @Singleton method, wire repos + db into manager
See code-patterns.md for complete controller, response model, pagination, error handling, and factory bean templates.
Gotchas
- Multi-word
@QueryValue params MUST have explicit snake_case names. The frontend axios interceptor sends project_id but Micronaut matches the literal param name. Write @QueryValue("project_id") projectId: UUID, not bare @QueryValue projectId: UUID.
- Don't use
@Put, @Delete, or @Patch. This is RPC-style — all mutations are @Post. The only @Get is for reads.
- Controllers that inject repositories are a code smell. If you see
private val fooRepository: FooRepository in a controller, move it to the manager.
andWhere {} not second .where {}. Calling .where {} twice replaces the first condition. Use .andWhere {} to chain.
- Don't forget
@ExecuteOn(TaskExecutors.IO). Without it, suspend functions may hang or run on the wrong thread pool. Every controller needs it.
1---2name: backend-api-design3description: Design RPC-style APIs with layered architecture (Controller → Manager → Repository). Use when creating new API endpoints, designing API contracts, or reviewing API patterns.4---56# Backend API Design — Quick Reference78## URL Patterns910```11GET /api/v1/employees # List (plural)12GET /api/v1/employee # Get one (?id=xxx)13POST /api/v1/employee # Create14POST /api/v1/employee/update # Update (?id=xxx)15POST /api/v1/employee/delete # Soft delete (?id=xxx)16POST /api/v1/employee/restore # Restore (?id=xxx)17POST /api/v1/sync/employees # Action18```1920## Hard Rules2122- **NO path params** — always `@QueryValue`, never `@PathVariable`23- **Singular for single resource** — `/employee` not `/employees/{id}`24- **Plural for collections** — `/employees`25- **Verb sub-paths for actions** — `/delete`, `/restore`, `/sync`2627## Layered Architecture2829```30Controller → thin, just delegates31 ↓32Manager → business logic, transactions, Either returns33 ↓34Repository → data access only, no business logic35```3637### Controller: Thin Wrapper38- Parse query params with defaults39- Delegate to manager40- Unwrap Either with `.throwOrValue()`41- NO business logic, NO repository access4243### Manager: Business Logic44- Returns `Either<ClientException, T>`45- Wraps DB ops in `transaction(db.primary) { }`46- Orchestrates multiple repositories47- Validates business rules4849### Repository: Data Access50- Returns entities or null51- `db.replica` for reads, `db.primary` for writes52- Always checks `deletedAt.isNull()`5354## Quick Code Reference5556The core controller delegation pattern:5758```kotlin59@Get("/employee")60suspend fun getEmployee(@QueryValue id: UUID): EmployeeResponse {61 return employeeManager.findById(id).throwOrValue()62}63```6465- **Response models** — `companion object { fun from(entity) }` in `module-client/response/{domain}/`66- **Pagination** — offset-based, manager returns `EmployeeListResponse` with `items`, `total`, `page`, `limit`, `hasMore`67- **Errors** — return `ClientError.NOT_FOUND.asException().left()` from managers, never throw68- **Factory beans** — `@Factory` class with `@Singleton` method, wire repos + db into manager6970> See code-patterns.md for complete controller, response model, pagination, error handling, and factory bean templates.7172## Gotchas7374- **Multi-word `@QueryValue` params MUST have explicit snake_case names.** The frontend axios interceptor sends `project_id` but Micronaut matches the literal param name. Write `@QueryValue("project_id") projectId: UUID`, not bare `@QueryValue projectId: UUID`.75- **Don't use `@Put`, `@Delete`, or `@Patch`.** This is RPC-style — all mutations are `@Post`. The only `@Get` is for reads.76- **Controllers that inject repositories are a code smell.** If you see `private val fooRepository: FooRepository` in a controller, move it to the manager.77- **`andWhere {}` not second `.where {}`.** Calling `.where {}` twice replaces the first condition. Use `.andWhere {}` to chain.78- **Don't forget `@ExecuteOn(TaskExecutors.IO)`.** Without it, suspend functions may hang or run on the wrong thread pool. Every controller needs it.