Changelog
1.9.1 (2026-06-17)
Bug fixes
-
Custom
formatter=on enum fields no longer triggers the enum length check (#161) — The enum field-length validation measured the longest enumname()(LITERAL) or the ordinal digit count (NUMERIC) and rejected the field when that exceeded@Field(length). That premise only holds for the built-inEnumFormatter. When a field declares its ownformatter=, the enum’s name/ordinal is irrelevant — the custom formatter emits its own representation (e.g. a single-character code) — yet the check still fired and blockedload/export.The check is now skipped whenever a non-default formatter is declared, in both validation layers in lock-step: the runtime (
FieldValidator.doValidateEnumFieldLength) no longer throws, and the compile-time annotation processor (FieldChecker.checkEnumLength) no longer reports ajavacerror. This loosens an activation gate but only ever removes a hard error — strictly more permissive, round-trip-safe, no migration.
1.9.0 (2026-06-12)
New features
-
Micrometer instrumentation — new optional
fixedformat4j-micrometerartifact (#120) — decorator-based metrics for any Micrometer registry (Spring Boot Actuator, Quarkus, Micronaut, plain Java).FixedFormatMetrics.of(registry).instrument(manager)publishesfixedformat.load/fixedformat.exporttimers tagged by record class, afixedformat.parse.errorscounter tagged by record class and field, and afixedformat.metadata.cache.classesgauge; wrapper factories (countLines,countUnmatched,countParseErrors) plug into the existingFixedFormatReaderBuilderstrategy seams forfixedformat.reader.lines.processed/.unmatched/.errors. See Metrics.The core
fixedformat4jartifact is unchanged and gains no dependencies. Performance (#140): timers are cached per record class in a GC-safeClassValueand reader counters are resolved once per wrapper, so the steady-state cost is just the timer sample (twoSystem.nanoTime()calls) per operation or one counter increment per line — tens of nanoseconds, only on instrumented managers/readers; applications without the module are completely unaffected. -
Schema introspection API —
FixedFormatIntrospector(#117) — query the field layout of a@Recordclass at runtime.FixedFormatManagerImplnow also implements the new narrowFixedFormatIntrospectorinterface, whoseintrospect(Class<?>)returns one immutableFieldInfoper effective@Field(ordered by offset): property name, offset, length, data type, resolved alignment, padding char,nullChar/nullValuesentinels, formatter class, repeat count, and nested-record flag. Intended for documentation generators, UI form builders, format-drift detection, and layout assertions in tests — see Schema introspection.Delivered as a separate interface rather than an addition to
FixedFormatManager, so existing third-party manager implementations remain source and binary compatible.Performance:
introspect()reuses the same cached per-class metadata asload()/export()(scan and validation run once per class); each call performs only an O(fields) mapping with no I/O. It triggers the same validation asload(), so it doubles as a startup preflight check. -
Compile-time annotation validation — new optional
fixedformat4j-processorartifact (#118) — an annotation processor that validates@Field/@Recordconfiguration duringjavac, so misconfigurations surface as compile errors instead of aFixedFormatExceptionon the first runtime use of the class. Checked at compile time: invalid@FixedFormatPatternvalues, enum values wider than@Field(length),nullChar/nullValueon primitive types and their mutual exclusion, all@Field(length = -1)rest-of-line rules, fields running past a fixed@Record(length), and overlapping field offsets. Enable it via MavenannotationProcessorPathsor GradleannotationProcessor— see Compile-time validation.Strictly opt-in: projects that do not add the processor compile and run exactly as on 1.8.x, and the runtime validation remains in place as the safety net. Note that the two layout checks (record-length overflow, overlapping offsets) are stricter than the runtime, which tolerates both silently — enabling the processor can turn latent configuration bugs in existing code into compile errors.
Performance: the processor runs inside
javaconly. It is never on the runtime classpath and adds zero runtime cost — no classloading, no reflection, no memory at application runtime. -
Java
recordsupport — constructor-based field binding (#119) —@Recordclasses can now be Javarecordtypes (JDK 16+). Annotate the record components directly;load()binds all parsed values through the canonical constructor in one call, andexport()reads through the component accessors. Every annotation —@Field,@Fields,@FixedFormatPattern,@FixedFormatDecimal,@FixedFormatNumber,@FixedFormatBoolean,@FixedFormatEnum— applies to record components exactly as to getter methods, including nested@Recordcomponents, repeating fields (count > 1), andnullChar/nullValue.@Record public record CustomerRecord( @Field(offset = 1, length = 10) String customerId, @Field(offset = 11, length = 20) String customerName) {}Strictly opt-in by class shape: conventional setter-based classes are processed exactly as before, and the artifact still runs on Java 11 — record binding activates only when a record class is encountered, which by definition requires a JDK 16+ runtime.
Performance: reflective access to the record API (component discovery, canonical-constructor lookup) happens once per class inside the cached metadata build — the same one-time cost the setter path already pays. The per-
load()hot path performs a single cachedMethodHandleconstructor invoke instead of one invoke per setter; there is noMethod.invoke-style reflection per record loaded. -
@Field.nullValue— literal null sentinel string (#130) — Complement tonullCharfor feeds where the null marker is a specific mixed-character string rather than a uniform pad of one character. A slice equal tonullValueloads asnull; anullvalue exports asnullValueverbatim, bypassing the formatter.// 4-char implied-decimal column: "0000" → 0, "9998" → null, "0501" → 50.1 @Field(offset = 1, length = 4, align = Align.RIGHT, paddingChar = '0', nullValue = "9998") @FixedFormatDecimal(decimals = 1) public BigDecimal getRate() { return rate; }Strictly opt-in (active only when non-empty; default
""), so existing records are unaffected. Applies per element on repeating fields (count > 1), exactly likenullChar. Validation rejects anullValuewhose length differs fromlength, use on rest-of-line (length = -1) or primitive-typed fields, and combiningnullCharwithnullValueon the same field — the two attributes are mutually exclusive. -
Pluggable type registry on
FixedFormatManagerImpl(#116) — Adds a builder API toFixedFormatManagerImplthat lets callers register a custom type-to-formatter mapping once at startup instead of repeatingformatter=on every@Fieldgetter for unsupported types (e.g.UUID,BigInteger,Instant).FixedFormatManager manager = FixedFormatManagerImpl.builder() .registerType(UUID.class, UUIDFormatter.class) .build();Custom registrations shadow built-in formatters; last-registration wins on duplicates and no exception is thrown. This means fixedformat4j can add new built-in types in future releases without breaking any consumer who already registered a formatter for that type.
The
FixedFormatManagerinterface is unchanged; the builder is accessed via the concrete implementation’s staticbuilder()method. The existingcreate()factory and thenew FixedFormatManagerImpl()constructor continue to work identically to before. -
FixedFormatWriter— write-side IO symmetry (#114) — AddsFixedFormatWriter, a new type incom.ancientprogramming.fixedformat4j.io.writethat provides the same ergonomic, fluent API for writing fixed-format files thatFixedFormatReaderprovides for reading. The writer owns resource management (opening, flushing, closing) and line delimiting; formatting of each record is still delegated toFixedFormatManager.export().Obtain an instance via the builder:
FixedFormatWriter writer = FixedFormatWriter.builder() .charset(StandardCharsets.ISO_8859_1) // optional; default UTF-8 .lineSeparator("\r\n") // optional; default System.lineSeparator() .build();Three target types are supported;
OutputStreamandPathadditionally accept an explicitCharsetargument (default is UTF-8):Writer—write(Writer, Iterable<?>)/write(Writer, Stream<?>)OutputStream—write(OutputStream, Iterable<?>)/write(OutputStream, Stream<?>)+ explicit-charset overloadsPath—write(Path, Iterable<?>)/write(Path, Stream<?>)+ explicit-charset overloads
Both
Iterable<?>(covers anyList,Set, or custom collection) andStream<?>(lazy, avoids materialising all records in memory) are accepted as record sources. ForStream<?>inputs the writer consumes but does not close the stream — the caller retains ownership.Heterogeneous mixed-type lists are supported out of the box: each element’s runtime type is used to locate the
@Recordannotation, soList.of(header, detail1, footer)works without extra configuration.Instances are thread-safe; all fields are
finaland eachwritecall is independent. -
FixedFormatReader.openStream()— lazy stream processing (#115) — AddsopenStream()methods toFixedFormatReaderthat return a lazyStreambacked by aSpliterator, so callers can process arbitrarily large files with bounded memory. Records are read and parsed one at a time on demand rather than loading the entire file into aListupfront.The
openprefix signals that the caller owns the stream lifecycle and must close it viatry-with-resources — closing the stream automatically closes the underlying reader or file.Two overload families are provided:
- Untyped —
openStream(Reader),openStream(InputStream),openStream(InputStream, Charset),openStream(Path),openStream(Path, Charset)— returnStream<Object> - Typed —
openStream(Reader, Class<T>),openStream(InputStream, Class<T>),openStream(Path, Class<T>)— filter to records of the requested type and returnStream<T>without requiring a cast at the call site
All configured strategies (
MultiMatchStrategy,UnmatchStrategy,ParseErrorStrategy) and theexcludeLinesfilter apply identically to the streaming path.// Process a large file record by record — only one line in memory at a time try (Stream<DetailRecord> s = reader.openStream(Path.of("data.txt"), DetailRecord.class)) { s.filter(r -> r.getAmount() > 0) .forEach(this::process); } - Untyped —
Performance improvements
Repeating fields (@Field(count > 1)) are significantly faster to load and export:
- Repeating-field metadata is cached per class — element type resolution, format instructions, formatter lookup, and per-element contexts were previously rebuilt with reflection on every
load()/export()call; they are now computed once and reused, the same design non-repeating fields have used since 1.7.0. In the bundled JMH benchmark, loading a record with a 10-element repeating field got ~1.9× faster and exporting ~1.25× faster. ByTypeFormatterresolves its delegate once —parse()andformat()no longer instantiate the type-specific formatter reflectively on every call.- Enum fields are cheaper — the enum constants array is cached instead of being cloned on every
NUMERICparse. - Fewer per-call allocations — a compiled regex in decimal parsing replaced with a simple character scan, and redundant padding loops removed from export.
- Decimal export skips
DecimalFormat— decimal values are already exactly-scaledBigDecimals when serialized, so the locale-sensitiveDecimalFormatround trip (plus grouping-character stripping) was replaced withtoPlainString(). Identical output on Latin-digit-locale JVMs, ~15% faster small-record export, and the thread-localDecimalFormatcache is gone. Output is now guaranteed locale-independent: previously, a JVM defaulting to a locale with non-Latin digits (e.g. some Arabic configurations) could export localized digit characters that the ASCII-only parser was unable to round-trip. Null values now flow throughBigDecimal.ZEROand the common path, producing the same bytes as the previous pre-computed zero string (pinned by tests, including a new locale-independence test).
A new RepeatingBenchmark was added to the benchmarks/ module to track the repeating-field path.
Bug fixes
- LEFT-aligned signed numbers no longer lose their leading digit —
Sign.PREPENDandSign.APPENDmade room for the sign by always dropping the first character of the aligned value. Correct for RIGHT alignment (where that character is padding), but with LEFT alignment it was the value’s leading digit: exporting5into a 5-wide PREPEND field produced"+ "and round-tripped as0. The sign slot is now freed from the padded side. Behaviour change: export output for@FixedFormatNumber(sign=PREPEND|APPEND)fields with LEFT alignment changes from corrupt to correct; RIGHT-aligned output is byte-for-byte unchanged. - NUMERIC enum ordinal 0 survives a round trip with
paddingChar='0'— ordinal 0 exports as an all-zeros field (e.g."000"), which padding-stripping reduced to an empty string that loaded asnull. In NUMERIC mode with'0'padding, a stripped-empty field now parses as ordinal 0. Behaviour change:load()of such a field returns the ordinal-0 constant instead ofnull; blank space-padded fields still load asnull, and LITERAL mode is unchanged. - Parse failures keep their root cause —
DateFormatter,LocalDateFormatter, andLocalDateTimeFormatterrethrewFixedFormatExceptionwithout chaining the underlyingParseException/DateTimeParseException, discarding error-index diagnostics. The cause is now chained; exception type and message are unchanged.
Dependency changes
- commons-lang3 removed — the library’s last 17
StringUtilscalls were replaced with Java 11 natives (String.repeat, plainsubstring, explicit null/empty checks), so the Apache Commons Lang dependency is gone.fixedformat4jnow has a single compile-scope dependency:slf4j-api. Action required only if your project relied on fixedformat4j transitively providing commons-lang3 — declare it directly in that case.
Build
- The library, samples, and benchmarks modules now compile with
--release 11instead ofsource/target, so builds on newer JDKs are validated against the Java 11 API signature. - Internal code modernized to Java 9–11 idioms: diamond on anonymous
ClassValuesubclasses,Map.ofEntriesfor the built-in formatter registry,List.copyOffor cached field metadata, and primitive parameters in the export padding path.
Known limitations (documented, unchanged)
DateFormatter(legacyjava.util.Date) uses lenientSimpleDateFormatparsing: an invalid date like20269999rolls over instead of failing. Thejava.timeformatters reject such input. PreferLocalDate/LocalDateTimefor strict validation.- Signed fields (
PREPEND/APPEND) reserve one character for the sign: a value that exactly fills the field width loses a digit. - A blank
Booleanfield loads asfalse(notnull), and anullvalue exports as the configured falseValue. - Pattern-based formatters assume the pattern produces fixed-length output; variable-length patterns (e.g.
MMMM) may interact badly with padding restoration.
Refactoring
FieldValidatorextracted — six validation methods moved out ofFixedFormatManagerImplinto a dedicated package-private class; the manager now has a single responsibility (load/export orchestration).DecimalFormatCacheextracted —DecimalFormatthread-local caching infrastructure separated fromAbstractDecimalFormatter, keeping formatting logic and caching concerns in separate classes.- Reduced boilerplate in number formatters — duplicate
asString()bodies inIntegerFormatter,ShortFormatter, andLongFormattercollapsed via a sharedvalueOrNull()helper onAbstractNumberFormatter. - Reduced boilerplate in decimal formatters — duplicate
asObject()pattern inDoubleFormatter,FloatFormatter, andBigDecimalFormattercollapsed via a sharedresolveDecimalString()helper onAbstractDecimalFormatter.
No behaviour change for existing annotated record classes, custom formatters, or serialized fixed-width data.
1.8.2 (2026-06-17)
Bug fixes
- Custom
formatter=on enum fields no longer triggers the enum length check (#161) — The enum field-length validation measured the longest enumname()(LITERAL) or the ordinal digit count (NUMERIC) and rejected the field when that exceeded@Field(length). When a field declares its ownformatter=, the enum’s name/ordinal is irrelevant — the custom formatter emits its own representation (e.g. a single-character code) — yet the check still fired and blockedload/export. The runtime check is now skipped whenever a non-default formatter is declared. This loosens an activation gate but only ever removes a hard error — strictly more permissive, round-trip-safe, no migration. Forward-ported from the 1.7.4 maintenance release.
1.8.1 (2026-05-05)
Performance improvements
load() and export() are measurably faster, especially for workloads that process many records or hit the same record class repeatedly:
- Less reflection per call — formatter instances are resolved once at cache-warm time instead of being created fresh on every field operation.
- Date and time fields are cheaper —
DateTimeFormatterandSimpleDateFormatinstances are cached and reused, eliminating the most expensive allocation in date parsing and formatting. - Tighter memory use — internal maps are pre-sized and constant string values are pre-computed at startup rather than rebuilt on every operation.
No API or behaviour change. Existing annotated record classes, custom formatters, and serialized fixed-width data are unaffected. Upgrade by bumping the version number.
1.8.0 (2026-05-01)
Breaking changes
-
Removed deprecated
protected FixedFormatManagerImpl#readDataAccordingFieldAnnotation(#109) — The method was deprecated since #77 (1.7.0) and was never on the liveload()path, which has usedClassMetadataCacheexclusively since that release. Only consumers thatextend FixedFormatManagerImpland override or call this method are affected; consumers using theFixedFormatManagerinterface are unaffected.Migration: subclassers should drive parsing through
FixedFormatManager#load(Class, String)instead. -
@Record(align)now usesRecordAligninstead ofAlign(#81) — A new two-value enumRecordAlign { LEFT, RIGHT }replacesAlignas the type of@Record#align(). BecauseAlignincludes theINHERITsentinel, which has no meaning at the record level, the old type admitted a combination that was only detectable at runtime (and was rejected with aFixedFormatExceptionsince 1.7.1).RecordAlignmakes that mistake impossible at compile time and removes the runtime check.Migration: replace
Align.LEFT/Align.RIGHTwithRecordAlign.LEFT/RecordAlign.RIGHTon every@Recordannotation that specifies thealignattribute:// Before (1.7.x) @Record(length = 20, align = Align.RIGHT) public class MyRecord { … } // After (1.8.0+) @Record(length = 20, align = RecordAlign.RIGHT) public class MyRecord { … }Records that do not specify
alignare unaffected — the default (RecordAlign.LEFT) preserves the existing behaviour. TheAlignenum itself is unchanged and continues to be used for@Field(align = …).
New features
-
FixedFormatReader— file and stream processing (#82, #95) — Reads fixed-format records from files, streams, orReaders line-by-line, routing each line to one or more@Record-annotated classes viaLinePatterndiscriminators. Three factories cover the common cases:LinePattern.prefix("HDR"),LinePattern.positional(int[], String)for multi-position checks (e.g. type code at offset 0..2 plus a sub-type at offset 7..8), andLinePattern.matchAll()for catch-all routing. Patterns are bucketed into hash tables at build time, so per-line routing is near O(1) regardless of how many record types are registered.FixedFormatReaderis unparameterized.Two output shapes:
read()— returnsReadResult, a type-safe class-keyed container;get(Class<R>)returnsList<R>with no cast required. Also providesgetAll(),contains(Class<?>), andclasses().process(source, HandlerRegistry)— push-style; dispatches each parsed record to the typedConsumer<R>registered in a per-callHandlerRegistry. Classes absent from the registry are silently ignored. Because the registry is supplied at call time, the same reader is safe to use from multiple threads.
Every shape accepts
Reader,InputStream, orPath; stream overloads default to UTF-8.Three configurable strategies:
MultiMatchStrategy(firstMatch/throwOnAmbiguity/allMatches),UnmatchStrategy(skip/throwException), andParseErrorStrategy(throwException/skipAndLog). AnexcludeLines(Predicate<String>)pre-filter runs before pattern matching and bypassesUnmatchStrategy.RecordMapping<T>is the public value type carrying the class and pattern for each registered mapping; it is surfaced as the parameter and return type ofMultiMatchStrategy.resolve(). Consumers implementing a customMultiMatchStrategymust reference it directly.FixedFormatIOException(extendsFixedFormatException) is thrown on underlyingIOException.import com.ancientprogramming.fixedformat4j.io.read.LinePattern; FixedFormatReader reader = FixedFormatReader.builder() .addMapping(HeaderRecord.class, LinePattern.prefix("HDR")) .addMapping(DetailRecord.class, LinePattern.prefix("DTL")) .build(); ReadResult result = reader.read(Path.of("data.txt")); List<HeaderRecord> headers = result.get(HeaderRecord.class); // no cast List<DetailRecord> details = result.get(DetailRecord.class); // no castSee File processing for a complete guide.
-
@Field(length = -1)— rest-of-line field (#97) — AStringfield may now declarelength = -1to capture everything from itsoffsetto the end of the line, regardless of how long that line is. This is useful for free-text trailers, comments, or variable-length payloads appended after a fixed-width prefix.Constraints validated at startup: only
Stringreturn type,count = 1, noalign, nopaddingCharoverride, nonullChar. The@Field(length = -1)field must be the last field (highestoffset) in the record. Combining@Record(length = …)with a rest-of-line field is rejected because record-level padding would corrupt the verbatim round-trip.@Record public class LogRecord { @Field(offset = 1, length = 3) public String getLevel() { … } // e.g. "INF", "ERR" @Field(offset = 4, length = -1) public String getMessage() { … } // captures remainder of each line }
Bug fixes
-
Classloader leak prevention via
ClassValue(#89) — The three JVM-level caches (ClassMetadataCache,FixedFormatManagerImpl.VALIDATED_CLASSES, andAbstractPatternFormatter.PATTERN_LENGTH_CACHE) were backed by staticConcurrentHashMap<Class<?>, …>instances. AConcurrentHashMapholds strong references to its keys, so aClassused as a key can never be garbage-collected — even after all application references to it are gone. In multi-classloader environments (OSGi, servlet containers, Spring Boot DevTools, Jakarta EE) this causes the childClassLoaderthat defined the record class to be retained indefinitely, leaking all classes it loaded.All three caches are now backed by
ClassValue<T>. Computed values are stored inside theClassobject itself; when the record class’s definingClassLoaderbecomes unreachable the cached metadata is collected with it — no external map, no leak.No API or behaviour change. Existing annotated record classes, custom formatters, and serialized fixed-width data are unaffected.
1.7.4 (2026-06-17)
Bug fixes
-
Custom
formatter=on enum fields no longer triggers the enum length check (#161) — The enum field-length validation measured the longest enumname()(LITERAL) or the ordinal digit count (NUMERIC) and rejected the field when that exceeded@Field(length). When a field declares its ownformatter=, the enum’s name/ordinal is irrelevant — the custom formatter emits its own representation (e.g. a single-character code) — yet the check still fired and blockedload/export. The runtime check is now skipped whenever a non-default formatter is declared. This loosens an activation gate but only ever removes a hard error — strictly more permissive, round-trip-safe, no migration.1.7.4 is the corrected republish of 1.7.3, whose Maven Central artifact was built from the wrong sources by a release-tooling bug; use 1.7.4.
1.7.2 (2026-04-20)
Behaviour changes
-
@Field(nullChar = …)now activates whennullChar == paddingChar(#84) — The activation gate for null-aware handling is relaxed so that settingnullCharequal topaddingCharis a supported, idiomatic configuration — the “blank-is-null” convention. Previously this combination was documented as a no-op; it now enables the same load and export semantics as the distinct-sentinel configuration.Typical uses:
// All spaces means null (e.g. optional date) @Field(offset = 1, length = 8, paddingChar = ' ', nullChar = ' ') public Date getInvoiceDate() { … } // All zeros means null (e.g. optional numeric with zero-padding) @Field(offset = 9, length = 5, align = Align.RIGHT, paddingChar = '0', nullChar = '0') public Integer getQuantity() { … }Migration: the prior
nullChar != paddingCharactivation rule is removed from the javadoc. Records that intentionally setnullChar == paddingCharto get no-op behaviour (none known) should omitnullCharinstead.
1.7.1 (2026-04-18)
Breaking changes
-
@Field.align()default changed fromAlign.LEFTtoAlign.INHERIT— The raw annotation value returned byfieldAnnotation.align()is nowAlign.INHERITfor any field that does not setalignexplicitly. The effective runtime behaviour is unchanged — the framework resolvesINHERITtoLEFTvia the enclosing@Record’saligndefault — but code that reads the annotation directly (annotation processors, reflection tools, custom bootstrap code) and passes the result toAlign.apply()orAlign.remove()will now receive anUnsupportedOperationException.Migration: read
FormatInstructions.getAlignment()instead of the raw annotation (it is already resolved), or guard againstAlign.INHERITbefore callingapply/remove. -
Align.INHERITnew enum constant — TheAlignenum gains a third value.switchstatements overAlignwithout an explicitdefaultarm now have an unhandled case. Add adefault:branch (or an explicitcase INHERIT:) that throws or delegates appropriately.
New features
-
Opt-in
nullCharattribute on@Fieldto represent null values (#29) — Adds anullCharattribute to the@Fieldannotation that lets callers distinguish a genuinely-absent field from a zero or empty value.Activation rule: null-aware handling is enabled only when
nullChardiffers frompaddingChar. The default value ('\0') is a sentinel that can never appear in a regular fixed-width payload, so all existing records retain their pre-1.7.1 behaviour unchanged.- On load — if every character in the field slice equals
nullChar, the setter is not invoked and the field staysnull. ConfiguringnullCharon a primitive-typed field throwsFixedFormatExceptionat validation time. - On export — if the getter returns
null, the field is emitted aslengthcopies ofnullChar, bypassing the formatter entirely.
For repeating fields (
count > 1) the check is applied per element: each slot is evaluated independently. Primitive array element types (e.g.int[]) cannot holdnulland are unaffected.// Null and zero are now distinguishable: // " " (spaces) → null "00042" → 42 @Field(offset = 1, length = 5, align = Align.RIGHT, paddingChar = '0', nullChar = ' ') public Integer getAmount() { … } - On load — if every character in the field slice equals
-
Record-level default alignment via
@Record(align = …)(#30) — Adds analignattribute to the@Recordannotation that sets a default alignment for all fields in the record. Individual fields may still override it with an explicit@Field(align = …). The effective runtime behaviour for existing records is unchanged:@Record.align()defaults toAlign.LEFT, and fields that inherit that default continue to behave as they did before. See the breaking-change note above regarding the raw@Field.align()annotation value.// Before — alignment repeated on every field @Record(length = 20) public class MyRecord { @Field(offset = 1, length = 10, align = Align.RIGHT, paddingChar = '0') public Integer getField1() { … } @Field(offset = 11, length = 10, align = Align.RIGHT, paddingChar = '0') public Integer getField2() { … } } // After — alignment declared once at record level @Record(length = 20, align = Align.RIGHT) public class MyRecord { @Field(offset = 1, length = 10, paddingChar = '0') public Integer getField1() { … } @Field(offset = 11, length = 10, paddingChar = '0') public Integer getField2() { … } }
Bug fixes
- Null nested
@Recordfield now exports as padding instead of throwing (#45) — Exporting a parent record whose nested@Recordfield isnullpreviously threw aFixedFormatException. It now outputs the field’spaddingCharrepeated for the declared@Fieldlength, consistent with how all other formatters handlenullvalues.
1.7.0 (2026-04-18)
Breaking changes
-
AbstractFixedFormatter.getRemovePaddingremoved — deprecated in 1.6.1 and now deleted. Rename any override tostripPadding; the signature is identical. The call chain is nowparse()→stripPadding()directly.// Before (1.6.x) @Override protected String getRemovePadding(String value, FormatInstructions instructions) { … } // After (1.7.0+) @Override protected String stripPadding(String value, FormatInstructions instructions) { … }
New features
-
Enum support via
@FixedFormatEnum(#67) — Annotate any getter that returns anenumtype with@FixedFormatEnumto control how the value is serialised in the fixed-width record. Two modes are available through theEnumFormatenum:LITERAL(default) — stores and reads the enum constant name (Enum.name()/valueOf()).NUMERIC— stores and reads the ordinal as a zero-padded integer (Enum.ordinal()/ index lookup).
public enum Status { ACTIVE, INACTIVE } // LITERAL (default): stores "ACTIVE" / "INACTIVE" @Field(offset = 1, length = 8) @FixedFormatEnum public Status getStatus() { … } // NUMERIC: stores "0" / "1" @Field(offset = 1, length = 1) @FixedFormatEnum(EnumFormat.NUMERIC) public Status getStatus() { … }
Performance improvements
-
Field metadata caching (#77) —
ClassMetadataCacheprecomputes and caches all field descriptors per annotated class on first use, eliminating repeated annotation scanning on everyload()/export()call. The cache is process-wide and thread-safe. -
MethodHandle dispatch (#75) — Getter and setter invocation now uses
MethodHandleinstead ofMethod.invoke(), reducing per-call overhead after JIT warmup. -
Reduced string allocations (#76) — Padding and sign handling rewritten to minimise intermediate
Stringobject creation per field.
1.6.1 (2026-04-10)
Bug fixes
DateFormatter(andLocalDateFormatter/LocalDateTimeFormatter) no longer over-strips padding characters (#33) — When the configuredpaddingCharhappened to be a character that also appears in the formatted date string (e.g.paddingChar = '0'with a time value whose seconds component is00), the previousstripPaddingimplementation removed those characters from the parsed string, leaving it too short and causing aParseException. The fix introducesAbstractPatternFormatter, which overridesstripPaddingto remove only leading/trailing padding characters rather than all occurrences of the character.
Deprecations
-
AbstractFixedFormatter.getRemovePaddingdeprecated — The method has been renamed tostripPadding, which better reflects its behaviour (it transforms a string, not returns a value). The old name carried a misleadinggetprefix that implied a zero-argument accessor.getRemovePaddingremains callable and fully functional in 1.6.1; it now delegates tostripPadding. It will be removed in 1.7.0.Migration: rename any override of
getRemovePaddingtostripPadding— the signature is identical:// Before (1.6.0 and earlier) @Override protected String getRemovePadding(String value, FormatInstructions instructions) { … } // After (1.6.1+) @Override protected String stripPadding(String value, FormatInstructions instructions) { … }Call chain in 1.6.1:
parse()→getRemovePadding()→stripPadding()Call chain in 1.7.0:
parse()→stripPadding()(direct;getRemovePaddingremoved)
1.6.0 (2026-04-09)
New features
-
Repeating fields — A single
@Fieldannotation can now map consecutive same-format slots in a record to a Java array or orderedCollectionvia the newcountattribute. Setcountto the number of repetitions; the getter/setter must returnT[],List<T>,LinkedList<T>,Set<T>,SortedSet<T>, orCollection<T>. Each slot occupieslengthcharacters, starting atoffset + length * index.// Three 5-character product codes packed consecutively from position 1 @Field(offset = 1, length = 5, count = 3) public String[] getProductCodes() { return productCodes; } // Same field mapped to a List @Field(offset = 1, length = 5, count = 3) public List<String> getProductCodes() { return productCodes; }The new
strictCountattribute (defaulttrue) controls what happens when the collection size does not matchcountat export time:truethrows aFixedFormatException;falselogs a warning and exportsmin(count, actualSize)elements.// Lenient: export however many elements are present, up to count @Field(offset = 1, length = 5, count = 3, strictCount = false) public List<String> getProductCodes() { return productCodes; }See Repeating fields in the annotation reference and Example 7 for a full walkthrough.
-
LocalDateTimesupport —java.time.LocalDateTimeis now a first-class field type handled automatically byByTypeFormatter. No custom formatter needed.@FixedFormatPatternis optional — only specify it when your format differs from the default (yyyy-MM-dd'T'HH:mm:ss).// Default pattern — no @FixedFormatPattern needed @Field(offset = 1, length = 19) public LocalDateTime getCreatedAt() { return createdAt; } // Custom pattern — only required when overriding the default @Field(offset = 1, length = 14) @FixedFormatPattern("yyyyMMddHHmmss") public LocalDateTime getCreatedAt() { return createdAt; }String
"2026-04-09T14:30:00"parses toLocalDateTime.of(2026, 4, 9, 14, 30, 0); exporting writes"2026-04-09T14:30:00"back.
1.5.0 (2026-04-08)
New features
-
Field-level
@Fieldand@Fieldsannotations —@Fieldand@Fieldscan now be placed directly on Java fields in addition to getter methods. The manager discovers them at runtime and derives the getter/setter by theget/isnaming convention. This enables clean usage with Lombok (@Getter/@Setter) and reduces boilerplate in plain POJOs.// Plain POJO — annotate the field instead of the getter @Record public class EmployeeRecord { @Field(offset = 1, length = 10) private String name; @Field(offset = 11, length = 8) @FixedFormatPattern("yyyyMMdd") private LocalDate hireDate; public String getName() { return name; } public void setName(String name) { this.name = name; } public LocalDate getHireDate() { return hireDate; } public void setHireDate(LocalDate hireDate) { this.hireDate = hireDate; } } // With Lombok — no getter/setter boilerplate needed @Getter @Setter @NoArgsConstructor @Record public class EmployeeRecord { @Field(offset = 1, length = 10) private String name; @Field(offset = 11, length = 8) @FixedFormatPattern("yyyyMMdd") private LocalDate hireDate; }If both the field and its getter carry
@Field, an error is logged (configuration mismatch) and the field annotation is used.
1.4.0 (2026-04-05)
New features
-
LocalDatesupport —java.time.LocalDateis now a first-class field type handled automatically byByTypeFormatter. No custom formatter needed. Configure the date pattern with@FixedFormatPattern(default:yyyyMMdd).@Field(offset = 1, length = 8) @FixedFormatPattern("yyyyMMdd") public LocalDate getEventDate() { return eventDate; }String
"20260405"parses toLocalDate.of(2026, 4, 5); exporting writes"20260405"back.
Breaking changes
- Java 11 minimum — Java 8 is no longer supported. The minimum required runtime is Java 11.
- Logging: SLF4J replaces Commons Logging — The library no longer depends on Apache Commons Logging. Logging is now done via SLF4J. If your project relied on the transitive
commons-loggingdependency, you will need to add an SLF4J binding instead (e.g.logback-classicorslf4j-simple). See Get It for details.
Documentation
- Added Quick Start guide, Examples page, and an enriched Annotations reference.
1.3.4 (2010-12-14)
Bug fixes
- Issue #23 — Removed unnecessary restriction of generic type
Ttojava.lang.NumberinAbstractNumberFormatter. - Issue #24 — Reverted the
paddingCharhonouring change introduced in 1.3.3 (issue #22).
1.3.3 (2010-12-14)
New features
- Issue #20 —
AbstractDecimalFormatternow supports explicit rounding.
Bug fixes
- Issue #16 —
@FixedFormatDecimalwith more than 3 decimal places truncated the fractional digits. - Issue #21 —
AbstractDecimalFormatterDecimalFormatusage was not thread-safe. - Issue #22 —
AbstractDecimalFormatterhard-coded'0'as the padding character instead of honouring thepaddingCharannotation setting.
1.3.2 (2010-12-03)
Bug fixes
- Issue #18 —
Sign.APPEND.applyfailed to detect the minus symbol correctly.
1.3.1
Bug fixes
- Issue #14 — Fixed
NullPointerExceptionduring export.
1.3.0
New features
- Issue #7 — Nested
@Recordsupport: a class annotated with@Recordcan now contain fields whose type is itself a@Record-annotated class. Useful for grouping logically related domain objects (e.g. card details inside a larger record). - Issue #10 —
ParseExceptionnow exposes getter methods so callers can retrieve structured failure details and build localised error messages rather than relying on the English exception message. - Issue #13 — Support for skipping unparseable fields within records.
- Added built-in
Short/shortformatter; registered inByTypeFormatter.
1.2.2 (2008-10-17)
Bug fixes
- Issue #9 — Fixed a loading failure when the input string was slightly shorter than the offset of the last field in the record.
1.2.1 (2008-10-15)
New features
- Issue #8 — Added support for annotated static nested classes and inner classes.
- Issue #6 — Added support for primitive types (
int,boolean,float, etc.) in addition to their boxed counterparts. (contributed by Marcos Lois Bermúdez)
Bug fixes
- Fixed a runtime failure when a getter or setter declared an interface or abstract class as its type and the format manager could not determine the concrete data type. (contributed by Marcos Lois Bermúdez)
1.2.0 (2008-06-12)
New features
- Issue #5 — Getters starting with
is(in addition toget) can now carry@Fieldannotations. - Improved error reporting on parse failures: error messages now include the full format context (class name, method name, and all relevant annotation settings).
1.1.1 (2008-05-29)
New features
- Added the ability to leave numbers unsigned; unsigned is now the default.
Changes
- Issue #4 —
FixedFormatterinterface generified. Custom formatters must be updated to specify the type parameter.
Bug fixes
- Fixed a bug when parsing numbers from strings with prepended signs.
- Fixed various smaller bugs in the built-in formatters.
1.1.0 (2008-05-26)
New features
- Introduced the ability to parse and format signed numbers (
Sign.PREPEND,Sign.APPEND).
1.0.0 (2008-05-25)
Initial release of fixedformat4j.