Architecture

May 30, 2026 · View on GitHub

A Jackson streaming dataformat module for SpreadsheetML — same pattern as jackson-dataformat-csv, applied to spreadsheets.

jackson-dataformat-csvjackson-dataformat-spreadsheet
CsvMapper extends ObjectMapperSpreadsheetMapper extends ObjectMapper
CsvParser extends ParserMinimalBaseSheetParser extends ParserMinimalBase
CsvGenerator extends GeneratorBaseSheetGenerator extends GeneratorBase
CsvFactory extends JsonFactorySpreadsheetFactory extends JsonFactory
CsvSchema via FormatVisitorSpreadsheetSchema via FormatVisitor
CsvParser.Feature implements FormatFeatureSheetParser.Feature, SpreadsheetFactory.Feature
CsvMapper.Builder extends MapperBuilderSpreadsheetMapper.Builder extends MapperBuilder
              ┌────────────────────────────────────────────────┐
              │               SpreadsheetMapper                │
              │             (extends ObjectMapper)             │
              └─────────────────┬────────────┬─────────────────┘
                                │            │
                 ┌──────────────▼────┐  ┌────▼──────────────┐
                 │    SheetParser    │  │  SheetGenerator   │
                 │  (ParserMinimal-  │  │  (GeneratorBase)  │
                 │       Base)       │  │                   │
                 └──────────┬────────┘  └─────────┬─────────┘
                            │                     │
                ┌───────────▼────────┐  ┌─────────▼──────────┐
                │    SheetReader     │  │    SheetWriter     │
                │    (interface)     │  │    (interface)     │
                └──┬───────────┬─────┘  └─────┬───────────┬──┘
                   │           │              │           │
         ┌─────────▼──┐ ┌──────▼──────┐┌──────▼─────┐ ┌───▼─────────┐
         │  POISheet  │ │  SSMLSheet  ││  POISheet  │ │  SSMLSheet  │
         │   Reader   │ │   Reader    ││   Writer   │ │   Writer    │
         │   (POI)    │ │ (Streaming) ││   (POI)    │ │ (Streaming) │
         └────────────┘ └─────────────┘└────────────┘ └─────────────┘

Jackson's Three Layers

Jackson processes data in three layers. This library plugs into all three:

LayerJacksonThis libraryRole
Data BindingObjectMapper, ObjectReader, ObjectWriterSpreadsheetMapper, SpreadsheetReader, SpreadsheetWriterPOJO serialization — inherited, not reimplemented
StreamingJsonParser, JsonGenerator, JsonFactorySheetParser, SheetGenerator, SpreadsheetFactoryFlat cell ↔ nested token translation — the core abstraction
Format I/OUTF8StreamJsonParser reads bytesSheetReader / SheetWriter (interface)Raw format read/write — swappable (Streaming or POI User Model)

Layer 3 is fully inherited — all Jackson annotations, overloads, and features work as-is. Layer 2 is where the library's core logic lives. Layer 1 is pluggable behind an interface.

Scope

This library handles data binding between POJOs and spreadsheet cells. Nothing more.

This libraryPOI (or user code)
POJO → flat cells (write)Workbook lifecycle, file I/O
Flat cells → POJO (read)Charts, images, pivot tables
Nested object flattening/reconstructionFormulas, conditional formatting
Header generation, cell stylingMacros (.xlsm)
Schema generation from class structureCell-level random access

Sheet is an I/O type — like OutputStream in jackson-core. The library reads from and writes to a POI Sheet but does not manage the Workbook lifecycle. Users open a workbook with POI, pass a Sheet to the mapper, and close the workbook themselves. This enables template-based writing, multi-sheet workbooks, and post-processing with POI — all outside the library's concern.

Token Model

The core problem: mapping flat (row, column) cells to hierarchical Jackson tokens.

Cell to Token Translation

A spreadsheet with nested POJO schema Employee { Address address; Employment employment; }:

