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 —
/employeenot/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.replicafor reads,db.primaryfor 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) }inmodule-client/response/{domain}/ - Pagination — offset-based, manager returns
EmployeeListResponsewithitems,total,page,limit,hasMore - Errors — return
ClientError.NOT_FOUND.asException().left()from managers, never throw - Factory beans —
@Factoryclass with@Singletonmethod, wire repos + db into manager
See code-patterns.md for complete controller, response model, pagination, error handling, and factory bean templates.
Gotchas
- Multi-word
@QueryValueparams MUST have explicit snake_case names. The frontend axios interceptor sendsproject_idbut 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@Getis for reads. - Controllers that inject repositories are a code smell. If you see
private val fooRepository: FooRepositoryin 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.