DMN Validator Implementation¶
Architecture overview¶
Validation is split across three layers:
Browser (React) Backend (Node.js / Express)
───────────────────── ──────────────────────────────────────────────
DmnValidator.tsx packages/backend/src/
│ POST /v1/dmns/validate ├── routes/dmn.routes.ts (route handler)
│ { content: string } ───► └── services/dmn-validation.service.ts
│ (five-layer validation engine)
◄──────────────────────────────
{ success, data: ValidationResult }
The frontend component is a pure React display layer. All business logic lives in the backend service.
Frontend — DmnValidator.tsx¶
Location: packages/frontend/src/components/DmnValidator.tsx
State model¶
interface DmnEntry {
id: string; // generated: Date.now()-random
name: string;
size: number;
content: string; // raw XML string read by FileReader
isValidating: boolean;
result: ValidationResult | null;
error: string | null;
}
useState<DmnEntry[]>([]) holds all loaded files. Each entry is updated immutably by id when its validation completes.
Key behaviours¶
Persistence across navigation. The component is rendered unconditionally in App.tsx and hidden with the Tailwind hidden class when not active:
<div className={`flex-1 overflow-hidden flex flex-col ${
viewMode === ViewMode.VALIDATE ? '' : 'hidden'
}`}>
<DmnValidator apiBaseUrl={API_BASE_URL} />
</div>
This keeps React from unmounting the component — and therefore from discarding state — when the user switches views.
Multi-file drop. The addFiles(files) helper iterates FileList | File[], rejects non-.dmn/.xml files with a toast, and for each accepted file spawns a FileReader that appends a new DmnEntry to state on onload.
Concurrent validation. validateAll() calls validateEntry(id) for every entry that is not already validating. Each call is an independent fetch — they run in parallel.
Non-blocking errors. A fetch or parse failure sets entry.error and clears entry.isValidating. It never throws or blocks other entries.
Component tree¶
DmnValidator
├── Header (title, "Validate all", "Clear all")
├── Drop zone (always mounted, compact when entries exist)
├── Drop error toast (auto-dismisses after 4 s)
└── Entry cards (horizontal scrollable row)
└── EntryCard (one per DmnEntry)
├── Card header (filename, size, Validate, ×)
└── Card body
├── "Press Validate to run checks" placeholder
├── Validation spinner
├── Error message
└── Result
├── Summary badge (valid/invalid + E/W/I counts)
└── LayerSection × 5
└── IssueRow × n
Backend — route handler¶
Location: packages/backend/src/routes/dmn.routes.ts
router.post('/validate', async (req: Request, res: Response) => {
const { content } = req.body as { content?: string };
if (!content || typeof content !== 'string') {
return res.status(400).json({
success: false,
error: { code: 'INVALID_REQUEST', message: '...' },
});
}
const result = await dmnValidationService.validateDmnContent(content);
res.json({ success: true, data: result, timestamp: new Date().toISOString() });
});
The endpoint is unauthenticated. Body size is limited to 10 MB by the express.json() middleware in index.ts. The route is registered under /v1/dmns, so the full path is POST /v1/dmns/validate.
Backend — validation service¶
Location: packages/backend/src/services/dmn-validation.service.ts
Dependency¶
libxmljs2 is the only added dependency. It provides:
- XML well-formedness parsing (
parseXml) - A full DOM-like API with XPath support (
find(),get(),.attr(),.namespace()) - Namespace-aware attribute lookup
No XSD validation is used. The library's XSD compiler rejects complex forward-referencing schemas; all structural checks are performed programmatically instead.
Entry point¶
export async function validateDmnContent(xmlContent: string): Promise<DmnValidationResult> {
const { layer: baseLayer, doc } = await validateBaseLayer(xmlContent);
// If XML could not be parsed, abort — layers 2–5 require a valid DOM
const parseFailure = baseLayer.issues.find(i => i.code === 'BASE-PARSE');
if (parseFailure) return buildResult(parseFailure.message, baseLayer);
if (!doc) return buildResult(null, baseLayer);
return buildResult(
null,
baseLayer,
validateBusinessLayer(doc),
validateExecutionLayer(doc, xmlContent),
validateInteractionLayer(doc),
validateContentLayer(doc),
);
}
Namespace constants¶
const DMN_NS = 'https://www.omg.org/spec/DMN/20191111/MODEL/';
// Both CPRMV namespaces are accepted (v2026.08.2) — see below.
const CPRMV_NAMESPACES = [
'https://cprmv.open-regels.nl/0.3.0/',
'https://standaarden.open-regels.nl/standards/cprmv/0.4.1#',
];
const NS = { d: DMN_NS };
cprmvAttr() threw on every call — ten rules were dead code
Until v2026.08.2, cprmvAttr() tried a namespace-aware fast path before
falling back to a scan:
In libxmljs2 0.37 that object form is the setter overload, not a getter.
It sets attributes literally called name and ns on the element and returns
a value with no .value() method, so the call threw — and the function's own
try/catch swallowed the throw and returned null, so the working
attrs() fallback beneath it was never reached.
Consequences: EXEC-002–EXEC-010 and CON-001–CON-003 never fired for
any DMN, while the validator reported a clean result, and every inspected
element was silently mutated with two junk attributes. Dropping the
object-form fast path is the entire fix.
A second defect compounded it: CPRMV_NS was pinned to the legacy 0.3.0
path, so a DMN declaring the current 0.4.1 namespace short-circuited on
EXEC-001 before any other check ran. Hence CPRMV_NAMESPACES above.
Verified against all 16 tracked .dmn files — no newly reported issues,
since none carried cprmv:* attributes at the time. The rules now work for
RONL DMN+ files that do. Found by the backend coverage campaign; see
Testing.
All XPath queries use the d: prefix mapped to DMN_NS. The namespace-agnostic fallback //*[local-name()="decision"] is used in Layer 1 to handle any DMN version.
Helper functions¶
find(node, xpath, ns?) // → XmlElement[] — returns [] on error
get(node, xpath, ns?) // → XmlElement | null
cprmvAttr(el, name) // → string | null — tries namespace-aware then prefix scan
elLoc(el) // → '<tagName id="..." />' for issue location strings
iss(severity, code, msg, location?, line?, column?) // → ValidationIssue
Adding a new validation check¶
- Identify the appropriate layer function (
validateBusinessLayer,validateExecutionLayer, etc.). - Add an XPath query using
find()orget()to locate the relevant elements. - Push a new issue using
iss('error'|'warning'|'info', 'LAYER-NNN', 'message', elLoc(el)). - Document the new code in the DMN Validation Reference.
Code numbering convention: BASE-NNN, BIZ-NNN, EXEC-NNN, INT-NNN, CON-NNN. Use the next available number in the relevant range.
Environment variables¶
| Variable | Component | Default |
|---|---|---|
VITE_API_BASE_URL |
Linked Data Explorer frontend | http://localhost:3001 |
REACT_APP_BACKEND_URL |
CPSV Editor frontend | http://localhost:3001 |