Column index:    0     1        2             3              4
Column pointer:  /id   /name   /address/zip  /address/city  /employment/title

The mapping runs both ways — flat cells ↔ nested Jackson tokens. For one row of cell values [1, "Alice", "12345", "Seoul", "SRE"], the parser emits:

START_OBJECT
  FIELD_NAME "id"
  VALUE_NUMBER_INT 1
  FIELD_NAME "name"
  VALUE_STRING "Alice"
  FIELD_NAME "address"
  START_OBJECT
    FIELD_NAME "zip"
    VALUE_STRING "12345"
    FIELD_NAME "city"
    VALUE_STRING "Seoul"
  END_OBJECT
  FIELD_NAME "employment"
  START_OBJECT
    FIELD_NAME "title"
    VALUE_STRING "SRE"
  END_OBJECT
END_OBJECT

Jackson's data-binding layer deserializes this token stream into Employee (read), or serializes Employee into the same stream that the generator consumes (write) — no special deserializer/serializer needed.

ColumnPointer

ColumnPointer is the mechanism that drives this translation — shared by both the parser and the generator. Each schema column has a pointer representing its path in the object hierarchy:

ColumnPointer.empty().resolve("address").resolve("zip")              → address/zip
ColumnPointer.empty().resolve("address").resolve("zip").getParent()  → address
pointer1.relativize(pointer2)                                        → relative path

When the parser or generator moves between cells, it computes the relative path between the current and previous ColumnPointer. If the parent changes (e.g., address/cityemployment/title), the parser emits END_OBJECT for the old scope and START_OBJECT + FIELD_NAME for the new one; the generator does the reverse, consuming END_OBJECT / START_OBJECT boundaries to switch scope.

ColumnPointer is custom rather than reusing Jackson's JsonPointer because spreadsheet columns flatten arrays in place — items[0]/qty and items[1]/qty map to the same column, not distinct paths. ColumnPointer.resolveArray() collapses the index, while JsonPointer would preserve it.

Schema Generation

Uses Jackson's JsonFormatVisitor — the same mechanism Jackson uses internally for JSON Schema generation:

schemaGenerator.generate(Employee.class)

  ├─ serializerProvider.acceptJsonFormatVisitor(type, visitor)
  │    │
  │    └─ FormatVisitorWrapper
  │         ├─ expectObjectFormat() → ObjectFormatVisitor
  │         │    ├─ property("id", IntSerializer)
  │         │    │    └─ Column(pointer="/id", type=int)
  │         │    ├─ property("address", BeanSerializer<Address>)
  │         │    │    └─ recursive visit with pointer="/address"
  │         │    │         ├─ property("zip", StringSerializer)
  │         │    │         │    └─ Column(pointer="/address/zip", type=String)
  │         │    │         └─ property("city", StringSerializer)
  │         │    │              └─ Column(pointer="/address/city", type=String)
  │         │    └─ ...
  │         └─ expectArrayFormat() → ArrayFormatVisitor
  │              └─ recursive visit for element type

  └─ SpreadsheetSchema(columns, ...)

Read Path

mapper.readValue(file, Employee.class)

  ├─ schemaGenerator.generate(Employee.class)
  │    └─ Jackson FormatVisitor → annotation introspection → SpreadsheetSchema

  ├─ SpreadsheetFactory.createParser(file)
  │    ├─ USE_POI_USER_MODEL OR non-OOXML(input)?
  │    │   ├─ yes → POISheetReader (POI User Model)
  │    │   └─ no  → SSMLSheetReader (Streaming)
  │    └─ new SheetParser(reader, formatFeatures)

  └─ SheetParser.nextToken() loop
       ├─ SheetReader.next() → SheetToken
       ├─ row/column bounds check against schema
       ├─ ColumnPointer scope tracking
       ├─ JsonToken emission (with nesting)
       └─ Jackson BeanDeserializer consumes tokens → Employee

