Schemas and multi-tenancy
MikroORM's schema entity option splits data into named partitions: one tenant per schema, an
audit schema kept apart from live data, a shared public catalog. SQL drivers implement it with
real database schemas. Neo4j has no such thing inside a database, so this driver implements it as a
soft subgraph: a reserved __schema node property plus a SchemaNode marker label, applied
implicitly wherever identity or matching happens.
The public API is the ordinary MikroORM one — nothing here is Neo4j-specific:
const tenant = orm.em.fork({ schema: 'tenant-1' });
tenant.create(Invoice, { id: 'INV-1', total: 90 });
await tenant.flush();
await tenant.find(Invoice, {}); // only tenant-1's invoices
await tenant.find(Invoice, {}, { schema: 'tenant-2' }); // one-off override
The schema is a property, not DDL. A new tenant is a new value, not a new set of tables.
The three modes
| Declaration | Meaning | Resolved from |
|---|---|---|
(no schema option) | Not schema-aware. Untouched by all of this. | — |
{ schema: '*' } | Wildcard — belongs to whichever schema is querying. | FindOptions.schema → em.schema → config schema → public |
{ schema: 'audit' } | Fixed. Always audit, whatever the EntityManager says. | the declaration itself |
Precedence is core's own chain, so it matches the SQL drivers exactly: a fixed schema always
wins, and '*' never means "all schemas" at query time.
@Entity({ schema: '*' }) // per-tenant
export class Invoice {}
@Entity({ schema: 'audit' }) // always in `audit`
export class AuditLog {}
@Entity({ schema: 'public' }) // shared catalog
export class Currency {}
export const InvoiceSchema = defineEntity({
name: 'Invoice',
schema: '*',
properties: (p) => ({ id: p.uuid().primary(), total: p.float() }),
});
Set the schema per unit of work, per query, or ambiently:
const tenant = orm.em.fork({ schema: 'tenant-1' }); // preferred: scoped, no leakage
orm.em.schema = 'tenant-1'; // ambient, respects the request context
await orm.em.find(Invoice, {}, { schema: 'tenant-2' }); // one query only
Storage model
A schema-aware node stores the resolved schema — including the default public — and gains the
marker label:
(:Invoice:SchemaNode { id: "INV-1", __schema: "tenant-1", total: 90 })
The property is the source of truth: it is part of the MERGE key and of every implicit predicate.
The marker label exists so cross-entity admin work ("list every schema", "drop this tenant") has one
label to match, backed by a single global index. The schema is never left absent to mean "default" —
that would force IS NULL predicates and make the index unusable.
Three consequences worth knowing:
- The schema is part of node identity. The same primary key under two schemas is two nodes.
Re-persisting the same
(id, schema)updates that node in place. __schemais immutable through updates. Nothing moves a node between schemas; see migrating existing data for the deliberate alternative.__schemanever reaches your entities. Hydration strips exactly that key — a property of your own genuinely namedschemais left alone.
Cross-schema relations
Each end of a relationship resolves the schema of its own entity, so a per-tenant entity can point at a fixed shared-catalog entity without the catalog being copied per tenant:
@Entity({ schema: '*' })
export class Invoice {
@PrimaryKey() id!: string;
// Currency is @Entity({ schema: 'public' }) — one node, shared by every tenant.
@ManyToOne(() => Currency, { ref: true, relationship: { type: 'PRICED_IN', direction: 'OUT' } })
currency!: Ref<Currency>;
}
Endpoint scoping is a correctness property, not a convenience. MATCH … MATCH … WHERE … MERGE is
cartesian: without the schema term, two tenants sharing a business id would each get the edge — the
cross-schema form of the C11 leak.
Query builder
Neo4jQueryBuilder scopes the root match and any relation target it resolved from entity metadata,
with two explicit escape hatches:
const qb = tenant.createQueryBuilder(Invoice).match();
qb.build(); // … WHERE this0.__schema = $param0
qb.withSchema('tenant-2'); // read another schema (fixed-schema entities still win)
qb.ignoreSchema(); // read across every schema — deliberately explicit
Both are no-ops on entities that never declared schema, and may be chained in any order.
Indexes
Per schema-aware entity, ensureIndexes() emits a composite uniqueness constraint on
(__schema, …primary key) — schema first, so the backing index also serves scoped scans — prefixes
your own uniques and RANGE indexes with __schema (SQL parity: unique is per-schema, so two tenants
may share an email), and emits one global range index on SchemaNode(__schema).
CREATE CONSTRAINT `Invoice___schema_id_unique` IF NOT EXISTS
FOR (n:`Invoice`) REQUIRE (n.`__schema`, n.`id`) IS UNIQUE
Composite uniqueness constraints landed in Neo4j 5.7 (Community included). That is a hard requirement
of schema support: on an older server ensureIndexes() fails with an explicit message rather than
degrading to a non-unique index and leaving identity unprotected. NODE KEY is Enterprise-only and
is not used.
Migrating existing data
Turning the option on makes every query filter on __schema, and nodes written before the change
have none — they become invisible. Backfill before deploying the entity change:
const generator = orm.schema as Neo4jSchemaGenerator;
await generator.assignDefaultSchema(Invoice); // the entity's own resolved schema
await generator.assignDefaultSchema(Invoice, 'tenant-1'); // or an explicit one
It writes in batches, so it must not run inside em.transactional(). The equivalent raw Cypher,
if you would rather run it as a migration:
MATCH (n:Invoice) WHERE n.`__schema` IS NULL
CALL { WITH n SET n.`__schema` = 'public' SET n:SchemaNode } IN TRANSACTIONS OF 10000 ROWS
Rolling back is simply removing the schema option: the extra property and label linger harmlessly,
and MATCH (n:SchemaNode) REMOVE n.__schema, n:SchemaNode clears them.
Limitations, stated plainly
- Soft, not hard, isolation. Every schema lives in one database. Physical separation means pointing MikroORM at a different database or instance. Raw Cypher can still read across schemas, by design.
- Raw surfaces are unscoped, exactly as raw SQL is in MikroORM:
em.run(), virtual-entityexpressions, and the query builder'spattern()/call()composition APIs. - Opt-in per entity — a deliberate divergence from SQL. In SQL every table lives in a schema, so
em.schemaaddresses everything. Here, an entity that never declaredschemais untouched: no label, no property, no predicate, byte-identical Cypher, even when a schema is forced throughFindOptions.schema, a fork orwithSchema(). This keeps existing databases working; the alternative would silently return nothing for pre-existing nodes. __schemaandSchemaNodeare reserved on schema-aware entities.- The denormalized scalar foreign key stored on a node keeps only the first primary-key column and is ambiguous across schemas — a pre-existing limitation (C10/C11 appendix); the edge carries the truth.
The implementation notes go through the injected predicates statement by statement, the alternatives that were rejected, and what the scoping costs.