Graph-native authoring
This page is about schema drift. The graph framing is one consequence of avoiding drift, not the primary motivation.
The drift problem
When the same concept is defined in multiple places, every place becomes its own source of truth. An application that defines email validation six times has six versions of "what counts as a valid email." Fix a bug in one, and the other five keep producing the same bug. Tighten the constraint in one, and a value that's valid in five places suddenly fails in the sixth. The bugs that come out of this are hard to trace because the symptom (rejection at one boundary, acceptance at another) doesn't point at the cause (two divergent definitions of the same concept).
This is not a graph problem. It is a duplication problem. Every type system that lets you inline constraints has it: TypeScript, Pydantic, Zod, TypeBox, JSON Schema authored by hand. The mitigation is the same in all of them: define each concept once and reference it everywhere it appears.
json-tology happens to model the consequences of duplication explicitly. When you inline { type: 'string', pattern: '^[^@]+@[^@]+$' } six times, the canonical graph contains six separate, unrelated property nodes. Each carries its own copy of the constraint. The OWL TBox output emits six anonymous DatatypeProperty ranges. The SHACL output emits six unrelated sh:pattern constraints. Reasoning queries traversing the graph cannot link them. They are the same fact written six times, and the system treats them as six different facts.
The fix is to extract the concept to a named schema and reference it. This page calls that pattern graph-native authoring because the graph makes the cost of duplication visible. The same pattern is good practice in any type system; the graph is the diagnostic, not the reason.
The rest of this page covers:
- How to detect duplicate inline shapes (
SchemaRegistry.findDuplicates) - How to enforce the named-entity pattern at registration time (strict graph mode)
- The structural equivalence operator (
Compose.equivalent) for two domain-distinct concepts that share validation rules - What "graph-native" actually means in the OWL TBox / SHACL output
Why named primitives matter
The divergence problem
When you write the same constrained shape inline in two different schemas, the graph sees them as two separate, unrelated nodes:
/**
* Graph-native authoring anti-pattern — two inline ISBN shapes.
*
* When the same constrained shape is inlined in multiple schemas, the graph
* sees them as two separate, unrelated nodes. The OWL output emits two
* anonymous DatatypeProperty ranges. `findDuplicates()` flags the inline
* shapes that match the named IsbnSchema as redundant.
*
* Demonstrates: inline duplication — findDuplicates returns the offending
* pair; `equivalentTo` points at the named IsbnSchema.
*/
import { SchemaRegistry } from '../../../src/modules/registry/SchemaRegistry.js';
// The named canonical form — this should be the single source of truth
const IsbnSchemaCanonical = {
'$id': 'urn:bookstore:IsbnCanonical',
'pattern': '^\\d{13}$',
'type': 'string'
} as const;
// BAD — two separate ISBN nodes in the graph, unrelated to each other
const BookInlineIsbn = {
'$id': 'urn:bookstore:BookInline',
'properties': {
'isbn': {
// node 1 — inline ISBN constraint, structurally identical to IsbnSchemaCanonical
'pattern': '^\\d{13}$',
'type': 'string'
},
'title': { 'type': 'string' }
},
'type': 'object'
} as const;
const OrderInlineIsbn = {
'$id': 'urn:bookstore:OrderInline',
'properties': {
'isbn': {
// node 2 — structurally identical but unrelated in the graph
'pattern': '^\\d{13}$',
'type': 'string'
},
'quantity': { 'type': 'integer' }
},
'type': 'object'
} as const;
// enableStrictGraph: false — this example intentionally registers schemas
// with inline duplicate shapes to demonstrate findDuplicates() detection.
const registry = new SchemaRegistry({ 'enableStrictGraph': false });
registry.set(IsbnSchemaCanonical);
registry.set(BookInlineIsbn);
registry.set(OrderInlineIsbn);
// findDuplicates reveals inline shapes that match the named IsbnSchemaCanonical
const duplicates = registry.findDuplicates();
console.assert(
duplicates.length > 0,
'findDuplicates detects inline ISBN shapes matching the named canonical schema'
);
console.assert(
duplicates.some((dup) => {
return dup.equivalentTo === IsbnSchemaCanonical.$id;
}),
'duplicates point back to the named IsbnSchemaCanonical'
);
console.log('Anti-pattern: inline duplicate count:', duplicates.length, '| equivalent to:', duplicates[0]?.equivalentTo);
The OWL output produces two anonymous DatatypeProperty ranges. Fix the ISBN regex once, and you have to find and update every copy. SHACL constraint propagation and rdfs:range reasoning work per-node - the two "isbn" properties have no declared relationship.
The named-entity solution
/**
* Graph-native authoring — named entity, multiple references.
*
* Define the constrained shape once as a named schema and reference it via
* `$ref` wherever it is needed. The graph sees one node; both consumers share
* a single OWL DatatypeProperty range; `findDuplicates()` returns empty.
*
* Demonstrates: named IsbnSchema referenced from BookSchema and OrderSchema;
* findDuplicates returns an empty array.
*/
import { SchemaRegistry } from '../../../src/modules/registry/SchemaRegistry.js';
// GOOD — one ISBN node, referenced from both schemas
const IsbnSchema = {
'$id': 'urn:bookstore:IsbnNamed',
'pattern': '^\\d{13}$',
'type': 'string'
} as const;
const BookWithRef = {
'$id': 'urn:bookstore:BookRef',
'properties': {
// $ref to named schema — not inline
'isbn': { '$ref': IsbnSchema.$id },
'title': { 'type': 'string' }
},
'type': 'object'
} as const;
const OrderWithRef = {
'$id': 'urn:bookstore:OrderRef',
'properties': {
// same $ref — single graph node shared by both schemas
'isbn': { '$ref': IsbnSchema.$id },
'quantity': { 'type': 'integer' }
},
'type': 'object'
} as const;
const registry = new SchemaRegistry();
registry.set(IsbnSchema);
registry.set(BookWithRef);
registry.set(OrderWithRef);
// Named entity with $ref — no duplicates
const duplicates = registry.findDuplicates();
console.assert(
duplicates.length === 0,
'named schema + $ref produces zero duplicates'
);
// Both schemas validate the same ISBN via the shared constraint
// Die unendliche Geschichte 1979 Thienemann first edition ISBN-13
const validIsbn = '9783522128001';
// ok is true when the ValidationErrors collection is empty (no errors)
const bookResult = registry.validate(BookWithRef.$id, {
'isbn': validIsbn,
'title': 'Die unendliche Geschichte'
});
console.assert(bookResult.ok, 'BookRef validates with the named IsbnSchema constraint');
const orderResult = registry.validate(OrderWithRef.$id, {
'isbn': validIsbn,
'quantity': 1
});
console.assert(orderResult.ok, 'OrderRef validates with the same named IsbnSchema constraint');
console.log('Named entity: duplicates:', duplicates.length, '| BookRef valid:', bookResult.ok, '| OrderRef valid:', orderResult.ok);
Now:
- Change the ISBN pattern in one place - both schemas update.
- OWL output emits
urn:bookstore:Isbnas a namedrdfs:Datatype. - SHACL output links
sh:datatypethrough the named type. findDuplicates()returns an empty array.
The per-entity file convention
A predictable file layout makes graph-native authoring easy to follow. One file per $id segment:
entities/
Isbn.ts # $id: urn:bookstore:Isbn
Author.ts # $id: urn:bookstore:Author
Book.ts # $id: urn:bookstore:Book
Order.ts # $id: urn:bookstore:Order
OrderLine.ts # $id: urn:bookstore:OrderLineInside Book.ts:
/**
* Per-entity file convention — one file per $id segment.
*
* The bookstore entities directory follows the one-file-per-schema convention:
* each `$id` segment maps to a single file. Imports always reference the
* defining file directly, never a barrel. This makes the schema graph explicit
* at the import level.
*
* Demonstrates: using the per-entity imports from the bookstore to construct
* a minimal registry — each entity comes from its own canonical file.
*/
import { SchemaRegistry } from '../../../src/modules/registry/SchemaRegistry.js';
import { AuthorNameSchema } from '../bookstore/entities/AuthorName.js';
import { IsbnSchema } from '../bookstore/entities/Isbn.js';
import { TitleSchema } from '../bookstore/entities/Title.js';
import { BookSchema } from '../bookstore/entities/Book.js';
// Each entity is imported from its canonical file — one schema per file
const registry = new SchemaRegistry();
registry.set(IsbnSchema);
registry.set(AuthorNameSchema);
registry.set(TitleSchema);
registry.set(BookSchema);
// The registry has all four named entities
console.assert(registry.has(IsbnSchema.$id), 'Isbn registered from its own file');
console.assert(registry.has(TitleSchema.$id), 'Title registered from its own file');
console.assert(registry.has(BookSchema.$id), 'Book registered from its own file');
// No inline duplicates — all refs point at named schemas
const duplicates = registry.findDuplicates();
console.assert(
duplicates.length === 0,
'per-entity file convention produces zero inline duplicates'
);
console.log('Per-entity file: registry size:', registry.size, '| duplicates:', duplicates.length);
Always show the import that defines the referenced shape - never use a bare string $ref pointing to an undocumented IRI.
Structural equivalence
When two schemas describe the same data but represent distinct domain concepts, use Compose.equivalent to give the second a name without duplicating the structure. See Compose.equivalent for the operator declaration; the relevant fact for graph authoring is that it produces a thin $ref alias which the OWL projection emits as owl:equivalentClass.
Structural extension
See Compose.extend for the operator declaration; the relevant fact for graph authoring is that extend produces allOf + $ref rather than flattening properties, which preserves the parent class as a real graph node and emits as rdfs:subClassOf in the OWL projection.
Detection and enforcement
Detection has on-demand (findDuplicates), warning (enableInlineWarnings, enableDuplicateDetection), and enforcement (enableStrictGraph) modes.
When inline is OK
Not every project needs strict graph mode. See Strict graph mode - when inline is OK for the criteria and the cost tradeoff.
Cross-references: Ontology output · toQuads · toTbox · toShacl
See also
- Bookstore domain - the running example domain
- Your types are already a graph - conceptual introduction to the graph model
- Graph concepts - TBox/ABox, OWA, equivalentClass