Skip to main content

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:

DeclaredLabels 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.

Scoping by tenant?

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.