Parsing Fixed-Length and Dynamic Banking Files with Apache Camel
If you’ve worked on core banking integrations, you know the truth: a huge amount of money still moves around as flat files. Fixed-length records for ledgers and account masters, delimited files for some feeds, and rigid formats like ACH/NACHA for payments. They arrive over SFTP on a schedule, and your job is to read them reliably, transform them, and hand them to the next system.
The naive approach is a wall of line.substring(0, 10), line.substring(10, 30)
calls. It works until the spec changes by two characters and every offset after
it shifts. Apache Camel with Bindy gives you a far more maintainable model:
describe the record as a Java class, and let Camel do the slicing.
Modelling a fixed-length record
Bindy maps each field to a position and length. A simplified general-ledger record might look like this:
import org.apache.camel.dataformat.bindy.annotation.DataField;
import org.apache.camel.dataformat.bindy.annotation.FixedLengthRecord;
@FixedLengthRecord(length = 80)
public class GlRecord {
@DataField(pos = 1, length = 10, trim = true)
private String accountNumber;
@DataField(pos = 11, length = 30, trim = true)
private String description;
// Bindy reads the implied-decimal amount; precision keeps the cents.
@DataField(pos = 41, length = 15, precision = 2)
private BigDecimal amount;
@DataField(pos = 56, length = 8, pattern = "yyyyMMdd")
private Date postingDate;
// getters / setters
}
Note pos is 1-based in Bindy — a common first-time gotcha. The
precision attribute handles implied-decimal amounts (a classic banking quirk
where 000000000150000 means 1500.00).
The route
With the model in place, the Camel route reads to a clean, declarative pipeline:
import org.apache.camel.builder.RouteBuilder;
import org.apache.camel.dataformat.bindy.fixed.BindyFixedLengthDataFormat;
public class GlFileRoute extends RouteBuilder {
@Override
public void configure() {
var bindy = new BindyFixedLengthDataFormat(GlRecord.class);
from("sftp://user@bank-host:22/outbound/gl"
+ "?password=RAW({{bank.sftp.password}})"
+ "&move=.done&moveFailed=.error"
+ "&readLock=changed&delay=60000")
.routeId("gl-file-inbound")
.log("Picked up ${header.CamelFileName}")
.unmarshal(bindy) // List<Map<String, Object>> or POJOs
.split(body()) // one exchange per record
.streaming()
.to("bean:glPostingService?method=post")
.end();
}
}
A few things doing real work here:
readLock=changedstops Camel from grabbing a file that’s still being written — it waits until the size stops changing. Essential for SFTP feeds.move/moveFailedarchive processed and failed files so you never reprocess and you keep an audit trail.split(body()).streaming()processes records one at a time instead of loading a million-line file into memory.
What about dynamic / variable-length files?
Not everything is fixed-width. For delimited feeds, swap Bindy’s CSV format in:
var csv = new BindyCsvDataFormat(StatementRow.class);
with @CsvRecord(separator = "|") on the model. For genuinely irregular,
multi-record-type files — a header row, N detail rows, then a trailer — Bindy’s
@Link and key-value record support, or a small custom DataFormat, keeps
the parsing logic in one place instead of scattered across the route.
Why this beats hand-rolled parsing
- The format lives in one annotated class, not in offset arithmetic spread across the codebase.
- Spec changes become an edit to
pos/length, not a bug hunt. - The route reads like the integration it describes: pick up → parse → split → process → archive.
In the next post I cover the operational half of this: making the SFTP pickup idempotent and resilient to failure with redelivery and dead-letter channels.