Skip to main content

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
Onboarding a tenant costs zero migrations

The schema is a property, not DDL. A new tenant is a new value, not a new set of tables.

The three modes​

DeclarationMeaningResolved 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.
  • __schema is immutable through updates. Nothing moves a node between schemas; see migrating existing data for the deliberate alternative.
  • __schema never reaches your entities. Hydration strips exactly that key — a property of your own genuinely named schema is 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
Neo4j 5.7 or newer

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-entity expressions, and the query builder's pattern() / call() composition APIs.
  • Opt-in per entity — a deliberate divergence from SQL. In SQL every table lives in a schema, so em.schema addresses everything. Here, an entity that never declared schema is untouched: no label, no property, no predicate, byte-identical Cypher, even when a schema is forced through FindOptions.schema, a fork or withSchema(). This keeps existing databases working; the alternative would silently return nothing for pre-existing nodes.
  • __schema and SchemaNode are 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.