Skip to main content

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

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.

src/entities.ts
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[];
}
Two declarations decide whether the API sees your data

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.

src/orm.module.ts
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 {}
Why defineConfig rather than a bare options object

MikroOrmModule 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:

src/catalog/catalog.module.ts
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:

src/graph-schema.module.ts
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 {}
One driver, one pool

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:

src/api.module.ts
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 {}
src/main.ts
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:

OperationWhat 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, deleteMoviesWrites, 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' } } });
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 __schema predicate is added, so a schema-aware entity is exposed across every tenant. Scope it yourself with @authorization filters, 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 a flush().
  • A to-one relation is currently exposed as a list. Movie.genre (a @ManyToOne) becomes genre: [Genre!]!, so create inputs take a list and reads return an array of at most one.
  • @id means the API generates the key. A primary key is emitted as id: ID! @id, so it is not part of MovieCreateInput — nodes created through GraphQL get a generated UUID, which the ORM then reads as an ordinary primary key.
  • Virtual entities become Query fields carrying @cypher, which is usually what you want, but their expression is not scoped either.
Publish a narrower schema

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.