Case study: generated + hand-written mappings¶
Reusing stylesheets showed how xsl:include and xsl:import
compose top-level declarations. This page is a concrete architecture for a problem
they solve together: a transformation where most of the code is machine-generated
and a minority is hand-written, and the two must coexist without ever clobbering
each other.
The setting is a format mapping — turning some source vocabulary into a UBL invoice by binding named business terms. It is the shape behind every e-invoicing converter, but the lesson is general: any time you generate the boring 90% of a stylesheet from a spec and hand-write the interesting 10%, this is how you wire them.
The problem¶
The source here is a flat legacy export; the target is UBL. Each source binds the
same set of canonical terms — invoice id, currency, seller name,
payable total, issue date, tax category — to wherever they live in the
source tree. Most bindings are mechanical: term → XPath, one line each. A few need
real logic: a date split across separate year/month/day elements has to be
reassembled into an xs:date; a source rate has to be translated to a UBL tax
category code. So you split the work:
- a generator emits the mechanical bindings from a mapping spreadsheet — the bulk, regenerated whenever the spec changes;
- a human writes the handful that need logic.
Two requirements fall out of that split, and they pull in opposite directions:
- Regenerating must never touch hand-written code. The two live in separate files.
- Accidental overlap must be caught, not swallowed. If the generator starts emitting a term you also hand-wrote, you want to be told — not have one silently win while the other rots.
Requirement 2 is the one that decides include vs import.
Two tools, two jobs¶
xsl:include |
xsl:import |
|
|---|---|---|
| Role | partition — disjoint files, no overlap | layer — one file overrides another |
| Precedence of pulled-in code | same as host | lower than host |
| Same-name declaration in two files | static error (XTSE0630) |
resolved silently by precedence |
The generated and hand-written binding files are a partition — every term is
declared in exactly one of them. That is include territory, and the
"duplicate global parameter" error is exactly the guardrail requirement 2 asks
for. The shared scaffold underneath is a layer the bindings override, so it
comes in by import. One real stylesheet uses both.
The pieces¶
1. The scaffold — hand-authored, imported¶
It owns all the UBL output and declares each term as a bare parameter with no
select (the pattern from reuse.md).
It is the lowest layer, so everything else overrides it.
- The only output template in the whole system lives here. An unbound term hits
its
exists()guard and emits nothing — so a source that binds half the terms produces a valid partial result, no errors.
2. The generated bindings — the bulk, machine-emitted¶
A flat list of xsl:param with select, one per mechanical term. Nothing in
this file is ever edited by hand; it is overwritten on every regeneration.
3. The hand-written bindings — the rest, with logic¶
The two terms the generator can't produce: issue-date needs three elements
reassembled into an xs:date, tax-category needs a rate translated to a UBL
code. These are written by hand and live in their own file, disjoint from the
generated one.
- Logic the generator has no business emitting — a helper function. It lives here because this is the hand-written file; the generated file stays a pure term → XPath table.
- These two params are not in
bindings.generated.xsl. Each term is declared exactly once across the two files — that is the partition.
4. Wiring it together — import the scaffold, include the bindings¶
| export-to-ubl.xsl | |
|---|---|
- Import — the scaffold drops to lower precedence, so the bindings below
override its bare params. (
importmust come before anyinclude.) - Include — the generated bindings join at this module's precedence: higher than the scaffold (so they win over its empty params), equal to…
- …the hand-written bindings, also included here. Equal precedence is the whole point — if a term ever appears in both included files, that is a same-precedence collision and a static error.
flowchart TD
src["export-to-ubl.xsl"]
gen["bindings.generated.xsl<br/>(included — same precedence)"]
man["bindings.manual.xsl<br/>(included — same precedence)"]
base["ubl-base.xsl<br/>(imported — lower precedence)"]
src --- gen
src --- man
gen -. "overlap here = XTSE0630 error" .- man
src -->|import: bindings win over bare params| base
The generated and hand-written files sit side by side at equal precedence — that is the partition, and overlap between them is an error. The scaffold sits below them by import — that is the layer the bindings are allowed to override, silently and by design. Two relationships, two mechanisms, in four lines.
The guardrail in action¶
Suppose the mapping spreadsheet gains a tax-category row, so the generator now
emits it too — while you still have it by hand. Both included files declare
tax-category at the same import precedence, and Saxon refuses to compile:
Static error in stylesheet:
XTSE0630: Duplicate global parameter declaration: a global parameter named
'tax-category' has already been declared (bindings.generated.xsl line 6)
at xsl:param on line 14 of bindings.manual.xsl
That is requirement 2 satisfied: the drift is caught at compile time, naming both
files and both lines, before a single invoice is transformed. With import instead
of include, one declaration would have silently shadowed the other and the
conflict would have shipped.
Why not import everywhere?
import looks more flexible — it never errors on overlap. That flexibility is
exactly what you don't want between generated and hand-written code: a
silent winner hides the fact that they now disagree. Reach for include
precisely because it is strict. Use import only where you genuinely mean
"this layer overrides that one."
When you do want to override a generated term¶
Sometimes the generated binding is wrong for one source and you want to replace
it by hand — without editing the generated file. That is a genuine override, so
it is an import layer, not a partition. Keep the include-based file as-is and
stack one more module on top:
- Everything from before — scaffold, generated, manual — drops to lower precedence as one combined unit.
- Re-bind
total. This sits above the includedtotalfrombindings.generated.xsl, so it wins by precedence — no error, because the two are now at different precedences. The generated file is untouched.
The decision in one line: same job, no overlap → include and let the error
guard you; deliberately replacing a lower layer → import and let precedence
resolve it. A real converter often does both — include the disjoint bulk,
then import a thin override layer for the few exceptions.
Why this shape is generator-friendly¶
The generated file is nothing but <xsl:param name="…" select="…"/> lines — a
flat term → XPath table with no control flow, no templates, no ordering
constraints among the lines. That is about the easiest XSLT there is to emit from
a spreadsheet, a CSV, or a few lines of script. Everything that needs judgment —
the UBL output structure and its types (the scaffold), the bindings with logic
(bindings.manual.xsl) — stays hand-written and out of the generator's way. The
two halves meet only at parameter names, and the include guardrail keeps that
contract honest as both sides evolve.
Next¶
XSLT at scale is the companion to this page: there you read a 50-module stylesheet (DocBook xslTNG) and see import precedence used as a customization surface across a large real codebase; here you build a small one where generated and hand-written code share the same precedence rules.