docs
Package: org.springframework.boot.convert
Module: org.springframework.boot:spring-boot
Since: 2.0.0
The convert package provides enhanced type conversion capabilities beyond Spring Framework's standard conversion service. It includes specialized converters for durations, periods, data sizes, and delimited strings, along with annotations for controlling conversion behavior.
| Component | Purpose | Thread Safety | Key Features |
|---|---|---|---|
ApplicationConversionService | Enhanced conversion service | Thread-safe for reads | Duration, Period, DataSize, delimited string converters |
DurationStyle | Duration format parser/printer | Immutable, thread-safe | SIMPLE ("10s"), ISO8601 ("PT10S") |
PeriodStyle | Period format parser/printer | Immutable, thread-safe | SIMPLE ("1y2m3d"), ISO8601 ("P1Y2M3D") |
@DurationFormat | Control duration format | N/A (annotation) | Specify SIMPLE or ISO8601 style |
@DurationUnit | Default duration unit | N/A (annotation) | Specify ChronoUnit when no suffix |
@PeriodFormat | Control period format | N/A (annotation) | Specify SIMPLE or ISO8601 style |
@PeriodUnit | Default period unit | N/A (annotation) | Specify ChronoUnit when no suffix |
@DataSizeUnit | Default data size unit | N/A (annotation) | Specify DataUnit (B, KB, MB, GB, TB) |
@Delimiter | Collection delimiter | N/A (annotation) | Specify delimiter for string splitting |
Enhanced conversion service with Spring Boot-specific converters pre-registered.
Thread Safety: Thread-safe for all conversion operations. Immutable after construction. The shared instance is safely published using double-checked locking.
package org.springframework.boot.convert;
import org.springframework.beans.factory.ListableBeanFactory;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.converter.ConverterRegistry;
import org.springframework.format.FormatterRegistry;
import org.springframework.format.support.FormattingConversionService;
import org.springframework.util.StringValueResolver;
import org.springframework.core.convert.TypeDescriptor;
import java.util.Map;
/**
* A specialization of FormattingConversionService configured by default
* with converters and formatters appropriate for most Spring Boot applications.
*
* Designed for direct instantiation but also exposes the static
* addApplicationConverters and addApplicationFormatters utility methods
* for ad-hoc use against registry instance.
*
* Thread Safety: Thread-safe for all read operations (conversions).
* Should not be modified after construction in multi-threaded environments.
*
* @since 2.0.0
*/
public class ApplicationConversionService extends FormattingConversionService {
/**
* Create a new ApplicationConversionService with default converters and formatters.
* Registers all Spring Boot specific converters including:
* - Duration converters (String to Duration, Number to Duration)
* - Period converters (String to Period, Number to Period)
* - DataSize converters (String to DataSize, Number to DataSize)
* - Delimited string converters (for collections)
* - Lenient enum converters (case-insensitive)
* - File converters
* - And more...
*/
public ApplicationConversionService() {
this(null);
}
/**
* Create a new ApplicationConversionService with an optional embedded
* value resolver for placeholder resolution.
*
* @param embeddedValueResolver the embedded value resolver to use for
* resolving ${...} placeholders and #{...}
* SpEL expressions (may be null)
*/
public ApplicationConversionService(@Nullable StringValueResolver embeddedValueResolver) {
// Registers default Spring converters
// Then adds Spring Boot specific converters via configure()
}
/**
* Add a Printer to be used under the given type.
* Throws UnsupportedOperationException if this is an unmodifiable shared instance.
*
* @param printer the printer to add
* @throws UnsupportedOperationException if the conversion service is unmodifiable
*/
@Override
public void addPrinter(Printer<?> printer) {
// Checks modifiability before delegating to super
}
/**
* Add a Parser to be used under the given type.
* Throws UnsupportedOperationException if this is an unmodifiable shared instance.
*
* @param parser the parser to add
* @throws UnsupportedOperationException if the conversion service is unmodifiable
*/
@Override
public void addParser(Parser<?> parser) {
// Checks modifiability before delegating to super
}
/**
* Add a Formatter to be used under the given type.
* Throws UnsupportedOperationException if this is an unmodifiable shared instance.
*
* @param formatter the formatter to add
* @throws UnsupportedOperationException if the conversion service is unmodifiable
*/
@Override
public void addFormatter(Formatter<?> formatter) {
// Checks modifiability before delegating to super
}
/**
* Add a Formatter to be used for the given field type.
* Throws UnsupportedOperationException if this is an unmodifiable shared instance.
*
* @param fieldType the field type to format
* @param formatter the formatter to add
* @throws UnsupportedOperationException if the conversion service is unmodifiable
*/
@Override
public void addFormatterForFieldType(Class<?> fieldType, Formatter<?> formatter) {
// Checks modifiability before delegating to super
}
/**
* Add a Printer/Parser pair to be used for the given field type.
* Throws UnsupportedOperationException if this is an unmodifiable shared instance.
*
* @param fieldType the field type to format
* @param printer the printer to add
* @param parser the parser to add
* @throws UnsupportedOperationException if the conversion service is unmodifiable
*/
@Override
public void addFormatterForFieldType(Class<?> fieldType, Printer<?> printer, Parser<?> parser) {
// Checks modifiability before delegating to super
}
/**
* Add a AnnotationFormatterFactory to be used for fields annotated with a specific annotation.
* Throws UnsupportedOperationException if this is an unmodifiable shared instance.
*
* @param annotationFormatterFactory the annotation formatter factory to add
* @throws UnsupportedOperationException if the conversion service is unmodifiable
*/
@Override
public void addFormatterForFieldAnnotation(
AnnotationFormatterFactory<? extends Annotation> annotationFormatterFactory) {
// Checks modifiability before delegating to super
}
/**
* Add a plain Converter to this registry.
* Throws UnsupportedOperationException if this is an unmodifiable shared instance.
*
* @param converter the converter to add
* @throws UnsupportedOperationException if the conversion service is unmodifiable
*/
@Override
public void addConverter(Converter<?, ?> converter) {
// Checks modifiability before delegating to super
}
/**
* Add a plain Converter for the given source/target type pair.
* Throws UnsupportedOperationException if this is an unmodifiable shared instance.
*
* @param sourceType the source type to convert from
* @param targetType the target type to convert to
* @param converter the converter to add
* @param <S> the source type
* @param <T> the target type
* @throws UnsupportedOperationException if the conversion service is unmodifiable
*/
@Override
public <S, T> void addConverter(Class<S> sourceType, Class<T> targetType,
Converter<? super S, ? extends T> converter) {
// Checks modifiability before delegating to super
}
/**
* Add a GenericConverter to this registry.
* Throws UnsupportedOperationException if this is an unmodifiable shared instance.
*
* @param converter the generic converter to add
* @throws UnsupportedOperationException if the conversion service is unmodifiable
*/
@Override
public void addConverter(GenericConverter converter) {
// Checks modifiability before delegating to super
}
/**
* Add a ConverterFactory to this registry.
* Throws UnsupportedOperationException if this is an unmodifiable shared instance.
*
* @param factory the converter factory to add
* @throws UnsupportedOperationException if the conversion service is unmodifiable
*/
@Override
public void addConverterFactory(ConverterFactory<?, ?> factory) {
// Checks modifiability before delegating to super
}
/**
* Remove any converters from sourceType to targetType.
* Throws UnsupportedOperationException if this is an unmodifiable shared instance.
*
* @param sourceType the source type
* @param targetType the target type
* @throws UnsupportedOperationException if the conversion service is unmodifiable
*/
@Override
public void removeConvertible(Class<?> sourceType, Class<?> targetType) {
// Checks modifiability before delegating to super
}
/**
* Return true if objects of sourceType can be converted to the targetType
* and the converter has Object.class as a supported source type.
* This is useful for detecting generic ObjectTo* converters.
*
* @param sourceType the source type to test (must not be null)
* @param targetType the target type to test (must not be null)
* @return true if conversion happens through an ObjectTo... converter
* @throws IllegalArgumentException if sourceType or targetType is null
* @since 2.4.3
*/
public boolean isConvertViaObjectSourceType(
TypeDescriptor sourceType,
TypeDescriptor targetType) {
// Checks if conversion path goes through Object source type
}
/**
* Return a shared default application ConversionService instance,
* lazily building it once needed. This is the recommended way to
* obtain a conversion service in most applications.
*
* Note: This method actually returns an ApplicationConversionService
* instance. However, the ConversionService signature has been preserved
* for binary compatibility.
*
* Thread Safety: This method is thread-safe. The instance is created
* using double-checked locking and safely published.
*
* @return the shared ApplicationConversionService instance (never null)
*/
public static ConversionService getSharedInstance() {
// Double-checked locking for lazy initialization
// Returns singleton instance
}
/**
* Configure the given FormatterRegistry with formatters and converters
* appropriate for most Spring Boot applications.
*
* This is the main entry point for adding Spring Boot converters to an
* existing conversion service.
*
* @param registry the registry of converters to add to (must also be
* castable to ConversionService, must not be null)
* @throws ClassCastException if the given FormatterRegistry could not
* be cast to a ConversionService
* @throws IllegalArgumentException if registry is null
*/
public static void configure(FormatterRegistry registry) {
// Adds default Spring Framework converters
// Adds Spring Boot application converters
// Adds Spring Boot application formatters
}
/**
* Add converters useful for most Spring Boot applications.
* Includes converters for Duration, Period, DataSize, delimited strings,
* and more.
*
* Registered Converters:
* - StringToDurationConverter (supports @DurationUnit, @DurationFormat)
* - NumberToDurationConverter
* - DurationToNumberConverter
* - DurationToStringConverter
* - StringToPeriodConverter (supports @PeriodUnit, @PeriodFormat)
* - NumberToPeriodConverter
* - PeriodToStringConverter
* - StringToDataSizeConverter (supports @DataSizeUnit)
* - NumberToDataSizeConverter
* - StringToFileConverter
* - InputStreamSourceToByteArrayConverter
* - LenientObjectToEnumConverterFactory (case-insensitive)
* - Delimited string converters (see addDelimitedStringConverters)
*
* @param registry the registry of converters to add to (must also be
* castable to ConversionService, must not be null)
* @throws ClassCastException if the given ConverterRegistry could not
* be cast to a ConversionService
* @throws IllegalArgumentException if registry is null
*/
public static void addApplicationConverters(ConverterRegistry registry) {
// Registers all Spring Boot specific converters
}
/**
* Add converters to support delimited strings.
* These converters handle splitting strings into collections and arrays,
* and joining collections/arrays into delimited strings.
*
* Registered Converters:
* - ArrayToDelimitedStringConverter (array to comma-separated string)
* - CollectionToDelimitedStringConverter (collection to comma-separated string)
* - DelimitedStringToArrayConverter (comma-separated string to array)
* - DelimitedStringToCollectionConverter (comma-separated string to collection)
*
* These converters respect the @Delimiter annotation when present.
*
* @param registry the registry of converters to add to (must also be
* castable to ConversionService, must not be null)
* @throws ClassCastException if the given ConverterRegistry could not
* be cast to a ConversionService
* @throws IllegalArgumentException if registry is null
*/
public static void addDelimitedStringConverters(ConverterRegistry registry) {
// Registers delimited string converters with @Delimiter support
}
/**
* Add formatters useful for most Spring Boot applications.
*
* Registered Formatters:
* - CharArrayFormatter (char[] to String and vice versa)
* - InetAddressFormatter (InetAddress to String and vice versa)
* - IsoOffsetFormatter (OffsetDateTime/OffsetTime formatting)
*
* @param registry the service to register default formatters with (must not be null)
* @throws IllegalArgumentException if registry is null
*/
public static void addApplicationFormatters(FormatterRegistry registry) {
// Registers Spring Boot specific formatters
}
/**
* Add Printer, Parser, Formatter, Converter, ConverterFactory,
* GenericConverter, and beans from the specified bean factory.
*
* This method discovers all converter beans in the application context
* and registers them with the conversion service.
*
* @param registry the service to register beans with (must not be null)
* @param beanFactory the bean factory to get the beans from (must not be null)
* @throws IllegalArgumentException if registry or beanFactory is null
* @since 2.2.0
*/
public static void addBeans(
FormatterRegistry registry,
ListableBeanFactory beanFactory) {
// Discovers and registers converter beans without qualifier filtering
addBeans(registry, beanFactory, null);
}
/**
* Add Printer, Parser, Formatter, Converter, ConverterFactory,
* GenericConverter, and beans from the specified bean factory that
* match the specified qualifier.
*
* This method discovers converter beans with specific qualifiers
* (e.g., @Qualifier("myConverters")) and registers them.
*
* @param registry the service to register beans with (must not be null)
* @param beanFactory the bean factory to get the beans from (must not be null)
* @param qualifier the qualifier required on the beans or null for no filtering
* @return a map of bean names to registered bean instances (never null)
* @throws IllegalArgumentException if registry or beanFactory is null
* @since 3.5.0
*/
public static Map<String, Object> addBeans(
FormatterRegistry registry,
ListableBeanFactory beanFactory,
String qualifier) {
// Discovers and registers qualified converter beans
// Returns map of registered bean names to instances
}
}Enumeration defining how duration values are parsed and formatted.
Thread Safety: Enums are immutable and thread-safe. All parsing and printing operations are stateless.
package org.springframework.boot.convert;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.regex.Pattern;
/**
* Duration format styles for parsing and printing Duration values.
*
* Thread Safety: Immutable and thread-safe. All operations are stateless.
*
* @since 2.0.0
*/
public enum DurationStyle {
/**
* Simple formatting, for example '1s', '10ms', '5m'.
* Supports human-readable duration strings with optional unit suffixes.
*
* Pattern: ^([+-]?\\d+)([a-zA-Z]{0,2})$
*
* Supported Units:
* - ns = nanoseconds
* - us = microseconds
* - ms = milliseconds (default if no unit specified)
* - s = seconds
* - m = minutes
* - h = hours
* - d = days
*
* Examples:
* - "10" -> 10 milliseconds (default)
* - "10s" -> 10 seconds
* - "5m" -> 5 minutes
* - "2h" -> 2 hours
* - "1d" -> 1 day
* - "-10s" -> negative 10 seconds
*/
SIMPLE("^([+-]?\\d+)([a-zA-Z]{0,2})$") {
@Override
public Duration parse(String value, ChronoUnit unit) {
// Parses simple format with optional unit suffix
// Uses provided unit as default if no suffix present
// Supports positive and negative values
}
@Override
public String print(Duration value, ChronoUnit unit) {
// Prints in simple format with appropriate unit
// Chooses most concise representation
// e.g., 1000ms printed as "1s"
}
},
/**
* ISO-8601 formatting, for example 'PT20.345S', 'PT15M', 'PT10H'.
* Uses standard ISO-8601 duration format (PnDTnHnMnS).
*
* Pattern: ^[+-]?[pP].*$
*
* Format Components:
* - P = period designator (required)
* - nD = number of days
* - T = time designator (required if hour/minute/second specified)
* - nH = number of hours
* - nM = number of minutes
* - nS = number of seconds (can be fractional)
*
* Examples:
* - "PT10S" -> 10 seconds
* - "PT5M" -> 5 minutes
* - "PT2H" -> 2 hours
* - "P2D" -> 2 days
* - "PT1H30M" -> 1 hour 30 minutes
* - "PT0.5S" -> 500 milliseconds
*/
ISO8601("^[+-]?[pP].*$") {
@Override
public Duration parse(String value, ChronoUnit unit) {
// Delegates to Java's Duration.parse()
// Ignores unit parameter (format is self-contained)
}
@Override
public String print(Duration value, ChronoUnit unit) {
// Uses Java's Duration.toString()
// Returns ISO-8601 format
}
};
private final Pattern pattern;
DurationStyle(String pattern) {
this.pattern = Pattern.compile(pattern);
}
/**
* Parse the given value to a duration using the default unit (milliseconds).
*
* @param value the value to parse (must not be null)
* @return a duration (never null)
* @throws IllegalArgumentException if value is null or cannot be parsed
*/
public Duration parse(String value) {
return parse(value, null);
}
/**
* Parse the given value to a duration.
*
* @param value the value to parse (must not be null)
* @param unit the duration unit to use if the value doesn't specify one
* (null will default to milliseconds for SIMPLE style,
* ignored for ISO8601 style)
* @return a duration (never null)
* @throws IllegalArgumentException if value is null or cannot be parsed
* @throws java.time.format.DateTimeParseException if ISO8601 format is invalid
*/
public abstract Duration parse(String value, ChronoUnit unit);
/**
* Print the specified duration using default formatting.
*
* @param value the value to print (must not be null)
* @return the printed result (never null)
* @throws IllegalArgumentException if value is null
*/
public String print(Duration value) {
return print(value, null);
}
/**
* Print the specified duration using the given unit.
*
* @param value the value to print (must not be null)
* @param unit the value to use for printing (may be null to use most appropriate)
* @return the printed result (never null)
* @throws IllegalArgumentException if value is null
*/
public abstract String print(Duration value, ChronoUnit unit);
/**
* Detect the style then parse the value to return a duration.
* Tries SIMPLE first, then ISO8601.
*
* @param value the value to parse (must not be null)
* @return the parsed duration (never null)
* @throws IllegalArgumentException if the value is not a known style
* or cannot be parsed
*/
public static Duration detectAndParse(String value) {
return detectAndParse(value, null);
}
/**
* Detect the style then parse the value to return a duration.
* Pattern matching is used to determine which style to use.
*
* @param value the value to parse (must not be null)
* @param unit the duration unit to use if the value doesn't specify one
* (null will default to milliseconds for SIMPLE style,
* ignored for ISO8601 style)
* @return the parsed duration (never null)
* @throws IllegalArgumentException if the value is not a known style
* or cannot be parsed
*/
public static Duration detectAndParse(String value, ChronoUnit unit) {
// Pattern matches to determine style
// Attempts parsing with detected style
// Throws IllegalArgumentException if no style matches
}
/**
* Detect the style from the given source value.
* Uses pattern matching to determine the format style.
*
* @param value the source value (must not be null)
* @return the duration style (never null)
* @throws IllegalArgumentException if the value is null or
* does not match any known style
*/
public static DurationStyle detect(String value) {
// Tests value against each style's pattern
// Returns first matching style
// Throws IllegalArgumentException if no match
}
/**
* Check if this style matches the given value.
*
* @param value the value to check (must not be null)
* @return true if this style can parse the value
*/
protected boolean matches(String value) {
return this.pattern.matcher(value).matches();
}
}Enumeration defining how period values (date-based durations) are parsed and formatted.
Thread Safety: Enums are immutable and thread-safe. All parsing and printing operations are stateless.
package org.springframework.boot.convert;
import java.time.Period;
import java.time.temporal.ChronoUnit;
import java.util.regex.Pattern;
/**
* Period format styles for parsing and printing Period values.
*
* Thread Safety: Immutable and thread-safe. All operations are stateless.
*
* @since 2.3.0
*/
public enum PeriodStyle {
/**
* Simple formatting, for example '1y', '6m', '30d', '1y6m10d'.
* Supports human-readable period strings with unit suffixes.
*
* Pattern: ^(?:([-+]?[0-9]+)Y)?(?:([-+]?[0-9]+)M)?(?:([-+]?[0-9]+)W)?(?:([-+]?[0-9]+)D)?$
*
* Supported Units (case-insensitive):
* - y or Y = years
* - m or M = months
* - w or W = weeks (converted to days: 1w = 7d)
* - d or D = days (default if no unit specified)
*
* Examples:
* - "10" -> 10 days (default)
* - "1y" -> 1 year
* - "6m" -> 6 months
* - "2w" -> 14 days (2 weeks)
* - "30d" -> 30 days
* - "1y6m10d" -> 1 year, 6 months, 10 days
* - "-1y" -> negative 1 year
*/
SIMPLE("^(?:([-+]?[0-9]+)Y)?(?:([-+]?[0-9]+)M)?(?:([-+]?[0-9]+)W)?(?:([-+]?[0-9]+)D)?$",
Pattern.CASE_INSENSITIVE) {
@Override
public Period parse(String value, ChronoUnit unit) {
// Parses simple format with optional unit suffixes
// Supports combination: "1y2m3d"
// Converts weeks to days
}
@Override
public String print(Period value, ChronoUnit unit) {
// Prints in simple format with unit suffixes
// e.g., Period.of(1, 6, 10) -> "1y6m10d"
}
},
/**
* ISO-8601 formatting, for example 'P1Y', 'P6M', 'P1Y6M10D'.
* Uses standard ISO-8601 period format (PnYnMnD).
*
* Pattern: ^[+-]?P.*$
*
* Format Components:
* - P = period designator (required)
* - nY = number of years
* - nM = number of months
* - nD = number of days
*
* Examples:
* - "P1Y" -> 1 year
* - "P6M" -> 6 months
* - "P30D" -> 30 days
* - "P1Y6M10D" -> 1 year, 6 months, 10 days
* - "-P1Y" -> negative 1 year
*/
ISO8601("^[+-]?P.*$", Pattern.CASE_INSENSITIVE) {
@Override
public Period parse(String value, ChronoUnit unit) {
// Delegates to Java's Period.parse()
// Ignores unit parameter (format is self-contained)
}
@Override
public String print(Period value, ChronoUnit unit) {
// Uses Java's Period.toString()
// Returns ISO-8601 format
}
};
private final Pattern pattern;
PeriodStyle(String pattern, int flags) {
this.pattern = Pattern.compile(pattern, flags);
}
/**
* Parse the given value to a Period using the default unit (days).
*
* @param value the value to parse (must not be null)
* @return a period (never null)
* @throws IllegalArgumentException if value is null or cannot be parsed
*/
public Period parse(String value) {
return parse(value, null);
}
/**
* Parse the given value to a period.
*
* @param value the value to parse (must not be null)
* @param unit the period unit to use if the value doesn't specify one
* (null will default to days for SIMPLE style,
* ignored for ISO8601 style)
* @return a period (never null)
* @throws IllegalArgumentException if value is null or cannot be parsed
* @throws java.time.format.DateTimeParseException if ISO8601 format is invalid
*/
public abstract Period parse(String value, ChronoUnit unit);
/**
* Print the specified period using default formatting.
*
* @param value the value to print (must not be null)
* @return the printed result (never null)
* @throws IllegalArgumentException if value is null
*/
public String print(Period value) {
return print(value, null);
}
/**
* Print the specified period using the given unit.
*
* @param value the value to print (must not be null)
* @param unit the value to use for printing (may be null to use all components)
* @return the printed result (never null)
* @throws IllegalArgumentException if value is null
*/
public abstract String print(Period value, ChronoUnit unit);
/**
* Detect the style then parse the value to return a period.
* Tries SIMPLE first, then ISO8601.
*
* @param value the value to parse (must not be null)
* @return the parsed period (never null)
* @throws IllegalArgumentException if the value is not a known style
* or cannot be parsed
*/
public static Period detectAndParse(String value) {
return detectAndParse(value, null);
}
/**
* Detect the style then parse the value to return a period.
* Pattern matching is used to determine which style to use.
*
* @param value the value to parse (must not be null)
* @param unit the period unit to use if the value doesn't specify one
* (null will default to days for SIMPLE style,
* ignored for ISO8601 style)
* @return the parsed period (never null)
* @throws IllegalArgumentException if the value is not a known style
* or cannot be parsed
*/
public static Period detectAndParse(String value, ChronoUnit unit) {
// Pattern matches to determine style
// Attempts parsing with detected style
// Throws IllegalArgumentException if no style matches
}
/**
* Detect the style from the given source value.
* Uses pattern matching to determine the format style.
*
* @param value the source value (must not be null)
* @return the period style (never null)
* @throws IllegalArgumentException if the value is null or
* does not match any known style
*/
public static PeriodStyle detect(String value) {
// Tests value against each style's pattern
// Returns first matching style
// Throws IllegalArgumentException if no match
}
/**
* Check if this style matches the given value.
*
* @param value the value to check (must not be null)
* @return true if this style can parse the value
*/
protected boolean matches(String value) {
return this.pattern.matcher(value).matches();
}
}package org.springframework.boot.convert;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Annotation that can be used to indicate the format to use when
* converting a Duration from a String.
*
* Used with @ConfigurationProperties and other property binding mechanisms.
*
* @since 2.0.0
*/
@Target({ ElementType.FIELD, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DurationFormat {
/**
* The duration format style to use for parsing.
*
* @return the duration format style (SIMPLE or ISO8601)
*/
DurationStyle value();
}package org.springframework.boot.convert;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.time.temporal.ChronoUnit;
/**
* Annotation that can be used to change the default unit used when
* converting a Duration from a String without an explicit unit suffix.
*
* Without this annotation, the default unit is MILLIS.
* With this annotation, you can specify any ChronoUnit (NANOS, MICROS,
* MILLIS, SECONDS, MINUTES, HOURS, DAYS, etc.)
*
* @since 2.0.0
*/
@Target({ ElementType.FIELD, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DurationUnit {
/**
* The duration unit to use if one is not specified in the value.
*
* Common values:
* - ChronoUnit.NANOS
* - ChronoUnit.MICROS
* - ChronoUnit.MILLIS (default without annotation)
* - ChronoUnit.SECONDS
* - ChronoUnit.MINUTES
* - ChronoUnit.HOURS
* - ChronoUnit.DAYS
*
* @return the duration unit (must not be null)
*/
ChronoUnit value();
}package org.springframework.boot.convert;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Annotation that can be used to indicate the format to use when
* converting a Period from a String.
*
* Used with @ConfigurationProperties and other property binding mechanisms.
*
* @since 2.3.0
*/
@Target({ ElementType.FIELD, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PeriodFormat {
/**
* The Period format style to use for parsing.
*
* @return the period format style (SIMPLE or ISO8601)
*/
PeriodStyle value();
}package org.springframework.boot.convert;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.time.temporal.ChronoUnit;
/**
* Annotation that can be used to change the default unit used when
* converting a Period from a String without an explicit unit suffix.
*
* Without this annotation, the default unit is DAYS.
* With this annotation, you can specify DAYS, WEEKS, MONTHS, or YEARS.
*
* @since 2.3.0
*/
@Target({ ElementType.FIELD, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PeriodUnit {
/**
* The Period unit to use if one is not specified in the value.
*
* Valid values for periods:
* - ChronoUnit.DAYS (default without annotation)
* - ChronoUnit.WEEKS
* - ChronoUnit.MONTHS
* - ChronoUnit.YEARS
*
* @return the Period unit (must not be null)
*/
ChronoUnit value();
}package org.springframework.boot.convert;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.util.unit.DataUnit;
/**
* Annotation that can be used to change the default unit used when
* converting a DataSize from a String without an explicit unit suffix.
*
* Without this annotation, the default unit is BYTES.
* With this annotation, you can specify any DataUnit (BYTES, KILOBYTES,
* MEGABYTES, GIGABYTES, TERABYTES).
*
* @since 2.1.0
*/
@Target({ ElementType.FIELD, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataSizeUnit {
/**
* The DataUnit to use if one is not specified in the value.
*
* Available units:
* - DataUnit.BYTES (default without annotation)
* - DataUnit.KILOBYTES
* - DataUnit.MEGABYTES
* - DataUnit.GIGABYTES
* - DataUnit.TERABYTES
*
* @return the data unit (must not be null)
*/
DataUnit value();
}package org.springframework.boot.convert;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Declares a field or method parameter should be converted to a collection
* using the specified delimiter when binding from a String.
*
* Without this annotation, the default delimiter is comma (,).
* Use NONE to treat the entire string as a single collection element.
*
* @since 2.0.0
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.METHOD, ElementType.FIELD,
ElementType.PARAMETER, ElementType.ANNOTATION_TYPE })
public @interface Delimiter {
/**
* A delimiter value used to indicate that no delimiter is required
* and the result should be a single element containing the entire string.
*
* Use this when your values might contain the delimiter character.
*/
String NONE = "";
/**
* The delimiter to use or NONE if the entire contents should be
* treated as a single element.
*
* Common delimiters:
* - "," (default without annotation)
* - ";"
* - "|"
* - " " (space)
* - NONE (no splitting)
*
* @return the delimiter (must not be null)
*/
String value();
}import org.springframework.boot.convert.DurationStyle;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
public class DurationExample {
public static void main(String[] args) {
parseExamples();
printExamples();
errorHandling();
}
public static void parseExamples() {
// Simple style (auto-detected)
Duration d1 = DurationStyle.detectAndParse("10s"); // 10 seconds
Duration d2 = DurationStyle.detectAndParse("5m"); // 5 minutes
Duration d3 = DurationStyle.detectAndParse("2h"); // 2 hours
Duration d4 = DurationStyle.detectAndParse("1d"); // 1 day
Duration d5 = DurationStyle.detectAndParse("100"); // 100 ms (default)
Duration d6 = DurationStyle.detectAndParse("500ms"); // 500 milliseconds
Duration d7 = DurationStyle.detectAndParse("10ns"); // 10 nanoseconds
Duration d8 = DurationStyle.detectAndParse("-10s"); // negative 10 seconds
// ISO-8601 style (auto-detected)
Duration d9 = DurationStyle.detectAndParse("PT10S"); // 10 seconds
Duration d10 = DurationStyle.detectAndParse("PT5M"); // 5 minutes
Duration d11 = DurationStyle.detectAndParse("PT2H30M"); // 2h 30m
Duration d12 = DurationStyle.detectAndParse("P2D"); // 2 days
// Explicit style
Duration d13 = DurationStyle.SIMPLE.parse("30s");
Duration d14 = DurationStyle.ISO8601.parse("PT30S");
// With default unit (overrides default milliseconds)
Duration d15 = DurationStyle.detectAndParse("10", ChronoUnit.SECONDS);
// Parses "10" as 10 seconds instead of 10 milliseconds
Duration d16 = DurationStyle.detectAndParse("5", ChronoUnit.MINUTES);
// Parses "5" as 5 minutes
System.out.println("d1 (10s): " + d1.getSeconds() + " seconds");
System.out.println("d15 (10 with SECONDS unit): " + d15.getSeconds() + " seconds");
}
public static void printExamples() {
Duration duration = Duration.ofMinutes(5);
// Simple style
String s1 = DurationStyle.SIMPLE.print(duration);
System.out.println("Simple: " + s1); // "5m"
// ISO-8601 style
String s2 = DurationStyle.ISO8601.print(duration);
System.out.println("ISO-8601: " + s2); // "PT5M"
// With specific unit
String s3 = DurationStyle.SIMPLE.print(duration, ChronoUnit.SECONDS);
System.out.println("As seconds: " + s3); // "300s"
// Complex duration
Duration complex = Duration.ofHours(2).plusMinutes(30).plusSeconds(15);
System.out.println("Complex SIMPLE: " + DurationStyle.SIMPLE.print(complex));
System.out.println("Complex ISO8601: " + DurationStyle.ISO8601.print(complex));
}
public static void errorHandling() {
try {
// Invalid format
Duration invalid = DurationStyle.detectAndParse("invalid");
} catch (IllegalArgumentException e) {
System.err.println("Parse error: " + e.getMessage());
}
try {
// Wrong style
Duration wrong = DurationStyle.SIMPLE.parse("PT10S");
} catch (IllegalArgumentException e) {
System.err.println("Style mismatch: " + e.getMessage());
}
}
}import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.convert.DurationUnit;
import org.springframework.boot.convert.DurationFormat;
import org.springframework.boot.convert.DurationStyle;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Min;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
@ConfigurationProperties("app.timeout")
@Validated
@Component
@EnableConfigurationProperties
public class TimeoutProperties {
/**
* Connection timeout.
* Default: accepts "30s", "30000ms", "PT30S"
* Default unit: milliseconds
*/
@NotNull
private Duration connection = Duration.ofSeconds(30);
/**
* Read timeout with seconds as default unit.
* "30" parsed as 30 seconds (not 30 milliseconds)
*/
@DurationUnit(ChronoUnit.SECONDS)
@Min(1)
private Duration read = Duration.ofSeconds(10);
/**
* Write timeout - only accepts ISO-8601 format.
* Must be specified as "PT1M", "PT30S", etc.
*/
@DurationFormat(DurationStyle.ISO8601)
@NotNull
private Duration write = Duration.ofSeconds(30);
/**
* Idle timeout - combination of seconds as default unit
* with simple format enforced.
*/
@DurationUnit(ChronoUnit.SECONDS)
@DurationFormat(DurationStyle.SIMPLE)
private Duration idle = Duration.ofMinutes(5);
// Getters and setters with validation
public Duration getConnection() {
return connection;
}
public void setConnection(Duration connection) {
if (connection.isNegative()) {
throw new IllegalArgumentException("Connection timeout must be positive");
}
this.connection = connection;
}
public Duration getRead() {
return read;
}
public void setRead(Duration read) {
this.read = read;
}
public Duration getWrite() {
return write;
}
public void setWrite(Duration write) {
this.write = write;
}
public Duration getIdle() {
return idle;
}
public void setIdle(Duration idle) {
this.idle = idle;
}
}# application.yml
app:
timeout:
connection: 30s # 30 seconds (simple format)
read: 10 # 10 seconds (due to @DurationUnit)
write: PT1M # 1 minute (ISO-8601 required)
idle: 300 # 300 seconds (due to @DurationUnit)# application.properties
app.timeout.connection=30s
app.timeout.read=10
app.timeout.write=PT1M
app.timeout.idle=300import org.springframework.boot.convert.PeriodStyle;
import java.time.Period;
import java.time.temporal.ChronoUnit;
public class PeriodExample {
public static void main(String[] args) {
parseExamples();
printExamples();
businessLogicExample();
}
public static void parseExamples() {
// Simple style (case-insensitive)
Period p1 = PeriodStyle.detectAndParse("1y"); // 1 year
Period p2 = PeriodStyle.detectAndParse("6M"); // 6 months
Period p3 = PeriodStyle.detectAndParse("30d"); // 30 days
Period p4 = PeriodStyle.detectAndParse("2w"); // 2 weeks (14 days)
Period p5 = PeriodStyle.detectAndParse("1Y6M10D"); // 1 year 6 months 10 days
Period p6 = PeriodStyle.detectAndParse("90"); // 90 days (default)
Period p7 = PeriodStyle.detectAndParse("-1y"); // negative 1 year
// ISO-8601 style
Period p8 = PeriodStyle.detectAndParse("P1Y"); // 1 year
Period p9 = PeriodStyle.detectAndParse("P6M"); // 6 months
Period p10 = PeriodStyle.detectAndParse("P1Y6M10D"); // 1 year 6 months 10 days
// Explicit style
Period p11 = PeriodStyle.SIMPLE.parse("1y");
Period p12 = PeriodStyle.ISO8601.parse("P1Y");
// With default unit
Period p13 = PeriodStyle.detectAndParse("90", ChronoUnit.DAYS);
Period p14 = PeriodStyle.detectAndParse("12", ChronoUnit.MONTHS);
System.out.println("p1 years: " + p1.getYears());
System.out.println("p5: " + p5.getYears() + "y " +
p5.getMonths() + "m " + p5.getDays() + "d");
}
public static void printExamples() {
Period period = Period.of(1, 6, 10); // 1y 6m 10d
// Simple style
String s1 = PeriodStyle.SIMPLE.print(period);
System.out.println("Simple: " + s1); // "1y6m10d"
// ISO-8601 style
String s2 = PeriodStyle.ISO8601.print(period);
System.out.println("ISO-8601: " + s2); // "P1Y6M10D"
// Period with only days
Period daysOnly = Period.ofDays(90);
System.out.println("90 days simple: " + PeriodStyle.SIMPLE.print(daysOnly));
System.out.println("90 days ISO: " + PeriodStyle.ISO8601.print(daysOnly));
}
public static void businessLogicExample() {
// Data retention policy
Period retentionPeriod = PeriodStyle.detectAndParse("1y");
java.time.LocalDate today = java.time.LocalDate.now();
java.time.LocalDate expirationDate = today.minus(retentionPeriod);
System.out.println("Data older than " + expirationDate + " should be archived");
}
}import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.convert.Delimiter;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Set;
import java.util.ArrayList;
@ConfigurationProperties("app")
@Component
public class DelimitedProperties {
/**
* Tags - default delimiter is comma.
* "tag1,tag2,tag3" -> ["tag1", "tag2", "tag3"]
*/
private List<String> tags = new ArrayList<>();
/**
* Hosts - custom semicolon delimiter.
* "host1;host2;host3" -> ["host1", "host2", "host3"]
*/
@Delimiter(";")
private List<String> hosts = new ArrayList<>();
/**
* Ports - pipe delimiter with Set to remove duplicates.
* "8080|8443|9000" -> [8080, 8443, 9000]
*/
@Delimiter("|")
private Set<Integer> ports;
/**
* No delimiter - entire value as single element.
* "a,b,c" -> ["a,b,c"] (not split)
*/
@Delimiter(Delimiter.NONE)
private List<String> description = new ArrayList<>();
/**
* Space-delimited list.
* "value1 value2 value3" -> ["value1", "value2", "value3"]
*/
@Delimiter(" ")
private List<String> keywords = new ArrayList<>();
// Getters and setters
public List<String> getTags() {
return tags;
}
public void setTags(List<String> tags) {
this.tags = tags;
}
public List<String> getHosts() {
return hosts;
}
public void setHosts(List<String> hosts) {
this.hosts = hosts;
}
public Set<Integer> getPorts() {
return ports;
}
public void setPorts(Set<Integer> ports) {
this.ports = ports;
}
public List<String> getDescription() {
return description;
}
public void setDescription(List<String> description) {
this.description = description;
}
public List<String> getKeywords() {
return keywords;
}
public void setKeywords(List<String> keywords) {
this.keywords = keywords;
}
}# application.yml
app:
tags: production,backend,critical
hosts: server1.example.com;server2.example.com;server3.example.com
ports: 8080|8443|9000
description: This is a single value, not split on commas
keywords: java spring microservices cloudimport org.springframework.boot.convert.ApplicationConversionService;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.TypeDescriptor;
import org.springframework.stereotype.Component;
import org.springframework.util.unit.DataSize;
import java.time.Duration;
import java.time.Period;
import java.util.List;
@Component
public class ConversionExample {
private final ConversionService conversionService;
public ConversionExample() {
// Use shared instance (recommended for most cases)
this.conversionService = ApplicationConversionService.getSharedInstance();
}
public void convertExamples() {
// String to Duration
Duration d1 = conversionService.convert("30s", Duration.class);
System.out.println("Duration: " + d1.getSeconds() + " seconds");
// String to Period
Period p1 = conversionService.convert("90d", Period.class);
System.out.println("Period: " + p1.getDays() + " days");
// String to DataSize
DataSize size = conversionService.convert("10MB", DataSize.class);
System.out.println("DataSize: " + size.toMegabytes() + " MB");
// Delimited string to List (requires TypeDescriptor)
TypeDescriptor sourceType = TypeDescriptor.valueOf(String.class);
TypeDescriptor targetType = TypeDescriptor.collection(List.class,
TypeDescriptor.valueOf(String.class));
@SuppressWarnings("unchecked")
List<String> list = (List<String>) conversionService.convert(
"a,b,c",
sourceType,
targetType
);
System.out.println("List: " + list);
// Check if conversion is possible
boolean canConvert = conversionService.canConvert(
String.class,
Duration.class
);
System.out.println("Can convert String to Duration: " + canConvert);
}
public void errorHandlingExample() {
try {
// Invalid duration format
Duration invalid = conversionService.convert("not-a-duration", Duration.class);
} catch (Exception e) {
System.err.println("Conversion error: " + e.getMessage());
// Handle error appropriately
}
}
}import org.springframework.boot.convert.ApplicationConversionService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.converter.Converter;
import org.springframework.format.FormatterRegistry;
import java.util.regex.Pattern;
import java.util.regex.Matcher;
@Configuration
public class ConversionConfiguration {
/**
* Create custom conversion service with additional converters.
*/
@Bean
public ConversionService conversionService() {
ApplicationConversionService service = new ApplicationConversionService();
// Add custom converters
service.addConverter(new StringToEmailConverter());
service.addConverter(new StringToPhoneNumberConverter());
service.addConverter(new StringToUrlConverter());
return service;
}
/**
* Custom converter for email addresses
*/
public static class StringToEmailConverter implements Converter<String, Email> {
private static final Pattern EMAIL_PATTERN = Pattern.compile(
"^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$"
);
@Override
public Email convert(String source) {
if (source == null || source.trim().isEmpty()) {
return null;
}
source = source.trim().toLowerCase();
if (!EMAIL_PATTERN.matcher(source).matches()) {
throw new IllegalArgumentException(
"Invalid email format: " + source
);
}
return new Email(source);
}
}
/**
* Custom converter for phone numbers
*/
public static class StringToPhoneNumberConverter implements Converter<String, PhoneNumber> {
private static final Pattern PHONE_PATTERN = Pattern.compile(
"^\\+?[1-9]\\d{1,14}$"
);
@Override
public PhoneNumber convert(String source) {
if (source == null || source.trim().isEmpty()) {
return null;
}
// Remove common separators
String cleaned = source.replaceAll("[\\s()-]", "");
if (!PHONE_PATTERN.matcher(cleaned).matches()) {
throw new IllegalArgumentException(
"Invalid phone number format: " + source
);
}
return new PhoneNumber(cleaned);
}
}
/**
* Custom converter for URLs with validation
*/
public static class StringToUrlConverter implements Converter<String, java.net.URL> {
@Override
public java.net.URL convert(String source) {
if (source == null || source.trim().isEmpty()) {
return null;
}
try {
return new java.net.URL(source);
} catch (java.net.MalformedURLException e) {
throw new IllegalArgumentException(
"Invalid URL: " + source, e
);
}
}
}
/**
* Alternative: Configure existing conversion service via customizer
*/
@Bean
public ConversionServiceConfigurer conversionServiceConfigurer() {
return registry -> {
// Add standard Spring Boot converters
ApplicationConversionService.addApplicationConverters(registry);
// Add custom converters
registry.addConverter(new StringToEmailConverter());
registry.addConverter(new StringToPhoneNumberConverter());
};
}
@FunctionalInterface
public interface ConversionServiceConfigurer {
void configure(FormatterRegistry registry);
}
}
// Supporting value objects
class Email {
private final String address;
public Email(String address) {
this.address = address;
}
public String getAddress() {
return address;
}
}
class PhoneNumber {
private final String number;
public PhoneNumber(String number) {
this.number = number;
}
public String getNumber() {
return number;
}
}import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.convert.DataSizeUnit;
import org.springframework.stereotype.Component;
import org.springframework.util.unit.DataSize;
import org.springframework.util.unit.DataUnit;
import javax.validation.constraints.NotNull;
@ConfigurationProperties("app.storage")
@Component
public class StorageProperties {
/**
* Cache size - default unit is bytes.
* "10MB" or "10485760" (bytes)
*/
@NotNull
private DataSize cacheSize = DataSize.ofMegabytes(256);
/**
* Max upload size - default unit is megabytes.
* "10" parsed as 10MB
*/
@DataSizeUnit(DataUnit.MEGABYTES)
private DataSize maxUploadSize = DataSize.ofMegabytes(50);
/**
* Disk quota - default unit is gigabytes.
* "100" parsed as 100GB
*/
@DataSizeUnit(DataUnit.GIGABYTES)
private DataSize diskQuota = DataSize.ofTerabytes(1);
/**
* Buffer size - default unit is kilobytes.
* "64" parsed as 64KB
*/
@DataSizeUnit(DataUnit.KILOBYTES)
private DataSize bufferSize = DataSize.ofKilobytes(64);
// Getters and setters with validation
public DataSize getCacheSize() {
return cacheSize;
}
public void setCacheSize(DataSize cacheSize) {
if (cacheSize.toBytes() <= 0) {
throw new IllegalArgumentException("Cache size must be positive");
}
this.cacheSize = cacheSize;
}
public DataSize getMaxUploadSize() {
return maxUploadSize;
}
public void setMaxUploadSize(DataSize maxUploadSize) {
this.maxUploadSize = maxUploadSize;
}
public DataSize getDiskQuota() {
return diskQuota;
}
public void setDiskQuota(DataSize diskQuota) {
this.diskQuota = diskQuota;
}
public DataSize getBufferSize() {
return bufferSize;
}
public void setBufferSize(DataSize bufferSize) {
this.bufferSize = bufferSize;
}
// Utility methods
public boolean hasEnoughDiskSpace(DataSize required) {
return diskQuota.toBytes() >= required.toBytes();
}
public boolean isWithinUploadLimit(long fileSizeBytes) {
return fileSizeBytes <= maxUploadSize.toBytes();
}
}# application.yml
app:
storage:
cache-size: 256MB # Explicit unit
max-upload-size: 50 # 50MB (due to @DataSizeUnit)
disk-quota: 1TB # Explicit unit
buffer-size: 64 # 64KB (due to @DataSizeUnit)import org.springframework.boot.convert.DurationStyle;
import org.springframework.boot.convert.PeriodStyle;
import java.time.Duration;
import java.time.Period;
import java.time.format.DateTimeParseException;
public class ConversionErrorHandling {
public static Duration parseDurationSafely(String value, Duration defaultValue) {
try {
return DurationStyle.detectAndParse(value);
} catch (IllegalArgumentException e) {
System.err.println("Invalid duration format: " + value);
System.err.println("Error: " + e.getMessage());
return defaultValue;
} catch (DateTimeParseException e) {
System.err.println("Invalid ISO-8601 duration: " + value);
System.err.println("Error: " + e.getMessage());
return defaultValue;
}
}
public static Period parsePeriodSafely(String value, Period defaultValue) {
try {
return PeriodStyle.detectAndParse(value);
} catch (IllegalArgumentException e) {
System.err.println("Invalid period format: " + value);
System.err.println("Error: " + e.getMessage());
return defaultValue;
} catch (DateTimeParseException e) {
System.err.println("Invalid ISO-8601 period: " + value);
System.err.println("Error: " + e.getMessage());
return defaultValue;
}
}
public static void main(String[] args) {
// Valid parsing
Duration d1 = parseDurationSafely("30s", Duration.ofSeconds(10));
System.out.println("Parsed: " + d1.getSeconds() + "s");
// Invalid parsing - uses default
Duration d2 = parseDurationSafely("invalid", Duration.ofSeconds(10));
System.out.println("Default: " + d2.getSeconds() + "s");
// Style detection failure
Duration d3 = parseDurationSafely("notaduration", Duration.ZERO);
System.out.println("Fallback: " + d3.getSeconds() + "s");
}
}import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.convert.DurationUnit;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Positive;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
@ConfigurationProperties("app.timeouts")
@Validated
@Component
public class ValidatedTimeoutProperties {
@NotNull(message = "Connection timeout must be specified")
@DurationUnit(ChronoUnit.SECONDS)
private Duration connection;
@Positive(message = "Read timeout must be positive")
@DurationUnit(ChronoUnit.SECONDS)
private Duration read = Duration.ofSeconds(30);
// Custom validation
public void setConnection(Duration connection) {
if (connection != null && connection.toSeconds() > 300) {
throw new IllegalArgumentException(
"Connection timeout cannot exceed 300 seconds"
);
}
this.connection = connection;
}
public Duration getConnection() {
return connection;
}
public Duration getRead() {
return read;
}
public void setRead(Duration read) {
this.read = read;
}
}import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.convert.DurationUnit;
import org.springframework.boot.convert.DataSizeUnit;
import org.springframework.stereotype.Component;
import org.springframework.util.unit.DataSize;
import org.springframework.util.unit.DataUnit;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.HashMap;
@ConfigurationProperties("tenants")
@Component
public class MultiTenantProperties {
private Map<String, TenantConfig> configs = new HashMap<>();
public static class TenantConfig {
@DurationUnit(ChronoUnit.SECONDS)
private Duration sessionTimeout = Duration.ofMinutes(30);
@DataSizeUnit(DataUnit.MEGABYTES)
private DataSize maxStoragePerUser = DataSize.ofGigabytes(1);
private int maxConcurrentUsers = 100;
// Getters and setters
public Duration getSessionTimeout() {
return sessionTimeout;
}
public void setSessionTimeout(Duration sessionTimeout) {
this.sessionTimeout = sessionTimeout;
}
public DataSize getMaxStoragePerUser() {
return maxStoragePerUser;
}
public void setMaxStoragePerUser(DataSize maxStoragePerUser) {
this.maxStoragePerUser = maxStoragePerUser;
}
public int getMaxConcurrentUsers() {
return maxConcurrentUsers;
}
public void setMaxConcurrentUsers(int maxConcurrentUsers) {
this.maxConcurrentUsers = maxConcurrentUsers;
}
}
public Map<String, TenantConfig> getConfigs() {
return configs;
}
public void setConfigs(Map<String, TenantConfig> configs) {
this.configs = configs;
}
public TenantConfig getConfigForTenant(String tenantId) {
return configs.getOrDefault(tenantId, new TenantConfig());
}
}# application.yml
tenants:
configs:
tenant-a:
session-timeout: 1800 # 30 minutes (seconds)
max-storage-per-user: 2048 # 2GB (megabytes)
max-concurrent-users: 200
tenant-b:
session-timeout: 3600 # 1 hour (seconds)
max-storage-per-user: 5120 # 5GB (megabytes)
max-concurrent-users: 500import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.convert.DurationUnit;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
@ConfigurationProperties("features")
@Component
public class FeatureFlagProperties {
private FeatureConfig newDashboard = new FeatureConfig();
private FeatureConfig betaApi = new FeatureConfig();
public static class FeatureConfig {
private boolean enabled = false;
@DurationUnit(ChronoUnit.DAYS)
private Duration rolloutPeriod = Duration.ofDays(14);
private Instant startDate;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public Duration getRolloutPeriod() {
return rolloutPeriod;
}
public void setRolloutPeriod(Duration rolloutPeriod) {
this.rolloutPeriod = rolloutPeriod;
}
public Instant getStartDate() {
return startDate;
}
public void setStartDate(Instant startDate) {
this.startDate = startDate;
}
public boolean isInRolloutPeriod() {
if (!enabled || startDate == null) {
return false;
}
Instant now = Instant.now();
Instant endDate = startDate.plus(rolloutPeriod);
return now.isAfter(startDate) && now.isBefore(endDate);
}
}
public FeatureConfig getNewDashboard() {
return newDashboard;
}
public void setNewDashboard(FeatureConfig newDashboard) {
this.newDashboard = newDashboard;
}
public FeatureConfig getBetaApi() {
return betaApi;
}
public void setBetaApi(FeatureConfig betaApi) {
this.betaApi = betaApi;
}
}// GOOD: Clear default unit specification
@DurationUnit(ChronoUnit.SECONDS)
private Duration timeout; // "30" -> 30 seconds
// AVOID: Relying on implicit millisecond default
private Duration timeout; // "30" -> 30 milliseconds (confusing!)
// GOOD: Explicit format requirement
@DurationFormat(DurationStyle.ISO8601)
private Duration apiTimeout;
// GOOD: Appropriate data size units
@DataSizeUnit(DataUnit.MEGABYTES)
private DataSize maxUploadSize; // "50" -> 50MB// For user-facing configuration: SIMPLE (more readable)
@DurationFormat(DurationStyle.SIMPLE)
private Duration userTimeout; // Users write "30s", "5m"
// For API/interoperability: ISO8601 (standardized)
@DurationFormat(DurationStyle.ISO8601)
private Duration apiTimeout; // Systems write "PT30S", "PT5M"
// For internal configuration: Allow both (auto-detect)
private Duration internalTimeout; // Accepts both formats// Use comma for simple lists (default)
@Delimiter(",")
private List<String> tags; // "tag1,tag2,tag3"
// Use different delimiter if values contain commas
@Delimiter(";")
private List<String> descriptions; // "Description 1, with comma;Description 2"
// Use NONE if splitting is not desired
@Delimiter(Delimiter.NONE)
private List<String> singleValue; // "a,b,c" -> ["a,b,c"]
// Use space for keyword lists
@Delimiter(" ")
private List<String> keywords; // "java spring boot"// GOOD: Type-safe data sizes with automatic unit conversion
private DataSize maxSize;
long bytes = maxSize.toBytes();
long megabytes = maxSize.toMegabytes();
// AVOID: Using primitives for sizes (error-prone)
private long maxSizeBytes; // What unit? Bytes? KB? MB?@ConfigurationProperties("app")
@Validated
public class AppProperties {
@DurationUnit(ChronoUnit.SECONDS)
@Min(1)
@Max(3600)
private Duration timeout; // Must be between 1 and 3600 seconds
@NotNull
@DataSizeUnit(DataUnit.MEGABYTES)
private DataSize maxUploadSize;
@NotEmpty
@Delimiter(";")
private List<String> allowedHosts;
// Custom validation in setter
public void setTimeout(Duration timeout) {
if (timeout.isNegative() || timeout.isZero()) {
throw new IllegalArgumentException("Timeout must be positive");
}
this.timeout = timeout;
}
}// GOOD: Use shared instance (thread-safe, cached)
ConversionService service = ApplicationConversionService.getSharedInstance();
// AVOID: Creating new instances repeatedly
ConversionService service = new ApplicationConversionService(); // Wasteful// GOOD: Implement Converter interface with null safety
public class StringToEmailConverter implements Converter<String, Email> {
@Override
public Email convert(String source) {
if (source == null || source.trim().isEmpty()) {
return null; // Handle null/empty gracefully
}
// Validation and conversion logic
return new Email(source);
}
}
// Register globally or in specific conversion service/**
* Connection timeout configuration.
*
* Format: Simple duration format ("30s", "5m", "2h")
* Default unit: seconds (if no suffix provided)
* Default value: 30 seconds
* Valid range: 1-300 seconds
*
* Examples:
* - "30s" or "30" -> 30 seconds
* - "5m" -> 5 minutes
* - "PT1M" -> 1 minute (ISO-8601)
*/
@DurationUnit(ChronoUnit.SECONDS)
@Min(1)
@Max(300)
private Duration connectionTimeout = Duration.ofSeconds(30);| Input | Default Unit | Parsed As | Notes |
|---|---|---|---|
"30" | MILLISECONDS | 30 milliseconds | Default without @DurationUnit |
"10ns" | - | 10 nanoseconds | Explicit unit |
"10us" | - | 10 microseconds | Explicit unit |
"30ms" | - | 30 milliseconds | Explicit unit |
"30s" | - | 30 seconds | Explicit unit |
"5m" | - | 5 minutes | Explicit unit |
"2h" | - | 2 hours | Explicit unit |
"1d" | - | 1 day | Explicit unit |
"-10s" | - | -10 seconds | Negative duration |
"PT30S" | - | 30 seconds | ISO-8601 format |
"PT5M" | - | 5 minutes | ISO-8601 format |
"PT2H30M" | - | 2h 30m | ISO-8601 format |
"P2D" | - | 2 days | ISO-8601 format |
| Input | Default Unit | Parsed As | Notes |
|---|---|---|---|
"90" | DAYS | 90 days | Default without @PeriodUnit |
"1y" | - | 1 year | Case-insensitive |
"1Y" | - | 1 year | Case-insensitive |
"6m" | - | 6 months | Case-insensitive |
"6M" | - | 6 months | Case-insensitive |
"2w" | - | 14 days | Weeks to days |
"2W" | - | 14 days | Weeks to days |
"30d" | - | 30 days | Case-insensitive |
"30D" | - | 30 days | Case-insensitive |
"1y6m10d" | - | 1y 6m 10d | Combined format |
"1Y6M10D" | - | 1y 6m 10d | Combined format |
"P1Y" | - | 1 year | ISO-8601 format |
"P6M" | - | 6 months | ISO-8601 format |
"P1Y6M10D" | - | 1y 6m 10d | ISO-8601 format |
| Input | Default Unit | Parsed As | Notes |
|---|---|---|---|
"1024" | BYTES | 1024 bytes | Default without @DataSizeUnit |
"1KB" | - | 1 kilobyte | 1024 bytes |
"10MB" | - | 10 megabytes | 10 × 1024² bytes |
"5GB" | - | 5 gigabytes | 5 × 1024³ bytes |
"1TB" | - | 1 terabyte | 1 × 1024⁴ bytes |
ApplicationConversionService:
DurationStyle and PeriodStyle:
Conversion Operations:
Custom Converters:
// ApplicationConversionService is auto-configured
// Available for injection in Spring Boot applications
@Component
public class MyService {
private final ConversionService conversionService;
// Autowired - uses ApplicationConversionService
public MyService(ConversionService conversionService) {
this.conversionService = conversionService;
}
public void processConfig(String durationStr) {
Duration duration = conversionService.convert(durationStr, Duration.class);
// Use duration
}
}// Converters implementing Spring's Converter interface
// are automatically discovered and registered
@Component
public class MyCustomConverter implements Converter<String, MyType> {
@Override
public MyType convert(String source) {
// Conversion logic
return new MyType(source);
}
}
// Automatically registered with ApplicationConversionServiceProblem: Forgetting that durations without a unit default to milliseconds, not seconds.
// WRONG - thinks it's 30 seconds, but it's 30 milliseconds!
@ConfigurationProperties("app")
public class AppConfig {
private Duration timeout = Duration.ofSeconds(30);
// In application.yml: timeout: 30
// Actual value: 30ms (not 30s!)
}Error: Application times out immediately, connections fail rapidly.
Solution: Always use @DurationUnit or include units in config:
// CORRECT - explicit unit annotation
@ConfigurationProperties("app")
public class AppConfig {
@DurationUnit(ChronoUnit.SECONDS)
private Duration timeout; // "30" now means 30 seconds
}
// Or in configuration - explicit unit
// application.yml: timeout: 30sWhy It Happens: Spring Boot's default is milliseconds for historical reasons. Most developers expect seconds.
Problem: Mixing simple and ISO8601 format styles causes parsing errors.
@ConfigurationProperties("app")
public class AppConfig {
@DurationFormat(DurationStyle.ISO8601) // Expects "PT30S"
private Duration timeout;
}
// application.yml
app:
timeout: 30s // WRONG - simple format but ISO8601 expectedError:
IllegalArgumentException: Could not parse duration '30s'Solution: Match format style with configuration format:
// Option 1: Use SIMPLE format (most user-friendly)
@DurationFormat(DurationStyle.SIMPLE)
@DurationUnit(ChronoUnit.SECONDS)
private Duration timeout; // Accepts "30", "30s", "1m"
// Option 2: Use ISO8601 format (standards-compliant)
@DurationFormat(DurationStyle.ISO8601)
private Duration timeout; // Requires "PT30S", "PT1M"
// Option 3: Let auto-detection work (recommended)
private Duration timeout; // Accepts both formatsBest Practice: Omit @DurationFormat to support both formats automatically.
Problem: Using Period for time-based durations or Duration for date-based periods.
// WRONG - Period doesn't support hours/minutes/seconds
@ConfigurationProperties("app")
public class AppConfig {
private Period cacheExpiry; // Can't represent "30 minutes"
}
// application.yml - this will FAIL
app:
cache-expiry: 30m // Period doesn't support minutes!Error:
IllegalArgumentException: Period cannot be parsed from durationSolution: Use the right type for the time scale:
// Use Duration for time-based values (seconds, minutes, hours, days)
private Duration cacheExpiry; // For "30m", "2h", "1d"
private Duration sessionTimeout; // For "15m"
private Duration requestTimeout; // For "30s"
// Use Period for date-based values (days, months, years)
private Period retentionPeriod; // For "90d", "6m", "1y"
private Period archivePeriod; // For "7d", "1m"Rule of Thumb:
Problem: Confusion between decimal (1000-based) and binary (1024-based) units.
@ConfigurationProperties("app")
public class AppConfig {
private DataSize maxUploadSize;
}
// application.yml
app:
max-upload-size: 10KB // Is this 10,000 or 10,240 bytes?Solution: Spring Boot uses decimal units (1000-based):
// Spring Boot DataSize units (all 1000-based)
10KB = 10,000 bytes
10MB = 10,000,000 bytes
10GB = 10,000,000,000 bytes
// NOT binary units
10KB ≠ 10,240 bytes (10 KiB)
10MB ≠ 10,485,760 bytes (10 MiB)Best Practice: Be explicit about expectations:
@ConfigurationProperties("app")
public class AppConfig {
@DataSizeUnit(DataUnit.MEGABYTES) // Clear default
private DataSize maxUploadSize;
// Validate expectations
@PostConstruct
public void validate() {
long bytes = maxUploadSize.toBytes();
System.out.println("Max upload: " + bytes + " bytes");
}
}Problem: Using delimiter that appears within values causes incorrect splitting.
@ConfigurationProperties("app")
public class AppConfig {
@Delimiter(",")
private List<String> servers;
}
// application.yml
app:
servers: "server1.example.com,zone=us-east,server2.example.com"
# Expected: ["server1.example.com,zone=us-east", "server2.example.com"]
# Actual: ["server1.example.com", "zone=us-east", "server2.example.com"]Solution: Choose delimiter that doesn't appear in values or use array notation:
// Option 1: Different delimiter
@Delimiter(";")
private List<String> servers;
// servers: "server1.example.com,zone=us-east;server2.example.com"
// Option 2: Use YAML array (best)
@ConfigurationProperties("app")
public class AppConfig {
private List<String> servers; // No delimiter needed
}
// application.yml
app:
servers:
- "server1.example.com,zone=us-east"
- "server2.example.com"
// Option 3: NONE delimiter for single value with commas
@Delimiter(Delimiter.NONE)
private List<String> description; // Entire string as one elementProblem: Unit tests fail because conversion doesn't happen.
@Test
public void testDuration() {
AppConfig config = new AppConfig();
config.setTimeout("30s"); // FAILS - no converter available
}Error:
IllegalArgumentException: No converter found for String to DurationSolution: Use proper test configuration:
// Option 1: Use @SpringBootTest
@SpringBootTest
class AppConfigTest {
@Autowired
private AppConfig config; // Converters automatically available
@Test
void testTimeout() {
assertThat(config.getTimeout()).isEqualTo(Duration.ofSeconds(30));
}
}
// Option 2: Manual conversion in plain unit tests
@Test
public void testDurationParsing() {
String input = "30s";
Duration duration = DurationStyle.detectAndParse(input);
assertThat(duration).isEqualTo(Duration.ofSeconds(30));
}
// Option 3: Use @ConfigurationPropertiesTest
@ConfigurationPropertiesTest
class AppConfigTest {
@Autowired
private AppConfig config; // Lightweight test with converters
}Problem: Not realizing negative durations are valid and have specific meaning.
@ConfigurationProperties("app")
public class AppConfig {
private Duration timeout;
}
// application.yml
app:
timeout: -30s // Negative duration!Behavior: Spring Boot accepts negative durations without error.
Solution: Add validation if negative values are invalid:
@ConfigurationProperties("app")
@Validated
public class AppConfig {
@Positive // Only positive durations
private Duration timeout;
// Or custom validation
@PostConstruct
public void validate() {
if (timeout.isNegative()) {
throw new IllegalArgumentException("Timeout must be positive");
}
}
}When Negative Is Valid:
Problem: Creating FormattingConversionService manually, missing Boot converters.
// WRONG - missing Duration, Period, DataSize converters
@Configuration
public class Config {
@Bean
public ConversionService conversionService() {
return new DefaultFormattingConversionService(); // Missing Boot converters!
}
}Error: Properties with Duration, Period, or DataSize fail to bind.
Solution: Always use ApplicationConversionService:
// CORRECT - includes all Boot converters
@Configuration
public class Config {
@Bean
public ConversionService conversionService() {
return new ApplicationConversionService();
}
// Or add Boot converters to existing service
@Bean
public ConversionService customConversionService() {
FormattingConversionService service = new DefaultFormattingConversionService();
ApplicationConversionService.addApplicationConverters(service);
ApplicationConversionService.addApplicationFormatters(service);
return service;
}
}Problem: Using Java 16+ records or Kotlin data classes, conversions fail.
// Java 17+ record
@ConfigurationProperties("app")
public record AppConfig(
Duration timeout, // No setter - binding fails!
DataSize maxSize
) {}Error:
BindException: Cannot bind to property 'timeout' - no setter availableSolution: Enable constructor binding:
// Java 17+ record - use @ConstructorBinding (or auto-detected in 3.0+)
@ConfigurationProperties("app")
@ConstructorBinding // Explicit in Boot 2.x
public record AppConfig(
@DurationUnit(ChronoUnit.SECONDS)
Duration timeout,
@DataSizeUnit(DataUnit.MEGABYTES)
DataSize maxSize
) {}
// Or use @ConfigurationPropertiesScan
@SpringBootApplication
@ConfigurationPropertiesScan
public class Application {}
// Kotlin data class
@ConfigurationProperties("app")
@ConstructorBinding
data class AppConfig(
@DurationUnit(ChronoUnit.SECONDS)
val timeout: Duration,
@DataSizeUnit(DataUnit.MEGABYTES)
val maxSize: DataSize
)Problem: Invalid value in config causes silent failure or default value usage.
@ConfigurationProperties("app")
public class AppConfig {
private Duration timeout = Duration.ofSeconds(30); // Default
}
// application.yml
app:
timeout: invalid // WRONG valueBehavior: Application starts with default value (30s), no error logged!
Solution: Enable fail-fast with validation and explicit binding:
@ConfigurationProperties("app")
@Validated // Enable validation
public class AppConfig {
@NotNull // Require non-null
private Duration timeout;
// Or use @PostConstruct for custom validation
@PostConstruct
public void validate() {
Objects.requireNonNull(timeout, "timeout must be configured");
if (timeout.isNegative() || timeout.isZero()) {
throw new IllegalArgumentException("timeout must be positive");
}
}
}
// Enable fail-fast binding errors
// application.properties
spring.config.import-check.enabled=trueBest Practice: Always use @Validated with required properties to fail fast on startup.