Dual Reader Strategy

Both implement SheetReader. Format is auto-detected via ZIP magic bytes (FileMagic.OOXML).

SSMLSheetReaderPOISheetReader
FormatXLSX (OOXML)XLS / XLSX (any POI-supported)
ParsingStAX XMLStreamReader on raw XMLPOI Sheet / Row / Cell API
MemoryStreaming — cell data not held in memory (SST grows with unique strings)Full workbook in memory
Cell typesOOXML STCellType schema enumPOI CellType enum
Shared stringsCustom SharedStringsLookup (lazy StAX)POI's built-in SharedStringsTable
When usedAuto-detected for XLSX filesXLS files, direct Sheet input, or USE_POI_USER_MODEL

When the input is an InputStream, OOXML files are copied to a temporary file — ZIP random access requires seekable I/O. The POI User Model path bypasses this copy: the stream is handed directly to POI, which decompresses the entire ZIP into memory (OOM risk on large files). Setting USE_POI_USER_MODEL forces this path even for OOXML input — the escape hatch for disk-write-restricted environments.

Pull-Pull Streaming

The streaming read path bridges two pull APIs — Jackson's nextToken() and StAX's next() — via SheetParser. The bridge token (SheetToken) carries flat sheet events (SHEET_DATA_START, ROW_START, CELL_VALUE, ROW_END, SHEET_DATA_END) which SheetParser translates into nested JsonToken for Jackson:

Jackson (pull)          SheetParser (pull)         StAX (pull)
    │                        │                        │
    ├─ nextToken() ─────────►├─ SheetReader.next() ──►├─ XMLStreamReader.next()
    │◄─ VALUE_STRING ────────┤◄─ CELL_VALUE ──────────┤◄─ START_ELEMENT <c>
    │                        │                        │

No buffering, no threading, no adapter — pull aligns with pull.

The alternative is SAX (push model), which POI's Event API uses. SAX pushes events to a handler callback, but Jackson pulls tokens from a parser. Bridging push→pull requires an intermediate buffer or a separate thread — complexity that defeats the purpose of streaming. This library bypasses POI's Event API entirely and parses OOXML XML directly via StAX.

Write Path

mapper.writeValue(file, employee)

  ├─ SpreadsheetFactory.createGenerator(file)
  │    ├─ USE_POI_USER_MODEL OR non-XSSF(sheet)?
  │    │   ├─ yes → POISheetWriter (POI User Model)
  │    │   └─ no  → SSMLSheetWriter (Streaming)
  │    └─ new SheetGenerator(writer)

  └─ Jackson BeanSerializer
       ├─ writeStartObject()
       │    └─ SheetGenerator creates scope context
       ├─ writeFieldName("address")
       │    └─ stored in context for ColumnPointer resolution
       ├─ writeString("12345")
       │    └─ SheetGenerator.verifyValueWrite()
       │         └─ SheetStreamContext.currentReference() → CellAddress
       │              └─ SheetWriter.setReference() + writeString()
       ├─ writeEndObject()
       │    └─ mergeScopedColumns(...)
       └─ close()
            └─ SheetWriter.write() + close()

Dual Writer Strategy

SSMLSheetWriterPOISheetWriter
PackagingPOI scaffold + ZipOutputStreamPOI Workbook.write()
Cell writingSoA buffer → StringBuilder → ZipOutputStreamPOI Cell.setCellValue()
Shared stringsSharedStringsStore (in-memory or file-backed)POI managed
StylesPOI CellStyle.getIndex() from scaffoldPOI CellStyle API
FormatXLSX only (requires XSSFWorkbook)XLSX, XLS
When usedOOXML output + XSSFSheet workbookDirect Sheet output, non-XSSF custom workbook, or USE_POI_USER_MODEL

Scaffold-Based Streaming

