Entities and labels
An entity is a node. Its properties are node properties, its primary key is what writes MERGE,
and its labels are what every query matches on.
Labels
A node is written with a primary label plus any extra labels you declare:
| Declared | Labels written |
|---|---|
@Entity() on class User | :User |
@Entity({ tableName: 'Person' }) | :Person |
@Entity({ labels: ['User', 'Person'] }) | :User:Person |
The primary label is the collection name — the class name, or tableName if you set one — and it is
the one that matters operationally: indexes are created on the primary label only. A query that
matches a secondary label already seeks through the primary one, so indexing every label would
multiply indexes for no gain.
Property names are written to the node as the JS key. The naming strategy affects the label, not
the properties, so a plainCamelCase property is plainCamelCase on the node — never
plain_camel_case.
Decorators
import { Collection } from 'mikro-orm-neo4j';
import { Entity, ManyToOne, OneToMany, PrimaryKey, Property } from '@mikro-orm/decorators/legacy';
@Entity({ labels: ['User', 'Person'] })
export class User {
@PrimaryKey()
id!: string;
@Property()
name!: string;
@Property({ nullable: true })
bio?: string;
@OneToMany(() => Post, (post) => post.author)
posts = new Collection<Post>(this);
}
@Entity()
export class Post {
@PrimaryKey()
id!: string;
@Property()
title!: string;
@ManyToOne(() => User, { relationship: { type: 'CREATED', direction: 'IN' } })
author!: User;
}
labels at entity level needs one line of ceremony under a strict compiler — see
TypeScript setup.
defineEntity
The functional API needs no decorators, no reflect-metadata and no reflection provider: the entity
type is inferred from the property builders. The driver's defineEntity is core's, wrapped so
that labels and relationship are part of its signature.
import { defineEntity, type InferEntity, neo4j } from 'mikro-orm-neo4j';
import crypto from 'node:crypto';
export const MovieSchema = defineEntity({
name: 'Movie',
labels: ['Cinema', 'Show'],
properties: (p) => ({
id: p.uuid().primary().onCreate(() => crypto.randomUUID()),
title: p.string(),
released: p.integer(),
actors: () =>
neo4j(p.manyToMany(ActorSchema).mappedBy('movies'), { type: 'ACTED_IN', direction: 'IN' }),
}),
});
export type Movie = InferEntity<typeof MovieSchema>;
neo4j(builder, options) is the property-level counterpart of the relationship option: it attaches
the edge type and direction to whatever builder you hand it.
Adding behaviour
defineEntity returns a schema whose generated class you can extend, which is how a functional
entity gets methods and getters:
export class Movie extends MovieSchema.class {
get isRecent(): boolean {
return this.released > 2020;
}
}
MovieSchema.setClass(Movie);
Comments
comment is carried through to the generated GraphQL SDL as a
docstring, on entities and on properties alike. It is the cheapest way to make a schema readable —
by a person or by a model consuming the SDL.
@Entity({ comment: 'Represents a human user in the system.' })
export class User {
@Property({ comment: 'The display name used in public profiles.' })
name!: string;
}
Primary keys
The primary key is the MERGE key. Composite keys are supported end to end — writes, relationship
matching and endpoint navigation all compose the whole key:
@Entity()
export class Document {
@PrimaryKey() id!: string;
@PrimaryKey() tenant!: string; // composite: (id, tenant)
}
One limitation to know: the denormalized foreign key stored as a node property stays scalar — Neo4j cannot store an object as a property — and keeps the first key column for a composite target. The edge itself carries the full key, so relationship correctness is unaffected. The C10/C11 appendix has the details.
If tenant exists to isolate data rather than to identify it, prefer
schemas: the scope is then applied implicitly to every read, write and
relationship endpoint, instead of having to appear in every filter you write.