@UniqueName
data class CreateRequest(
    val id: String = "",
    @get:NotBlank(message = "{req.name.blank}")
    val name: String = "",
    @get:NotBlank(message = "{req.description.blank}")
    val description: String = "",
    @get:Valid
    val nested: NestedClass = NestedClass("", ""),
)

data class NestedClass(
    @get:NotBlank(message = "{req.nested.something.blank}")
    val something: String,
    @get:NotBlank(message = "{req.nested.something_else.blank}")
    val somethingElse: String
)

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [UniqueNameValidator::class])
annotation class UniqueName(
    val message: String = "",
    val groups: Array<KClass<*>> = [],
    val payload: Array<KClass<out Payload>> = []
)

class UniqueNameValidator(
    private val sampleService: SampleService
) : ConstraintValidator<UniqueName, CreateRequest> {
    override fun isValid(request: CreateRequest, context: ConstraintValidatorContext): Boolean {
        val dupSample = sampleService.findByName(request.name)
        val result = !(dupSample != null && dupSample.id != project.id)
        if (!result) {
            context.disableDefaultConstraintViolation()
            context
                .buildConstraintViolationWithTemplate("{req.name.unique}")
                .addPropertyNode("name")
                .addConstraintViolation()
        }
        return result
    }
}

src/main/resources/ValidationMessages.properties

req.name.blank=Name is required.
req.name.unique=Name must be unique.
req.description.blank=Description is required.
req.nested.something.blank=Something is required.
req.nested.something_else.blank=Something Else is required.

Custom Response Messages

@ControllerAdvice
class ControllerAdvice {
    @ExceptionHandler(value = [MethodArgumentNotValidException::class])
    fun handleMethodArgumentNotValid(exception: MethodArgumentNotValidException): ResponseEntity<ValidationError> {
        val violations = exception.bindingResult.allErrors
            .mapNotNull { error ->
                when (error) {
                    is FieldError -> Violation(error.field.toSnakeCase(), error.defaultMessage ?: "")
                    is ObjectError -> Violation(error.objectName.toSnakeCase(), error.defaultMessage ?: "")
                    else -> null
                }
            }
            .toList()
        return ResponseEntity.status(BAD_REQUEST).body(ValidationError(errors = violations))
    }
}

class ValidationError(
    val status: String = BAD_REQUEST.toString(),
    val message: String = "Errors occurred during validation",
    val errors: List<Violation>
)
class Violation(val field: String, val message: String)

fun String.toSnakeCase(): String = Normalizer
    .normalize(this, Normalizer.Form.NFD)
    .replace("([A-Z]+)".toRegex(), "\\_\$1")
    .toLowerCase()

Controller

@PostMapping
fun create(@Valid @RequestBody body: CreateRequest): SampleDocument {
    return sampleService.save(body)
}

Sample Response

{
  "status": "400 BAD_REQUEST",
  "message": "Errors occurred during validation",
  "errors": [
    {
      "field": "nested.something_else",
      "message": "Something Else is required."
    },
    {
      "field": "name",
      "message": "Name must be unique."
    },
    {
      "field": "nested.something",
      "message": "Something is required."
    },
    {
      "field": "name",
      "message": "Name is required."
    },
    {
      "field": "description",
      "message": "Description is required."
    }
  ]
}