package com.example.search;
import java.time.LocalDate;
@ValidDateRange
public class DateRangeSearch {
private LocalDate from;
private LocalDate to;
private String keyword;
public LocalDate getFrom() {
return from;
}
public void setFrom(LocalDate from) {
this.from = from;
}
public LocalDate getTo() {
return to;
}
public void setTo(LocalDate to) {
this.to = to;
}
public String getKeyword() {
return keyword;
}
public void setKeyword(String keyword) {
this.keyword = keyword == null ? null : keyword.trim();
}
}
package com.example.search;
import java.beans.PropertyEditorSupport;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
public class LocalDateEditor extends PropertyEditorSupport {
private final DateTimeFormatter formatter;
public LocalDateEditor(DateTimeFormatter formatter) {
this.formatter = formatter;
}
@Override
public void setAsText(String text) {
if (text == null || text.trim().isEmpty()) {
setValue(null);
return;
}
try {
setValue(LocalDate.parse(text.trim(), formatter));
} catch (DateTimeParseException ex) {
throw new IllegalArgumentException("Expected a date like 2024-01-31", ex);
}
}
@Override
public String getAsText() {
LocalDate value = (LocalDate) getValue();
return value == null ? "" : value.format(formatter);
}
}
package com.example.search;
import jakarta.validation.Constraint;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import jakarta.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ValidDateRange.DateRangeValidator.class)
public @interface ValidDateRange {
String message() default "'from' must not be after 'to'";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
class DateRangeValidator implements ConstraintValidator<ValidDateRange, DateRangeSearch> {
@Override
public boolean isValid(DateRangeSearch search, ConstraintValidatorContext ctx) {
if (search.getFrom() == null || search.getTo() == null) {
return true; // let @NotNull-style constraints report nulls
}
return !search.getFrom().isAfter(search.getTo());
}
}
}
package com.example.search;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import jakarta.validation.Valid;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
@Controller
public class SearchController {
private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_LOCAL_DATE;
private final OrderSearchService service;
public SearchController(OrderSearchService service) {
this.service = service;
}
@InitBinder("dateRangeSearch")
public void initBinder(WebDataBinder binder) {
binder.registerCustomEditor(LocalDate.class, new LocalDateEditor(ISO));
}
@GetMapping("/orders/search")
public String search(@Valid @ModelAttribute DateRangeSearch dateRangeSearch,
BindingResult result,
Model model) {
if (result.hasErrors()) {
return "orders/search-form";
}
model.addAttribute("results", service.find(dateRangeSearch));
return "orders/search-results";
}
}
This snippet shows how Spring MVC binds query parameters onto a command object with @ModelAttribute, converts raw date strings through a PropertyEditor, and enforces a cross-field rule that the start must not be after the end. It is the classic pattern for a search form where the URL carries from and to parameters and the controller wants a typed, validated object instead of loose strings.
The DateRangeSearch command class is a plain POJO holding from, to, and an optional keyword. It carries a bean-validation constraint at the type level, @ValidDateRange, which expresses a relationship between two fields — something field-level annotations like @NotNull cannot capture. The two LocalDate fields are populated by the binder rather than parsed by hand, which keeps the controller free of string wrangling.
LocalDateEditor extends PropertyEditorSupport and is the bridge between the raw request string and a LocalDate. Its setAsText trims input, treats blank as null, and rethrows a DateTimeParseException as an IllegalArgumentException so Spring records a proper field-level binding error instead of blowing up. Implementing getAsText makes the editor symmetric so the value renders back correctly when the form redisplays.
The editor is not global by default; @InitBinder in SearchController registers it per-controller via binder.registerCustomEditor(LocalDate.class, new LocalDateEditor(...)). Using a strict DateTimeFormatter here means the parse format is a controller decision, not a scattered constant. The search handler takes @Valid @ModelAttribute plus a BindingResult; the BindingResult must immediately follow the validated object or Spring throws before the method runs.
Binding and type-conversion errors surface as field errors, while the class-level range check surfaces as a global error. When result.hasErrors() is true the handler returns to the form view instead of querying, so invalid input never reaches the service layer. ValidDateRange and its DateRangeValidator implement the ConstraintValidator contract; the validator returns true when either bound is null so the null case is left to other constraints, avoiding double-reporting. This separation — editor for conversion, validator for business rules, controller for flow — is why the request handler stays this small.
Related snips
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
package com.example.starter.config;
import com.example.starter.properties.CustomProperties;
import com.example.starter.service.CustomService;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
Custom Spring Boot starters
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
package com.example.demo.config;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
Messaging with Apache Kafka
Share this code
Here's the card — post it anywhere.