Skip to content

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=changed stops Camel from grabbing a file that’s still being written — it waits until the size stops changing. Essential for SFTP feeds.
  • move / moveFailed archive 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.