spring-api-layer
Skill18 starsChanged 4 months agoMITView on GitHub
API layer for Spring Boot Kotlin backends - REST controllers, request and response DTOs, Bean Validation, api mappers, the current user, pagination parameters, and the error contract (domain exceptions mapped to RFC 9457 ProblemDetail with a stable error code in per-module @RestControllerAdvice classes). Use this skill whenever adding or changing an endpoint, writing a controller, creating a request or response DTO, validating input, mapping a domain model to a DTO, adding an exception handler, defining an error code, or deciding which HTTP status an error gets. Trigger on phrases like "add an endpoint", "controller", "REST API", "@RestController", "@GetMapping", "@PostMapping", "request body", "DTO", "response", "validation", "@Valid", "@NotBlank", "custom constraint", "exception handler", "@RestControllerAdvice", "ProblemDetail", "error response", "error code", "status code", "404", "400", or "pagination".
In this repository67 other files it gives its agents.
- agent-onboardingSkill
- android-backgroundSkill
- android-cliSkill
- android-compose-architectureSkill
- android-compose-componentsSkill
- android-data-layerSkill
- android-di-koinSkill
- android-error-handlingSkill
- android-integration-testingSkill
- android-jacoco-setupSkill
- android-jvm-test-coverageSkill
- android-module-structureSkill
- android-navigationSkill
- android-presentation-mviSkill
- android-preview-figma-verifySkill
- android-ui-screenshot-verifySkill
- android-ui-testingSkill
- android-ui-verifySkill
- android-unit-testingSkill
- android-version-catalogSkill
- architecture-decision-recordSkill
- careful-planningSkill
- compose-adaptive-layoutsSkill
- figma-to-android-vectorSkill
- finalizeSkill
- git-commitSkill
- handoffSkill
- kmp-compose-architectureSkill
- kmp-compose-componentsSkill
- kmp-data-layerSkill
- kmp-di-koinSkill
- kmp-error-handlingSkill
- kmp-integration-testingSkill
- kmp-migration-analyzerSkill
- kmp-module-structureSkill
- kmp-navigationSkill
- kmp-presentation-mviSkill
- kmp-ui-testingSkill
- kmp-unit-testingSkill
- kotlin-coroutinesSkill
- kotlin-flowsSkill
- kotlin-mutation-testingSkill
- openspec-artifact-review-lightSkill
- openspec-artifact-reviewSkill
- project-breakdownSkill
- react-data-layerSkill
- react-error-handlingSkill
- react-integration-testingSkill
- react-presentation-mviSkill
- react-project-structureSkill
- react-routingSkill
- react-screen-architectureSkill
- react-securitySkill
- react-server-stateSkill
- react-ui-componentsSkill
- react-unit-testingSkill
- speckit-parallelize-agent-teamsSkill
- speckit-parallelize-subagentsSkill
- spring-di-configurationSkill
- spring-integration-testingSkill
- The first 60 shown.
The file
We split this skill into the rules it gives an agent
4 rules. About 0 are common, 1 is distinctive, and 3 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
3
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 13
Controllers are thin: Read input, call one service method, map the result to a DTO.
- Very specific: line 102
Every @RequestBody whose type has constraints is annotated @Valid.
- Distinctive: line 166
Every page size has a hard maximum.
- Very specific: line 173
Every error response is an RFC 9457 application/problem+json body with one extra property, code.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Spring API Layer
The api package of a module is everything that receives input from outside the module: REST controllers, WebSocket handlers, and listeners for other modules' events (see spring-service-layer). This skill covers REST.
Principles
- Controllers are thin. Read input, call one service method, map the result to a DTO. No business rules, no repository access, no
@Transactional. - DTOs define the HTTP contract. A controller never returns a domain model or an entity, so changing a domain model never silently changes an API response.
- Errors are domain exceptions. Services throw them. The api layer maps them to HTTP in one place per module. Controllers never build error responses by hand.
Controllers
@RestController
@RequestMapping("/api/chats")
class ChatController(
private val chatService: ChatService,
) {
@GetMapping("/{chatId}")
fun getChat(@PathVariable chatId: ChatId): ChatDto {
return chatService
.getChat(chatId = chatId, requestUserId = requestUserId)
.toChatDto()
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun createChat(@Valid @RequestBody body: CreateChatRequest): ChatDto {
return chatService
.createChat(
creatorId = requestUserId,
otherUserIds = body.otherUserIds.toSet(),
)
.toChatDto()
}
@DeleteMapping("/{chatId}/participants/me")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun leaveChat(@PathVariable chatId: ChatId) {
chatService.removeParticipant(chatId = chatId, userId = requestUserId)
}
}- Base path
/api/<plural-resource>. Sub-resources nest (/api/chats/{chatId}/messages). Actions that aren't CRUD are a sub-resource noun or a verb segment as a last resort (/api/auth/refresh). - Return the DTO directly. Use
@ResponseStatusfor a fixed non-200 status (201on create,204on delete). UseResponseEntityonly when headers or the status depend on the outcome. - Pass request fields to the service as named arguments. Don't map a request DTO into a domain model in the controller, unless the service genuinely takes that model.
- Nothing in a controller returns
nullto mean "not found". The service throws a domain exception (ChatNotFoundException), and the module's exception handler turns it into404. Never throwResponseStatusException. - The authenticated user comes from
requestUserId(see spring-security). Never accept the acting user's ID from the request body or a path variable.
Request and Response DTOs
data class CreateChatRequest(
@field:Size(min = 1, max = 50, message = "A chat needs between 1 and 50 other participants")
val otherUserIds: List<UserId>,
)
data class ChatDto(
val id: ChatId,
val participants: List<ChatParticipantDto>,
val lastMessage: ChatMessageDto?,
val lastActivityAt: Instant,
)- Incoming bodies:
<Action><Resource>Request(CreateChatRequest,ChangePasswordRequest). Outgoing bodies:<Resource>Dto(ChatDto). A DTO is never used for both directions. - Plain
data classwithvals. Required fields are non-null with no default. A missing required field then fails deserialization and becomes a400automatically. - API enums are their own DTO enums (
PlatformDto), mapped to domain enums. Renaming a domain enum constant must not break clients. Instants go over the wire as ISO-8601 UTC strings (Jackson 3's default). IDs are UUID strings.- DTOs live in
api/dto, one file per DTO. Small enums used by only one DTO may share its file.
Validation
@PostMapping("/register")
fun register(@Valid @RequestBody body: RegisterRequest): UserDto { ... }
data class RegisterRequest(
@field:Email(message = "Must be a valid email address")
val email: String,
@field:Size(min = 3, max = 20, message = "Username must be between 3 and 20 characters")
val username: String,
@field:Password
val password: String,
)- Every
@RequestBodywhose type has constraints is annotated@Valid. Without it, the constraints compile and do nothing. - Constraint annotations on constructor properties always use the
@field:use-site target. That's where Hibernate Validator looks, whatever the compiler defaults are. - Nested objects and lists of objects get
@field:Validso their constraints run too. - Constraints on
@RequestParamand@PathVariable(@Max(100) pageSize: Int) are validated by Spring MVC's built-in method validation. A violation raisesHandlerMethodValidationException, which the common handler maps to400. - Validation is for shape: format, length, ranges, required. Rules that need data (email already taken, user is a chat member) belong in the service and throw domain exceptions.
- Reusable rules become a custom constraint in
api/validation:
@Target(AnnotationTarget.FIELD, AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [])
@Pattern(regexp = "^(?=.*[\\d\\W]).{8,}$")
@ReportAsSingleViolation
annotation class Password(
val message: String = "Password must be at least 8 characters and contain a digit or special character",
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<out Payload>> = [],
)Mappers
Extension functions in api/mapper, one file per domain area:
fun Chat.toChatDto(): ChatDto {
return ChatDto(
id = id,
participants = participants.map { it.toChatParticipantDto() },
lastMessage = lastMessage?.toChatMessageDto(),
lastActivityAt = lastActivityAt,
)
}
fun PlatformDto.toPlatform(): DeviceToken.Platform {
return when (this) {
PlatformDto.ANDROID -> DeviceToken.Platform.ANDROID
PlatformDto.IOS -> DeviceToken.Platform.IOS
}
}- Name the function after the target type:
toChatDto(),toPlatform(). - Enum mappers use an exhaustive
whenwith noelse, so a new constant fails compilation at every mapper. - Mappers are pure. No service calls, no clock, no I/O.
Pagination
Lists that grow without bound (messages, notifications) use keyset pagination, never offset pagination:
@GetMapping("/{chatId}/messages")
fun getMessages(
@PathVariable chatId: ChatId,
@RequestParam before: Instant?,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) pageSize: Int,
): List<ChatMessageDto> { ... }- The cursor is the sort key of the last item the client has (
before). An absent cursor means "newest page". - Every page size has a hard maximum.
- Small bounded lists (a user's chats) return everything without paging.
Error Contract — ProblemDetail
Every error response is an RFC 9457 application/problem+json body with one extra property, code. Clients switch on code, never on detail text.
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "Chat not found",
"instance": "/api/chats/3f2a...",
"code": "CHAT_NOT_FOUND"
}Validation failures add an errors list:
{
"status": 400,
"title": "Bad Request",
"detail": "Request validation failed",
"code": "VALIDATION_FAILED",
"errors": [{ "field": "email", "message": "Must be a valid email address" }]
}Shared helper (common.api)
fun problemDetail(status: HttpStatus, code: String, detail: String?): ProblemDetail {
return ProblemDetail.forStatusAndDetail(status, detail).apply {
setProperty("code", code)
}
}Module exception handler
Each module maps its own domain exceptions in api/exception/<Module>ExceptionHandler:
@RestControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
class ChatExceptionHandler {
@ExceptionHandler(ChatNotFoundException::class)
fun onChatNotFound(e: ChatNotFoundException) =
problemDetail(HttpStatus.NOT_FOUND, "CHAT_NOT_FOUND", e.message)
@ExceptionHandler(InvalidChatSizeException::class)
fun onInvalidChatSize(e: InvalidChatSizeException) =
problemDetail(HttpStatus.BAD_REQUEST, "INVALID_CHAT_SIZE", e.message)
}@RestControllerAdviceis required. A handler class without it compiles and is never called.- Returning a
ProblemDetailsets the response status from it, so no@ResponseStatusis needed. - One
@ExceptionHandlerper exception type, so every exception gets its owncode.
Common exception handler (common.api)
Handles Spring MVC's own exceptions, validation, the shared exceptions from common.domain.exception, and anything nobody else handled:
@RestControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
class CommonExceptionHandler : ResponseEntityExceptionHandler() {
override fun handleMethodArgumentNotValid(
ex: MethodArgumentNotValidException,
headers: HttpHeaders,
status: HttpStatusCode,
request: WebRequest,
): ResponseEntity<Any>? {
val body = problemDetail(HttpStatus.BAD_REQUEST, "VALIDATION_FAILED", "Request validation failed").apply {
setProperty(
"errors",
ex.bindingResult.fieldErrors.map { mapOf("field" to it.field, "message" to it.defaultMessage) }
)
}
return ResponseEntity.badRequest().body(body)
}
@ExceptionHandler(ForbiddenException::class)
fun onForbidden(e: ForbiddenException) =
problemDetail(HttpStatus.FORBIDDEN, "FORBIDDEN", e.message)
@ExceptionHandler(AccessDeniedException::class)
fun onAccessDenied(e: AccessDeniedException) =
problemDetail(HttpStatus.FORBIDDEN, "FORBIDDEN", "You are not allowed to do that")
@ExceptionHandler(Exception::class)
fun onUnexpected(e: Exception): ProblemDetail {
logger.error("Unhandled exception", e)
return problemDetail(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "Something went wrong")
}
}- Extending
ResponseEntityExceptionHandlerturns every Spring MVC exception (malformed JSON, missing parameter, wrong method, method validation) into aProblemDetail. Spring Boot's own problem-details handler backs off when this bean exists, so don't also setspring.mvc.problemdetails.enabled. - The
@Ordervalues matter. Spring picks the first advice that can handle an exception, and the catch-allExceptionhandler can handle everything. Module handlers must come first. loggeris inherited fromResponseEntityExceptionHandler. Declaring your ownloggerproperty clashes with it.- The catch-all never puts
e.messageinto the response. Internal messages leak implementation details. AccessDeniedExceptionmust be mapped explicitly. Otherwise the catch-all turns a403from method security into a500.
Status code mapping
| Situation | Status | Example code |
| Malformed input, failed validation | 400 | VALIDATION_FAILED, INVALID_CHAT_SIZE |
| Not authenticated, bad credentials, bad token | 401 | INVALID_CREDENTIALS, INVALID_TOKEN |
| Authenticated but not allowed | 403 | FORBIDDEN, EMAIL_NOT_VERIFIED |
| Resource doesn't exist or the caller can't see it | 404 | CHAT_NOT_FOUND |
| State conflict (duplicate, same password) | 409 | USER_ALREADY_EXISTS |
| Rate limited | 429 | RATE_LIMIT_EXCEEDED |
| Third-party dependency failed | 502 / 503 | STORAGE_UNAVAILABLE |
| Bug | 500 | INTERNAL_ERROR |
Return 404 rather than 403 when revealing that a resource exists would leak information (another user's chat).
Error codes are SCREAMING_SNAKE_CASE, specific to the failure, and never renamed once shipped: clients depend on them.
Naming Conventions
| Thing | Convention | Example |
| Controller | <Resource>Controller | ChatController |
| Request body | <Action><Resource>Request | CreateChatRequest |
| Response body | <Resource>Dto | ChatDto |
| API enum | <Name>Dto | PlatformDto |
| Mapper | extension fun named after the target | fun Chat.toChatDto() |
| Exception handler | <Module>ExceptionHandler | ChatExceptionHandler |
| Error code | SCREAMING_SNAKE_CASE | CHAT_NOT_FOUND |
Checklist: Adding an Endpoint
- Controller method calls exactly one service method and returns a DTO
- Request DTO named
<Action><Resource>Request, constraints with@field:,@Validon the parameter - Response DTO named
<Resource>Dto, mapped by an extension function inapi/mapper - Acting user from
requestUserId, never from the request @ResponseStatusfor201/204where it applies- Every new domain exception the service can throw is mapped in the module's exception handler with its own
code - Route is public or authenticated as intended in the security config (see spring-security)
- An E2E test covers the success response and each error
code(see spring-integration-testing)
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

