Skip to content

HTTP Controller Best Practices

PATCH Requests

PATCH is for partial updates. Unlike PUT (full replacement), PATCH only modifies specified fields.

Basic Implementation

@PatchMapping("/{id}")
fun updateUser(
    @PathVariable id: Long,
    @RequestBody updates: Map<String, Any>
): User {
    val user = userRepository.findById(id).orElseThrow()
    // Apply updates manually - not recommended for production
    return userRepository.save(user)
}

Better: Use a DTO with Nullable Fields

data class UserPatchDto(
    val email: String? = null,
    val name: String? = null,
    val age: Int? = null
)

@PatchMapping("/{id}")
fun updateUser(
    @PathVariable id: Long,
    @RequestBody @Valid patch: UserPatchDto
): User {
    val user = userRepository.findById(id).orElseThrow()

    patch.email?.let { user.email = it }
    patch.name?.let { user.name = it }
    patch.age?.let { user.age = it }

    return userRepository.save(user)
}

Key principle: null means "don't update", absence means "don't update", explicit value means "update to this value".

Handling Explicit Null (Setting Field to Null)

The problem: With nullable DTOs, you cannot distinguish: - Field not sent: {} → don't update email - Field sent as null: {"email": null} → set email to null

Solution: Use a custom deserializer with an Optional wrapper

// Wrapper class to track if field was present
data class Optional<T>(val value: T?) {
    val isPresent: Boolean = true

    companion object {
        fun <T> absent() = Optional<T>(null).apply {
            // Use reflection or custom marker to track absence
        }
    }
}

// Custom deserializer
class OptionalDeserializer : JsonDeserializer<Optional<*>>() {
    override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Optional<*> {
        return if (p.currentToken == JsonToken.VALUE_NULL) {
            Optional(null)  // Field present, value is null
        } else {
            Optional(p.readValueAs(Any::class.java))
        }
    }
}

data class UserPatchDto(
    @JsonDeserialize(using = OptionalDeserializer::class)
    val email: Optional<String>? = null,

    @JsonDeserialize(using = OptionalDeserializer::class)
    val name: Optional<String>? = null
)

@PatchMapping("/{id}")
fun updateUser(
    @PathVariable id: Long,
    @RequestBody patch: UserPatchDto
): User {
    val user = userRepository.findById(id).orElseThrow()

    // null = field not sent, don't update
    // Optional(null) = field sent as null, set to null
    // Optional(value) = field sent with value, update
    patch.email?.let { user.email = it.value }
    patch.name?.let { user.name = it.value }

    return userRepository.save(user)
}

Request examples:

// Don't update email
{"name": "John"}

// Set email to null
{"email": null, "name": "John"}

// Update email
{"email": "new@example.com", "name": "John"}

Simpler alternative using Kotlin's reflection:

import kotlin.reflect.full.memberProperties

data class UserPatchDto(
    val email: String? = null,
    val name: String? = null,
    val age: Int? = null
)

@PatchMapping("/{id}")
fun updateUser(
    @PathVariable id: Long,
    @RequestBody patch: UserPatchDto,
    request: HttpServletRequest
): User {
    val user = userRepository.findById(id).orElseThrow()

    // Read raw JSON to check which fields were actually sent
    val jsonBody = request.reader.readText()
    val sentFields = ObjectMapper().readTree(jsonBody).fieldNames().asSequence().toSet()

    if ("email" in sentFields) user.email = patch.email
    if ("name" in sentFields) user.name = patch.name
    if ("age" in sentFields) user.age = patch.age

    return userRepository.save(user)
}

Using Jackson for Cleaner Null Handling

@PatchMapping("/{id}")
fun updateUser(
    @PathVariable id: Long,
    @RequestBody updates: JsonNode
): User {
    val user = userRepository.findById(id).orElseThrow()

    updates.fields().forEach { (key, value) ->
        when (key) {
            "email" -> user.email = if (value.isNull) null else value.asText()
            "name" -> user.name = if (value.isNull) null else value.asText()
            "age" -> user.age = if (value.isNull) null else value.asInt()
        }
    }

    return userRepository.save(user)
}

JSON Merge Patch (RFC 7396)

Spring supports JSON Merge Patch with the correct content type:

@PatchMapping("/{id}", consumes = ["application/merge-patch+json"])
fun mergePatchUser(
    @PathVariable id: Long,
    @RequestBody patch: UserPatchDto
): User {
    val user = userRepository.findById(id).orElseThrow()

    // With merge-patch, explicit null means "set to null"
    patch.email?.let { user.email = it }
    patch.name?.let { user.name = it }

    return userRepository.save(user)
}

Validation on PATCH

data class UserPatchDto(
    @field:Email
    val email: String? = null,

    @field:Size(min = 2, max = 50)
    val name: String? = null,

    @field:Min(0)
    @field:Max(150)
    val age: Int? = null
)

Validation only runs on fields that are present in the request.

General Controller Best Practices

Response Codes

@PatchMapping("/{id}")
fun updateUser(@PathVariable id: Long, @RequestBody patch: UserPatchDto): ResponseEntity<User> {
    val user = userRepository.findById(id).orElseThrow()
    // apply updates
    return ResponseEntity.ok(userRepository.save(user))
}

@PostMapping
fun createUser(@RequestBody @Valid user: User): ResponseEntity<User> {
    val created = userRepository.save(user)
    return ResponseEntity
        .created(URI.create("/users/${created.id}"))
        .body(created)
}

@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deleteUser(@PathVariable id: Long) {
    userRepository.deleteById(id)
}

Idempotency

  • GET, PUT, DELETE: Naturally idempotent
  • PATCH: Should be idempotent (applying same patch multiple times = same result)
  • POST: Not idempotent (creates new resource each time)

Error Handling

@ExceptionHandler(EntityNotFoundException::class)
fun handleNotFound(e: EntityNotFoundException) =
    ResponseEntity.status(HttpStatus.NOT_FOUND).body(ErrorResponse(e.message))

@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidation(e: MethodArgumentNotValidException) =
    ResponseEntity.badRequest().body(
        ValidationErrorResponse(
            e.bindingResult.fieldErrors.associate { it.field to it.defaultMessage }
        )
    )

Don't Return Entities Directly

Use DTOs to avoid: - Lazy loading issues (Jackson serialization triggering queries) - Exposing internal structure - Circular references

@GetMapping("/{id}")
fun getUser(@PathVariable id: Long): UserDto {
    return userRepository.findById(id)
        .orElseThrow()
        .toDto()
}

fun User.toDto() = UserDto(
    id = id,
    email = email,
    name = name
)

Use Proper HTTP Methods

  • GET: Read, no side effects, cacheable
  • POST: Create, non-idempotent
  • PUT: Full replacement, idempotent
  • PATCH: Partial update, idempotent
  • DELETE: Remove, idempotent

Content Negotiation

@GetMapping("/{id}", produces = [MediaType.APPLICATION_JSON_VALUE])
fun getUserJson(@PathVariable id: Long): UserDto = // ...

@GetMapping("/{id}", produces = [MediaType.APPLICATION_XML_VALUE])
fun getUserXml(@PathVariable id: Long): UserDto = // ...

Or let Spring handle it automatically based on Accept header.