What is a Formatter, how does it differ from a Converter, and how do @DateTimeFormat/@NumberFormat plug in via AnnotationFormatterFactory?
answer
- Formatter = Printer(print) + Parser(parse), Locale-aware
- always Object↔String; Converter is any↔any
- lives in (Default)FormattingConversionService, adapted to GenericConverters
- AnnotationFormatterFactory: getFieldTypes + getPrinter/getParser
- @DateTimeFormat / @NumberFormat built-in factories
basics
~20 sA Formatter converts between an object and its String form in a locale-aware way (print + parse). Unlike a Converter it is always String↔Object and Locale-sensitive. AnnotationFormatterFactory lets annotations like @DateTimeFormat drive which Formatter is used on a given field.
solid answer
~40 sFormatter<T> is a locale-aware, bidirectional String↔Object SPI: it extends Printer<T> (T→String via print(T, Locale)) and Parser<T> (String→T via parse(String, Locale)). Converters, by contrast, are generic type→type and locale-unaware. Formatters live in a FormattingConversionService (e.g. DefaultFormattingConversionService), which adapts each Formatter into two internal GenericConverters and pulls the Locale from LocaleContextHolder. For per-field, annotation-driven formatting you implement AnnotationFormatterFactory<A extends Annotation>: getFieldTypes() declares which field types it applies to, and getPrinter(A, Class)/getParser(A, Class) return a Printer/Parser configured from the annotation instance. Spring ships Jsr310DateTimeFormatAnnotationFormatterFactory for @DateTimeFormat and NumberFormatAnnotationFormatterFactory for @NumberFormat. Use Formatters for user-facing text (dates, money, percentages) where locale matters; use Converters for structural type-to-type conversion.
code
java · 22 lines// A locale-aware Formatter for a Money value object
public class MoneyFormatter implements Formatter<Money> {
@Override public String print(Money money, Locale locale) {
return NumberFormat.getCurrencyInstance(locale).format(money.amount());
}
@Override public Money parse(String text, Locale locale) throws ParseException {
Number n = NumberFormat.getCurrencyInstance(locale).parse(text);
return Money.of(n.doubleValue());
}
}
// Registering formatters + annotation-driven formatting in Spring MVC
@Configuration
class FormatConfig implements WebMvcConfigurer {
@Override public void addFormatters(FormatterRegistry registry) {
registry.addFormatter(new MoneyFormatter());
// @DateTimeFormat / @NumberFormat factories are already registered by MVC/Boot
}
}
// Usage on a field: the annotation instance drives getParser/getPrinter
record Booking(@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date) {}go deeper
Know Formatter turns objects to/from Strings and that @DateTimeFormat formats dates.
Explain Printer+Parser, Locale-awareness, and Formatter-vs-Converter differences.
Detail AnnotationFormatterFactory (getFieldTypes/getPrinter/getParser), the built-in factories, and Formatter→GenericConverter adaptation.
Reason about registration surfaces, thread-safety of underlying formatters, Locale sourcing, and when a Formatter vs Converter is the right SPI.
## Formatter: the localization SPI While a `Converter` handles arbitrary type→type conversion, a **`Formatter<T>`** (`org.springframework.format.Formatter`) is specialized for the **presentation** boundary: converting an object to/from a **`String`** in a **`Locale`-aware** way. It is composed of two interfaces: ```java public interface Printer<T> { String print(T object, Locale locale); } public interface Parser<T> { T parse(String text, Locale locale) throws ParseException; } public interface Formatter<T> extends Printer<T>, Parser<T> {} ``` So a `Formatter<Money>` knows how to render `Money` as `"$1,234.56"` for `Locale.US` and parse it back. The **`Locale`** is supplied by the framework — in a web request it comes from `LocaleContextHolder.getLocale()`. ### Formatter vs Converter | | Converter | Formatter | |---|---|---| | Direction | one-way S→T (pair up two for both) | bidirectional print + parse | | Types | any type → any type | always Object ↔ String | | Locale | not aware | Locale-aware | | Home | any ConversionService | FormattingConversionService | Internally a `FormattingConversionService` (base of `DefaultFormattingConversionService`) **adapts** each registered `Formatter` into a pair of `GenericConverter`s — a `PrinterConverter` (`T→String`) and a `ParserConverter` (`String→T`) — so Formatters ride the same conversion machinery. ## Annotation-driven formatting: `AnnotationFormatterFactory` Often you don't want *one* global format for `LocalDate`; you want the format to depend on an **annotation on the field**, e.g. `@DateTimeFormat(pattern = "yyyy-MM-dd")`. That's what `AnnotationFormatterFactory<A extends Annotation>` does: ```java public interface AnnotationFormatterFactory<A extends Annotation> { Set<Class<?>> getFieldTypes(); // e.g. {LocalDate, LocalDateTime, ...} Printer<?> getPrinter(A annotation, Class<?> fieldType); Parser<?> getParser(A annotation, Class<?> fieldType); } ``` When the `FormattingConversionService` needs to convert a field annotated with `A`, it calls the factory with the **actual annotation instance** (so `getPrinter`/`getParser` can read `pattern`, `iso`, `style`, etc.) and the field's type, then uses the returned `Printer`/`Parser`. You register it via `FormatterRegistry#addFormatterForFieldAnnotation(...)`. ### Built-in factories - **`@DateTimeFormat`** → `Jsr310DateTimeFormatAnnotationFormatterFactory` (java.time) and `DateTimeFormatAnnotationFormatterFactory` (Joda, legacy). Honors `pattern`, `iso`, `style`, `fallbackPatterns`. - **`@NumberFormat`** → `NumberFormatAnnotationFormatterFactory`. Honors `style` (NUMBER, PERCENT, CURRENCY), `pattern`. `DefaultFormattingConversionService` registers these by default (also driven by `@EnableWebMvc` / Boot autoconfig). ## Registration surfaces - Core/manual: `new DefaultFormattingConversionService()` then `addFormatter(...)` / `addFormatterForFieldAnnotation(...)`. - Spring MVC: override `WebMvcConfigurer#addFormatters(FormatterRegistry registry)`. - Spring Boot: declare `Formatter`/`AnnotationFormatterFactory` beans — detected automatically. ## Gotchas - `parse` may throw `ParseException`; the service wraps failures as `ConversionFailedException` and, during binding, they surface as binding/validation errors — but the **binding orchestration itself is a separate concern** (DataBinder); here we only supply the parse/print capability. - `print`/`parse` must be **thread-safe**; note `java.text.SimpleDateFormat` and `java.text.NumberFormat` are **not** thread-safe, so create them per call or use thread-safe `java.time.format.DateTimeFormatter`. - Locale mistakes: forgetting `LocaleContextHolder` means outside a request you get the JVM default locale — surprising in tests/batch jobs. - A `Formatter` is String-oriented; if your target isn't textual, use a `Converter`/`GenericConverter` instead.
- Where does the Locale passed to print/parse come from in a web request?From LocaleContextHolder.getLocale(), populated by the DispatcherServlet via a LocaleResolver (e.g. AcceptHeaderLocaleResolver). Outside a request it defaults to the JVM/context default, which is a common test pitfall.
- Why is @DateTimeFormat implemented as an AnnotationFormatterFactory rather than a plain Formatter?Because the desired format is per-field and depends on the annotation's attributes (pattern/iso/style). A plain Formatter is global for a type; the factory receives the actual annotation instance and field type to build a Printer/Parser tailored to that field.
saying these in an interview costs you the question
- Saying a Formatter is one-directional like a Converter
- Claiming Formatters are not locale-aware
- Reusing a single SimpleDateFormat/NumberFormat instance across threads
- Thinking @DateTimeFormat works without a FormattingConversionService (a plain DefaultConversionService won't honor it)