The split separates correctness from throughput. The throughput bottleneck is sheetData + sharedStrings.xml — one entry per cell, scaling with row count. Everything else (relationships, content types, core properties, theme, drawing rels, namespace declarations, even mergeCells/autoFilter/conditionalFormatting) is bounded in size and OOXML spec-sensitive. Hand-rolling those in StringBuilder is not a throughput question; it's a compatibility risk.

SSMLSheetWriter lets POI generate a complete XLSX scaffold with the target schema's styles and an empty sheet, then patches only the two streaming entries:

setSchema()
  ├─ XSSFWorkbook (styles + empty sheet) → temp file
  ├─ schema.configureSheet() applies sheet-level features to the scaffold
  └─ split sheet1.xml at <sheetData>:
        head, tail   (POI-generated; copied verbatim)
        data         (<row> entries — streamed at write time)

close()
  ├─ for each zip entry:
  │     sheet1.xml        → head + streamed rows + tail
  │     sharedStrings.xml → SharedStringsStore-driven streaming
  │     others            → copied as-is
  └─ delete temp file

POI owns OOXML correctness. A row-linked SoA cell buffer feeds the StringBuilder per fragment, owning per-cell throughput. The scaffold is the handoff between them.

SharedStrings

XLSX SST is the memory bottleneck for string-heavy workloads. The streaming reader uses SharedStringsLookup (returns String directly, no RichTextString wrapping); the streaming writer uses SharedStringsStore. Both default to SoA buffers.

FILE_BACKED_SHARED_STRINGS switches both to H2 MVStore — constant heap on tables exceeding available heap. ENCRYPT_FILE_BACKED_STORE adds AES encryption for sensitive data at rest.

Nested List

A schema with List<T> (or array) fields maps N rows to one outer record on read. @DataColumn(anchor = true) marks the outer-record boundary; the parser buffers cells into a record tree until an anchor change closes the record, and a depth-first walk emits {outer, child1[], child2[], ...}, so sibling lists, outer-after-list, and N-depth nesting fall out of the tree.

On write, the same schema emits one outer record across N rows. When the schema has an outer field declared after a nested list or multiple sibling lists at the same scope, outer fields back-write into past rows — the library streaming writer keeps cell metadata in an SoA cell buffer for O(1) back-write; the POI path uses random Cell access.

POI Boundary

The read path and the write path have different relationships with POI:

POIThis library
XLSX read (default)ZIP package open (OPCPackage)XML parsing (StAX), SharedStrings, cell type resolution
Non-streaming readEverything (Workbook, Sheet, Row, Cell)Token translation only
XLSX write (default)Scaffold generation (XSSFWorkbook.write())StringBuilder streaming for worksheet + SST, zip entry copy for metadata
Non-streaming writeWorkbook.write(), CellStyle, FontSchema-driven cell routing, merge logic
StylingCellStyle / Font APIStylesBuilder (fluent builder layer)
Sheet-level featuresSheet.createFreezePane, setAutoFilter, protectSheet, setDisplayGridlines, setZoom, setRightToLeft, ConditionalFormatting APIGridConfigurer (fluent builder layer)

By default, both XLSX read and write paths bypass POI's User Model — the read path uses direct StAX parsing, the write path uses StringBuilder streaming with a POI scaffold for package metadata. Non-streaming paths (XLS, or XLSX with USE_POI_USER_MODEL) use POI's full User Model.

POICompat absorbs POI version differences internally, letting the library support POI 4.1.1+.


Public API surface (types users typically import): SpreadsheetMapper, SpreadsheetReader, SpreadsheetWriter, @DataGrid, @DataColumn, @DataColumnGroup, SheetInput, SheetOutput, SpreadsheetSchema, SchemaGenerator, StylesBuilder, GridConfigurer, SheetMappingIterator, SheetLocation, SheetParser.Feature, SpreadsheetFactory.Feature.

Everything under poi/ is implementation detail — swappable without affecting the streaming contract.