Changelog
Journey since 1.4.0
After many years of inactivity, fixedformat4j was revived with the 1.4.0 release. Here is a summary of everything that has improved since then:
LocalDateandLocalDateTimesupport — both are now built-in types inByTypeFormatterwith type-specific default patterns and eager pattern validation.- Repeating fields via
@Field(count)— map a list of same-format fields with a single annotation; optionalstrictCountenforces list size on export. - Field-level
@Field/@Fieldsannotations — place annotations directly on a Java field instead of its getter; works with plain POJOs and Lombok (@Getter/@Setter). - Date padding bug fixed — date formatters no longer over-strip padding characters that appear inside the formatted value (#33).
- Maven Central distribution — no GitHub account or personal access token required; standard
<dependency>block just works. - Negative decimal fix — parsing trailing-sign negatives with implicit decimals (e.g.
000000001-) no longer throwsNumberFormatException. - Modernised build — Java 11, SLF4J, commons-lang3, JUnit 5 with comprehensive test coverage.
- PIT mutation testing — live quality badges and a published mutation report on every release.
- New documentation site — full Markdown docs at jeyben.github.io/fixedformat4j (Quick Start, Annotations reference, Examples, FAQ, Changelog).
[Unreleased] — 1.8.0
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.
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.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.