A complete GraphQL backend with NestJS
Your entities already describe a graph: labels, directed edges, edge properties, fulltext indexes.
orm.schema.getGraphSdl() turns that description into GraphQL SDL,
@neo4j/graphql turns the SDL into an executable schema
that resolves straight to Cypher, and NestJS serves it. No resolvers to write, and no second model to
keep in sync.
@Entity() classes ──▶ orm.schema.getGraphSdl() ──▶ Neo4jGraphQL ──▶ GraphQLModule
│ SDL executable schema /graphql
│ │
└────────────── the same nodes and edges ────────────┘
None of it is a special path: @mikro-orm/nestjs boots this driver as a named instance like any
other, forFeature registers the entities, repositories and request context behave as MikroORM
documents them, and the generated schema is just another provider in the container.
The ORM and the API are two clients of one graph. That is the property worth checking, and the
test that pins this guide
asserts across the boundary in both directions: written through GraphQL, read with the ORM, and the
other way round — through the same MikroOrmModule wiring, named instance and DI tokens shown here.
Install
- npm
- pnpm
- Yarn
npm install mikro-orm-neo4j @mikro-orm/nestjs @neo4j/graphql graphql \
@nestjs/common @nestjs/core @nestjs/platform-express \
@nestjs/graphql @nestjs/apollo @apollo/server @as-integrations/express5
pnpm add mikro-orm-neo4j @mikro-orm/nestjs @neo4j/graphql graphql \
@nestjs/common @nestjs/core @nestjs/platform-express \
@nestjs/graphql @nestjs/apollo @apollo/server @as-integrations/express5
yarn add mikro-orm-neo4j @mikro-orm/nestjs @neo4j/graphql graphql \
@nestjs/common @nestjs/core @nestjs/platform-express \
@nestjs/graphql @nestjs/apollo @apollo/server @as-integrations/express5
Everything below is the standard NestJS tooling: @mikro-orm/nestjs for the ORM,
@nestjs/graphql + @nestjs/apollo for the transport. The driver is a MikroORM driver like any
other, and slots into both.
1. The model
Nothing here is GraphQL-specific — it is the ordinary modeling you would write anyway.
import {
Entity,
Index,
ManyToMany,
ManyToOne,
PrimaryKey,
Property,
} from '@mikro-orm/decorators/legacy';
import { Collection, Neo4jFullTextType, type Ref } from 'mikro-orm-neo4j';
@Entity({ tableName: 'Genre' })
export class Genre {
@PrimaryKey({ type: 'string' }) id!: string;
@Property({ type: 'string' }) name!: string;
}
@Entity({ tableName: 'Person' })
export class Person {
@PrimaryKey({ type: 'string' }) id!: string;
@Property({ type: 'string' }) name!: string;
@ManyToMany(() => Movie, undefined, {
owner: true,
pivotEntity: () => ActedIn,
inversedBy: 'actors',
relationship: { type: 'ACTED_IN', direction: 'OUT' },
})
movies = new Collection<Movie>(this);
}
@Entity({ tableName: 'Movie' })
export class Movie {
@PrimaryKey({ type: 'string' }) id!: string;
@Property({ type: 'string' }) title!: string;
@Property({ type: 'number' }) released!: number;
@ManyToMany(() => Person, (person) => person.movies)
actors = new Collection<Person>(this);
@ManyToOne(() => Genre, {
ref: true,
nullable: true,
relationship: { type: 'IN_GENRE', direction: 'OUT' },
})
genre?: Ref<Genre>;
/** Backs the `moviesByTitle` query the generated schema exposes. */
@Index({ type: 'fulltext' })
@Property({
type: new Neo4jFullTextType(['title'], { name: 'MovieSearch', queryName: 'moviesByTitle' }),
persist: false,
nullable: true,
})
readonly search?: string;
}
/** The edge itself, with properties of its own. */
@Entity({ tableName: 'ActedIn', relationship: { type: 'ACTED_IN' } })
export class ActedIn {
@ManyToOne(() => Person, { primary: true }) person!: Person;
@ManyToOne(() => Movie, { primary: true }) movie!: Movie;
@Property({ type: 'json', array: true }) roles!: string[];
}
tableName fixes the label. Without it the default naming strategy writes (:movie), not
(:Movie) — see labels and the naming strategy below, which the
generator handles but you should understand.
type: 'json', array: true is how a list property is declared. json is what marshals onto a
native Neo4j list (core's string[] is ArrayType, a SQL shape that comma-joins the values into one
string), and array: true is what tells the SDL generator the field is [String!]! rather than
String!.
2. Register the ORM, the MikroORM way
@mikro-orm/nestjs is the integration you would use with any other driver, and it is the one to use
here: forRoot boots the ORM, forFeature registers entities, and everything the ORM provides
arrives through DI. Register it as a named instance — contextName — so the token is explicit
and a second database (or a second ORM) can be added later without moving anything.
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
import { defineConfig, Neo4jDriver } from 'mikro-orm-neo4j';
export const NEO4J_CONTEXT = 'neo4j';
@Module({
imports: [
MikroOrmModule.forRootAsync({
contextName: NEO4J_CONTEXT,
driver: Neo4jDriver,
useFactory: () => ({
// `defineConfig` is the driver's own: it selects `Neo4jDriver` and applies
// the Neo4j-specific discovery hooks — search fields, stored routines,
// relationship metadata — that `MikroORM.init` would otherwise apply.
...defineConfig({
clientUrl: process.env.NEO4J_URL ?? 'bolt://localhost:7687',
user: process.env.NEO4J_USER ?? 'neo4j',
password: process.env.NEO4J_PASSWORD ?? 'password',
dbName: 'neo4j',
}),
// The entities come from every `forFeature` registered against this
// context, so a feature module owns its own model.
autoLoadEntities: true,
registerRequestContext: false,
}),
}),
MikroOrmModule.forMiddleware(),
],
})
export class OrmModule {}
defineConfig rather than a bare options objectMikroOrmModule calls core's MikroORM.init, not the driver's. defineConfig is what puts the
Neo4j behaviour back into the config it is handed: the driver class, the discovery hook that turns a
search field into an index, the
routine registry, and the relation-metadata fixes. Spread it and add
the two NestJS-only options on top.
A feature module registers its entities against that context, exactly as it would with any SQL driver:
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
import { ActedIn, Genre, Movie, Person } from '../entities.js';
import { NEO4J_CONTEXT } from '../orm.module.js';
@Module({
imports: [MikroOrmModule.forFeature([Genre, Person, Movie, ActedIn], NEO4J_CONTEXT)],
exports: [MikroOrmModule],
})
export class CatalogModule {}
autoLoadEntities is what makes that enough: the root configuration never names an entity, and each
feature module contributes its own.
3. Expose the GraphQL schema through DI
The executable schema is an ordinary provider. It is built from the named ORM's metadata, so the API and the model cannot drift apart:
import { getMikroORMToken } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
import { Neo4jGraphQL } from '@neo4j/graphql';
import type { GraphQLSchema } from 'graphql';
import type { MikroORM, Neo4jSchemaGenerator } from 'mikro-orm-neo4j';
import type { Driver } from 'neo4j-driver';
import { CatalogModule } from './catalog/catalog.module.js';
import { NEO4J_CONTEXT } from './orm.module.js';
export const NEO4J_DRIVER = Symbol('NEO4J_DRIVER');
export const GRAPHQL_SCHEMA = Symbol('GRAPHQL_SCHEMA');
@Module({
imports: [CatalogModule],
providers: [
{
// `@neo4j/graphql` resolves through a `neo4j-driver` of its own. Give it
// the one the ORM already opened rather than a second pool.
provide: NEO4J_DRIVER,
inject: [getMikroORMToken(NEO4J_CONTEXT)],
useFactory: (orm: MikroORM): Promise<Driver> => orm.em.getConnection().getClient(),
},
{
provide: GRAPHQL_SCHEMA,
inject: [getMikroORMToken(NEO4J_CONTEXT), NEO4J_DRIVER],
useFactory: async (orm: MikroORM, driver: Driver): Promise<GraphQLSchema> => {
const schema = orm.schema as Neo4jSchemaGenerator;
// A graph has no tables: the schema is the indexes and constraints.
// Idempotent, so this is safe on every boot.
await schema.ensureIndexes();
return new Neo4jGraphQL({ typeDefs: schema.getGraphSdl(), driver }).getSchema();
},
},
],
exports: [GRAPHQL_SCHEMA, NEO4J_DRIVER],
})
export class GraphSchemaModule {}
connection.getClient() hands back the live neo4j-driver instance the ORM is using — the same
escape hatch the other MikroORM drivers expose as getKnex() / getClient(). The API and the
EntityManager then share one pool, one set of credentials and one shutdown, and there is no second
place to configure TLS or pool sizes.
The connection owns it: orm.close() closes it, so open sessions from it freely and leave its
lifetime alone. getConnection('read') returns a replica's driver, if you
would rather point the read-only half of an API at one.
Then GraphQLModule just serves what DI already holds:
import { ApolloDriver, type ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import type { GraphQLSchema } from 'graphql';
import { GRAPHQL_SCHEMA, GraphSchemaModule } from './graph-schema.module.js';
import { OrmModule } from './orm.module.js';
@Module({
imports: [
OrmModule,
GraphSchemaModule,
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
imports: [GraphSchemaModule],
inject: [GRAPHQL_SCHEMA],
useFactory: (schema: GraphQLSchema) => ({
schema,
path: '/graphql',
playground: false,
graphiql: true,
}),
}),
],
})
export class ApiModule {}
import { NestFactory } from '@nestjs/core';
import { ApiModule } from './api.module.js';
const app = await NestFactory.create(ApiModule);
await app.listen(3000);
That is the backend. http://localhost:3000/graphql now serves a full CRUD API over the graph.
Your own resolvers, on the same instance
Nothing about the generated API is exclusive. The same named instance is available to any provider —
services, resolvers, controllers — through the tokens @mikro-orm/nestjs generates:
import { InjectMikroORM, InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import type { EntityRepository, MikroORM } from 'mikro-orm-neo4j';
import { Movie } from '../entities.js';
import { NEO4J_CONTEXT } from '../orm.module.js';
@Injectable()
export class CatalogService {
constructor(
@InjectRepository(Movie, NEO4J_CONTEXT) private readonly movies: EntityRepository<Movie>,
@InjectMikroORM(NEO4J_CONTEXT) private readonly orm: MikroORM,
) {}
releasedAfter(year: number) {
return this.movies.find({ released: { $gt: year } }, { populate: ['actors'] });
}
}
getMikroORMToken, getEntityManagerToken and getRepositoryToken are the same tokens under
another name, for inject: arrays and app.get().
4. What you get
For the four entities above, @neo4j/graphql generates:
| Operation | What it does |
|---|---|
movies(where:, sort:, limit:, offset:) | Filter, sort and page nodes — nested relation filters included. |
moviesConnection(first:, after:) | The same, cursor-paginated. |
moviesByTitle(phrase:) | Reads the fulltext index ensureIndexes() created, scored. |
createMovies, updateMovies, deleteMovies | Writes, including nested create/connect through relationships. |
actorsConnection { edges { properties { … } node { … } } } | The edge's own properties, from your relationship entity. |
Writing, relationships and edge properties in one mutation
mutation {
createMovies(
input: [
{
title: "The Matrix"
released: 1999
actors: {
create: [
{ edge: { roles: ["Neo"] }, node: { name: "Keanu Reeves" } }
{ edge: { roles: ["Trinity"] }, node: { name: "Carrie-Anne Moss" } }
]
}
genre: { create: [{ node: { name: "Science Fiction" } }] }
}
]
) {
movies { id title }
}
}
The nodes and the ACTED_IN edges that mutation creates are the ones em.find(Movie, …) returns:
const movie = await em.findOneOrFail(
Movie,
{ title: 'The Matrix' },
{ populate: ['actors', 'genre'] },
);
movie.actors.getItems().map((actor) => actor.name); // ['Keanu Reeves', 'Carrie-Anne Moss']
Reading, including the edge
query {
movies(where: { title: { eq: "The Matrix" } }) {
title
released
genre { name }
actorsConnection(sort: [{ node: { name: ASC } }]) {
edges {
properties { roles }
node { name }
}
}
}
}
Traversal filters, on both sides
query {
movies(where: { actors: { some: { name: { eq: "Carrie-Anne Moss" } } } }) {
title
}
}
is the API's spelling of the filter you would write here:
await em.find(Movie, { actors: { $some: { name: 'Carrie-Anne Moss' } } });
Full-text search
query {
moviesByTitle(phrase: "matrix") {
edges { score node { title } }
}
}
queryName on the search field is what named that query, and
indexName is what points it at the index ensureIndexes() created. The ORM reads the same index
with { search: { $fulltext: 'matrix' } }.
Labels and the naming strategy
Neo4j labels are case-sensitive, and MikroORM's default naming strategy derives the label from
the class name by underscoring it — class Studio becomes (:studio). The GraphQL type keeps the
class name, so the generated @node carries the real label whenever the two differ:
@Entity() // no tableName
export class Studio { /* … */ }
type Studio @node(labels: ["studio"]) { … }
Without that label the API would address (:Studio) — a node the ORM never writes, and the two
halves would silently see different graphs. The generator handles it; the reason to know it is that
it decides how your graph reads in the Neo4j Browser. Two ways to get PascalCase labels, which is the
Neo4j convention:
@Entity({ tableName: 'Movie' }) // this entity
@Entity({ labels: ['Movie', 'Film'] }) // or several, the first is the primary one
…or hand the ORM a naming strategy that leaves class names alone, which does it for every entity at once:
import { EntityCaseNamingStrategy } from 'mikro-orm-neo4j';
await MikroORM.init({ namingStrategy: EntityCaseNamingStrategy /* … */ });
That is a decision to make before there is data: changing it renames the label every query matches on, and nothing migrates the existing nodes for you.
Extending the generated schema
typeDefs takes an array, so the generated SDL is a starting point rather than the whole story:
const neoSchema = new Neo4jGraphQL({
typeDefs: [
orm.schema.getGraphSdl(),
/* GraphQL */ `
extend type Movie {
decade: Int! @cypher(statement: "RETURN this.released / 10 * 10 AS d", columnName: "d")
}
`,
],
driver,
});
The same door is where @neo4j/graphql's authorization directives go (@authentication,
@authorization), together with the context your Nest useFactory returns — see the
library's docs for the shapes it accepts.
Limitations
The generated API resolves to Cypher directly, not through the EntityManager. Everything the
ORM applies on its own read path is therefore absent from it — and everything below comes back the
moment a field is served by a resolver of your own, which
is why the two are worth mixing in one schema:
- Schemas / multi-tenancy are not applied. The generated API is a
raw surface, like
em.run(): no__schemapredicate is added, so a schema-aware entity is exposed across every tenant. Scope it yourself with@authorizationfilters, or keep schema-aware entities out of the SDL you publish. - Hooks, filters and the unit of work do not run. No
@BeforeCreate, no soft-delete filter, no identity map — a mutation is Cypher, not aflush(). - A to-one relation is currently exposed as a list.
Movie.genre(a@ManyToOne) becomesgenre: [Genre!]!, so create inputs take a list and reads return an array of at most one. @idmeans the API generates the key. A primary key is emitted asid: ID! @id, so it is not part ofMovieCreateInput— nodes created through GraphQL get a generated UUID, which the ORM then reads as an ordinary primary key.- Virtual entities become
Queryfields carrying@cypher, which is usually what you want, but their expression is not scoped either.
getGraphSdl(true) appends extend schema @mutation(operations: []), so the generated API has no
Mutation type at all — queries only. And when just part of the model should be public, feed
Neo4jGraphQL a filtered SDL: it is a string, and the ORM will not mind.