Advanced

Architecture

Understand the 4-stage rendering pipeline, package structure, and design principles behind NextReport Engine.

Overview

NextReport Engine is a schema-driven reporting engine built on a strict separation of concerns. The core insight: report generation is a data transformation pipeline, not a monolithic render call.

The 4-stage pipeline

Every report passes through four sequential stages:

Schema + Data


┌─────────────┐
│  1. Validate │  Verify schema structure and types
└──────┬──────┘


┌─────────────┐
│ 2. Expression│  Resolve {{...}} expressions against data
└──────┬──────┘


┌─────────────┐
│   3. Band    │  Expand detail bands, apply data binding
└──────┬──────┘


┌─────────────┐
│  4. Layout   │  Position elements, calculate page breaks
└──────┬──────┘


  RenderResult (IR)


  HTML / PDF / Custom

Stage 1: Validate

The validator checks the schema for structural correctness: required fields, valid types, known enums, balanced expression delimiters, and component bounds. Validation errors halt the pipeline immediately.

const validation = validateReport(schema)
if (!validation.valid) {
  // Stop here -- schema is broken
}

Stage 2: Expression

The expression engine parses {{...}} delimiters and evaluates them against the data context. Built-in and custom functions are resolved. The output is a schema with all expressions replaced by concrete values.

Stage 3: Band

The band engine processes each band in order:

  • Header/Footer bands render once with the top-level data context
  • Detail bands iterate over the bound array, producing one rendered band per element
  • The iterator variable is scoped to each iteration

Stage 4: Layout

The layout engine takes the expanded bands and positions them on pages. It calculates page breaks based on band heights and page dimensions, producing the final RenderResult with positioned elements.

Intermediate Representation (IR)

The RenderResult is the engine’s intermediate representation — a format-agnostic description of the rendered report:

interface RenderResult {
  pages: Page[]
  metadata: {
    pageCount: number
    renderTimeMs: number
  }
}

interface Page {
  width: number
  height: number
  elements: ResolvedElement[]
}

interface ResolvedElement {
  type: 'text' | 'table'
  position: { x: number; y: number; width: number; height: number }
  content: string
  style: {
    fontSize?: number
    fontWeight?: string
    fontFamily?: string
    color?: string
    textAlign?: string
  }
}

The IR decouples the rendering logic from output format concerns. Built-in renderers (renderToHtml, renderToPdf) consume the IR, and you can write custom renderers for any format.

Package structure

The engine is organized as a monorepo with strict module boundaries:

nextreport/engine
├── packages/
│   ├── engine-core/       # Schema types, validation, shared contracts
│   ├── engine-expression/ # Expression parser and evaluator
│   ├── engine-band/       # Band expansion and data binding
│   ├── engine-layout/     # Page layout and pagination
│   ├── engine-render/     # HTML and PDF renderers
│   └── engine/            # Public API facade
├── apps/
│   └── server/            # REST API server
└── docs/                  # Documentation

Dependency graph

engine (public API)
  ├── engine-core
  ├── engine-expression  → engine-core
  ├── engine-band        → engine-core, engine-expression
  ├── engine-layout      → engine-core
  └── engine-render      → engine-core

Each package depends only on engine-core for shared types, plus its direct upstream stage. There are no circular dependencies.

Engine isolation rule

The most important architectural constraint:

engine-core must never become a god-package.

Each package owns its domain logic. Shared types live in engine-core, but business logic stays in the package that owns the stage. For example:

  • Expression parsing logic lives in engine-expression, not engine-core
  • Band iteration logic lives in engine-band, not engine-core
  • Layout calculations live in engine-layout, not engine-core

This ensures packages can evolve independently and be tested in isolation.

Design principles

Schema-first

The report schema is the source of truth. It is a plain JSON/TypeScript object that fully describes the report without requiring code. This enables:

  • Schema validation before rendering
  • Schema storage and versioning
  • Visual designer tools that edit the schema directly
  • Cross-language compatibility (any language that speaks JSON can define schemas)

Deterministic rendering

Given the same schema and data, the engine always produces the same output. There is no hidden state, no random behavior, and no dependency on system time (unless the data includes it).

Progressive complexity

Simple reports require minimal schema. Advanced features (custom functions, complex data binding, multi-page layouts) are opt-in. A valid report can be as small as:

{
  version: '1.0',
  type: 'report',
  page: { size: 'A4', orientation: 'portrait' },
  bands: [],
}

Format independence

The IR (intermediate representation) is the contract between the pipeline and renderers. Adding a new output format (Excel, Markdown, SVG) requires only a new renderer function — the pipeline does not change.

Next steps