Follow me on Twitter, happy to take your suggestions and improvements.
I decided to write this blog post after working on a personal project. As I started it, I thought about a way to put in place a solid architecture to create a GraphQL API with NestJs and Mongoose.
Why GraphQL?
- No more Over- and Underfetching ;
- Rapid Product Iterations on the Frontend ;
- Insightful Analytics on the Backend ;
- Benefits of a Schema & Type System ;
.
Why NestJs?
- Nest provides a level of abstraction above these common Node.js frameworks (Express/Fastify) but also exposes their APIs directly to the developer. This allows developers the freedom to use the myriad of third-party modules that are available for the underlying platform.
.
Why Mongoose?
- MongooseJS provides an abstraction layer on top of MongoDB that eliminates the need to use named collections.
- Models in Mongoose perform the bulk of the work of establishing up default values for document properties and validating data.
- Functions may be attached to Models in MongooseJS. This allows for seamless incorporation of new functionality.
- Queries use function chaining rather than embedded mnemonics which result in code that is more flexible and readable, therefore more maintainable as well.
-- Jim Medlock
Problem
There is a question that always comes up when I start to set up the architecture of a project, it is the definition of the data model and how the different layers of the application will consume it. In my case, the definition of a data model for the different layers of the application gives me some irritation 😓:
- The definition of a schema for GraphQL to implement the API endpoint ;
- The definition of a schema for Mongoose to organize the documents of the database ;
- The definition of a data model so that the application map objects ;
Solution
The ideal is to have to define the data model only once and thus it will be used to generate the GraphQL schema, the MongoDB collection schemas as well as the classes used by NestJs providers. And the magic is that NestJs with its plugins allow us to do it easily 😙.
NestJs plugins
NestJS plugins encapsulate different technologies in NestJs modules for easy use and integration into the NestJs ecosystem. In this case, we will use the following two plugins: @nestjs/mongoose
and @nestjs/graphql
These two plugins allow us to proceed in two ways:
- schema-first: first, define the schemas for Mongoose and for GraphQL, then use it to generate our typescript classes.
- code-first: first, define our typescript classes, then use them to generate our schemas Mongoose/GraphQL.
I used the code-first approach because it allows me to implement a single model (typescript classes) and use it to generate my schemas for GraphQL as well as for Mongoose 👌.
Implementation
Here is the final project source code on GitHub.
Okay, I've talked too much. Warm-up our fingers to do some magic 🧙!
NestJS
First, create our NestJs project using @nestjs/cli
. We will call it three-in-one-project
:
$ npm i -g @nestjs/cli
$ nest new three-in-one-project
This will initiate our NestJs project:
What interests us here is the content of the
src/
folder :
main.ts
: the entry point of the NestJS app where we bootstrap it.app.module.ts
: the root module of the NestJS app. It implemente acontroller
AppController
and aprovider
AppService
.
To serve the nest server run :
$ npm start
For better organization, we will put the AppModule
files in a dedicated folder src/app/
and update the import path of AppModule
in main.ts
:
Don't forget to update the import path of
AppModule
inmain.ts
Model
We are going to create an API that manages a list of Peron
who have a list of Hobby
for that we will create these two model in app/
folder:
// person.model.ts
export class Person {
_id: string;
name: string;
hobbies: Hobby[];
}
// hobby.model.ts
export class Hobby {
_id: string;
name: string;
}
Mongoose
Installing dependencies
From the two classes Hobby
and Person
we will generate the corresponding mongoose schema HobbySchema
and PersonSchema
. For that we will install the package @nestjs/mongoose
with some other dependencies :
$ npm i @nestjs/mongoose mongoose
$ npm i --save-dev @types/mongoose
Connection to MongoDB
To connecte our backend to mongoDB data base, we will import in AppModule
the MongooseModule
:
// app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { MongooseModule } from '@nestjs/mongoose';
@Module({
// "mongodb://localhost:27017/three-in-one-db" is the connection string to the project db
imports: [
MongooseModule.forRoot('mongodb://localhost:27017/three-in-one-db'),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
For better organization we will create two sub-modules PersonModule
and HobbyModule
each manage the corresponding model. We will use @nestjs/cli
to do that quickly :
$ cd src\app\
$ nest generate module person
$ nest generate module hobby
The created modules
PersonModule
andHobbyModule
are automatically imported inAppModule
.
Now, move each file person.model.ts
and hobby.model.ts
into its corresponding modules :
Schema generation
We can now start to set up the generation of mongoose schemas. @nestjs/mongoose
gives us a decorators to annotate our typescript classes to indicate how to generate the mongoose schemas. let's add some decorator to our classes:
// person.model.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document, Schema as MongooseSchema } from 'mongoose';
import { Hobby } from '../hobby/hobby.model';
@Schema()
export class Person {
_id: MongooseSchema.Types.ObjectId;
@Prop()
name: string;
@Prop()
hobbies: Hobby[];
}
export type PersonDocument = Person & Document;
export const PersonSchema = SchemaFactory.createForClass(Person);
// hobby.model.ts
import { Document, Schema as MongooseSchema } from 'mongoose';
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
@Schema()
export class Hobby {
_id: MongooseSchema.Types.ObjectId;
@Prop()
name: string;
}
export type HobbyDocument = Hobby & Document;
export const HobbySchema = SchemaFactory.createForClass(Hobby);
- The
@Prop()
decorator defines a property in the document.
- The
@Schema()
decorator marks a class as a schema definition - Mongoose documents (ex:
PersonDocument
) represent a one-to-one mapping to documents as stored in MongoDB. -
MongooseSchema.Types.ObjectId
is a mongoose type typically used for unique identifiers
And finally, we import the model to MongooseModule in our two modules :
// person.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Person, PersonSchema } from './person.model';
@Module({
imports: [
MongooseModule.forFeature([{ name: Person.name, schema: PersonSchema }]),
],
})
export class PersonModule {}
// hobby.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Hobby, HobbySchema } from './hobby.model';
@Module({
imports: [
MongooseModule.forFeature([{ name: Hobby.name, schema: HobbySchema }]),
],
})
export class HobbyModule {}
CRUD Operations
We will create the layer for our two modules witch implement CRUD operations. For each module, create a NestJS service who implements that. To create the two services, execute the following commands :
$ cd src\app\person\
$ nest generate service person --flat
$ cd ..\hobby\
$ nest generate service hobby --flat
we use
--flat
to not generate a folder for the service.
Update services to add CRUD methods and their inputs classes :
// person.service.ts
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Schema as MongooseSchema } from 'mongoose';
import { Person, PersonDocument } from './person.model';
import {
CreatePersonInput,
ListPersonInput,
UpdatePersonInput,
} from './person.inputs';
@Injectable()
export class PersonService {
constructor(
@InjectModel(Person.name) private personModel: Model<PersonDocument>,
) {}
create(payload: CreatePersonInput) {
const createdPerson = new this.personModel(payload);
return createdPerson.save();
}
getById(_id: MongooseSchema.Types.ObjectId) {
return this.personModel.findById(_id).exec();
}
list(filters: ListPersonInput) {
return this.personModel.find({ ...filters }).exec();
}
update(payload: UpdatePersonInput) {
return this.personModel
.findByIdAndUpdate(payload._id, payload, { new: true })
.exec();
}
delete(_id: MongooseSchema.Types.ObjectId) {
return this.personModel.findByIdAndDelete(_id).exec();
}
}
// person.inputs.ts
import { Schema as MongooseSchema } from 'mongoose';
import { Hobby } from '../hobby/hobby.model';
export class CreatePersonInput {
name: string;
hobbies: Hobby[];
}
export class ListPersonInput {
_id?: MongooseSchema.Types.ObjectId;
name?: string;
hobbies?: Hobby[];
}
export class UpdatePersonInput {
_id: MongooseSchema.Types.ObjectId;
name?: string;
hobbies?: Hobby[];
}
// hobby.service.ts
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Schema as MongooseSchema } from 'mongoose';
import { Hobby, HobbyDocument } from './hobby.model';
import {
CreateHobbyInput,
ListHobbyInput,
UpdateHobbyInput,
} from './hobby.inputs';
@Injectable()
export class HobbyService {
constructor(
@InjectModel(Hobby.name) private hobbyModel: Model<HobbyDocument>,
) {}
create(payload: CreateHobbyInput) {
const createdHobby = new this.hobbyModel(payload);
return createdHobby.save();
}
getById(_id: MongooseSchema.Types.ObjectId) {
return this.hobbyModel.findById(_id).exec();
}
list(filters: ListHobbyInput) {
return this.hobbyModel.find({ ...filters }).exec();
}
update(payload: UpdateHobbyInput) {
return this.hobbyModel
.findByIdAndUpdate(payload._id, payload, { new: true })
.exec();
}
delete(_id: MongooseSchema.Types.ObjectId) {
return this.hobbyModel.findByIdAndDelete(_id).exec();
}
}
// hobby.inputs.ts
export class CreateHobbyInput {
name: string;
}
export class ListHobbyInput {
_id?: MongooseSchema.Types.ObjectId;
name?: string;
}
export class UpdateHobbyInput {
_id: MongooseSchema.Types.ObjectId;
name?: string;
}
Note that the attribute hobbies
of Hobby
class is an array of reference to Hobby
object. So let's make some adjustment:
// person.model.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document, Schema as MongooseSchema } from 'mongoose';
import { Hobby } from '../hobby/hobby.model';
@Schema()
export class Person {
_id: MongooseSchema.Types.ObjectId;
@Prop()
name: string;
@Prop({ type: [MongooseSchema.Types.ObjectId], ref: Hobby.name })
hobbies: MongooseSchema.Types.ObjectId[];
}
export type PersonDocument = Person & Document;
export const PersonSchema = SchemaFactory.createForClass(Person);
// person.inputs.ts
import { Hobby } from '../hobby/hobby.model';
import { Schema as MongooseSchema } from 'mongoose';
export class CreatePersonInput {
name: string;
hobbies: MongooseSchema.Types.ObjectId[];
}
export class ListPersonInput {
_id?: MongooseSchema.Types.ObjectId;
name?: string;
hobbies?: MongooseSchema.Types.ObjectId[];
}
export class UpdatePersonInput {
_id: MongooseSchema.Types.ObjectId;
name?: string;
hobbies?: MongooseSchema.Types.ObjectId[];
}
GraphQL
We are almost done, we just have to implement the graphQL layer.
Dependencies installation
$ npm i @nestjs/graphql graphql-tools graphql apollo-server-express
Schema generation
As for the mongoose, we will use decorators
from @nestjs/graphql
to annotate our typescript classes to indicate how to generate the graphQL schemas:
// person.model.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Field, ObjectType } from '@nestjs/graphql';
import { Document, Schema as MongooseSchema } from 'mongoose';
import { Hobby } from '../hobby/hobby.model';
@ObjectType()
@Schema()
export class Person {
@Field(() => String)
_id: MongooseSchema.Types.ObjectId;
@Field(() => String)
@Prop()
name: string;
@Field(() => [String])
@Prop({ type: [MongooseSchema.Types.ObjectId], ref: Hobby.name })
hobbies: MongooseSchema.Types.ObjectId[];
}
export type PersonDocument = Person & Document;
export const PersonSchema = SchemaFactory.createForClass(Person);
// hobby.model.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Field, ObjectType } from '@nestjs/graphql';
import { Document, Schema as MongooseSchema } from 'mongoose';
@ObjectType()
@Schema()
export class Hobby {
@Field(() => String)
_id: MongooseSchema.Types.ObjectId;
@Field(() => String)
@Prop()
name: string;
}
export type HobbyDocument = Hobby & Document;
export const HobbySchema = SchemaFactory.createForClass(Hobby);
For more information about
@nestjs/graphql
decoratorsObjectType
andField
: official doc
Resolvers
To define our graphQL query
, mutation
, and resolvers
we will create a NestJS resolver
. we can use the NestJs CLI to do that:
$ cd src\app\person\
$ nest generate resolver person --flat
$ cd ..\hobby\
$ nest generate resolver hobby --flat
Then, update the generated files :
// person.resolver.ts
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { Schema as MongooseSchema } from 'mongoose';
import { Person } from './person.model';
import { PersonService } from './person.service';
import {
CreatePersonInput,
ListPersonInput,
UpdatePersonInput,
} from './person.inputs';
@Resolver(() => Person)
export class PersonResolver {
constructor(private personService: PersonService) {}
@Query(() => Person)
async person(
@Args('_id', { type: () => String }) _id: MongooseSchema.Types.ObjectId,
) {
return this.personService.getById(_id);
}
@Query(() => [Person])
async persons(
@Args('filters', { nullable: true }) filters?: ListPersonInput,
) {
return this.personService.list(filters);
}
@Mutation(() => Person)
async createPerson(@Args('payload') payload: CreatePersonInput) {
return this.personService.create(payload);
}
@Mutation(() => Person)
async updatePerson(@Args('payload') payload: UpdatePersonInput) {
return this.personService.update(payload);
}
@Mutation(() => Person)
async deletePerson(
@Args('_id', { type: () => String }) _id: MongooseSchema.Types.ObjectId,
) {
return this.personService.delete(_id);
}
}
// hobby.resolver.ts
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { Schema as MongooseSchema } from 'mongoose';
import { Hobby } from './hobby.model';
import { HobbyService } from './hobby.service';
import {
CreateHobbyInput,
ListHobbyInput,
UpdateHobbyInput,
} from './hobby.inputs';
@Resolver(() => Hobby)
export class HobbyResolver {
constructor(private hobbyService: HobbyService) {}
@Query(() => Hobby)
async hobby(
@Args('_id', { type: () => String }) _id: MongooseSchema.Types.ObjectId,
) {
return this.hobbyService.getById(_id);
}
@Query(() => [Hobby])
async hobbies(@Args('filters', { nullable: true }) filters?: ListHobbyInput) {
return this.hobbyService.list(filters);
}
@Mutation(() => Hobby)
async createHobby(@Args('payload') payload: CreateHobbyInput) {
return this.hobbyService.create(payload);
}
@Mutation(() => Hobby)
async updateHobby(@Args('payload') payload: UpdateHobbyInput) {
return this.hobbyService.update(payload);
}
@Mutation(() => Hobby)
async deleteHobby(
@Args('_id', { type: () => String }) _id: MongooseSchema.Types.ObjectId,
) {
return this.hobbyService.delete(_id);
}
}
For more information about
@nestjs/graphql
decoratorsMutation
,Resolver
,andQuery
: official doc
let's add some decorators to inputs classes so that GraphQl recognizes them :
// person.inputs.ts
import { Field, InputType } from '@nestjs/graphql';
import { Schema as MongooseSchema } from 'mongoose';
import { Hobby } from '../hobby/hobby.model';
@InputType()
export class CreatePersonInput {
@Field(() => String)
name: string;
@Field(() => [String])
hobbies: MongooseSchema.Types.ObjectId[];
}
@InputType()
export class ListPersonInput {
@Field(() => String, { nullable: true })
_id?: MongooseSchema.Types.ObjectId;
@Field(() => String, { nullable: true })
name?: string;
@Field(() => [String], { nullable: true })
hobbies?: MongooseSchema.Types.ObjectId[];
}
@InputType()
export class UpdatePersonInput {
@Field(() => String)
_id: MongooseSchema.Types.ObjectId;
@Field(() => String, { nullable: true })
name?: string;
@Field(() => [String], { nullable: true })
hobbies?: MongooseSchema.Types.ObjectId[];
}
// hobby.inputs.ts
import { Schema as MongooseSchema } from 'mongoose';
import { Field, InputType } from '@nestjs/graphql';
@InputType()
export class CreateHobbyInput {
@Field(() => String)
name: string;
}
@InputType()
export class ListHobbyInput {
@Field(() => String, { nullable: true })
_id?: MongooseSchema.Types.ObjectId;
@Field(() => String, { nullable: true })
name?: string;
}
@InputType()
export class UpdateHobbyInput {
@Field(() => String)
_id: MongooseSchema.Types.ObjectId;
@Field(() => String, { nullable: true })
name?: string;
}
Import GraphQLModule
Finally, import the GraphQLModule
in AppModule
:
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { GraphQLModule } from '@nestjs/graphql';
import { join } from 'path';
import { PersonModule } from './person/person.module';
import { HobbyModule } from './hobby/hobby.module';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
imports: [
MongooseModule.forRoot('mongodb://localhost:27017/three-in-one-db'),
GraphQLModule.forRoot({
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
sortSchema: true,
playground: true,
debug: false,
}),
PersonModule,
HobbyModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
autoSchemaFile
property value is the path where your automatically generated schema will be created
-
sortSchema
the types in the generated schema will be in the order they are defined in the included modules. To sort the schema lexicographically turn this attribute totrue
-
playground
to activategraqh-playground
-
debug
to turn on/off debug mode
GraphQL playground
The playground is a graphical, interactive, in-browser GraphQL IDE, available by default on the same URL as the GraphQL server itself. To access the playground, you need a basic GraphQL server configured and running.
-- NestJs Doc
We have already activated playground in the previous step, once the server is started (yarn start
), we can access it via the following URL: http://localhost:3000/graphql :
Populate & ResolveField
Mongoose has a powerful method called populate()
, which lets you reference documents in other collections.
Population is the process of automatically replacing the specified paths in the document with document(s) from other collection(s). We may populate a single document, multiple documents, a plain object, multiple plain objects, or all objects returned from a query. Let's look at some examples.
-- Mongoose Doc
We can use Mongoose populate
to resolve hobbies
field of Person
:
// person.resolver.ts
import {
Args,
Mutation,
Parent,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { Schema as MongooseSchema } from 'mongoose';
import { Person, PersonDocument } from './person.model';
import { PersonService } from './person.service';
import {
CreatePersonInput,
ListPersonInput,
UpdatePersonInput,
} from './person.inputs';
import { Hobby } from '../hobby/hobby.model';
@Resolver(() => Person)
export class PersonResolver {
constructor(private personService: PersonService) {}
@Query(() => Person)
async person(
@Args('_id', { type: () => String }) _id: MongooseSchema.Types.ObjectId,
) {
return this.personService.getById(_id);
}
@Query(() => [Person])
async persons(
@Args('filters', { nullable: true }) filters?: ListPersonInput,
) {
return this.personService.list(filters);
}
@Mutation(() => Person)
async createPerson(@Args('payload') payload: CreatePersonInput) {
return this.personService.create(payload);
}
@Mutation(() => Person)
async updatePerson(@Args('payload') payload: UpdatePersonInput) {
return this.personService.update(payload);
}
@Mutation(() => Person)
async deletePerson(
@Args('_id', { type: () => String }) _id: MongooseSchema.Types.ObjectId,
) {
return this.personService.delete(_id);
}
@ResolveField()
async hobbies(
@Parent() person: PersonDocument,
@Args('populate') populate: boolean,
) {
if (populate)
await person
.populate({ path: 'hobbies', model: Hobby.name })
.execPopulate();
return person.hobbies;
}
}
// person.model.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Field, ObjectType } from '@nestjs/graphql';
import { Document, Schema as MongooseSchema } from 'mongoose';
import { Hobby } from '../hobby/hobby.model';
@ObjectType()
@Schema()
export class Person {
@Field(() => String)
_id: MongooseSchema.Types.ObjectId;
@Field(() => String)
@Prop()
name: string;
@Field(() => [Hobby])
@Prop({ type: [MongooseSchema.Types.ObjectId], ref: Hobby.name })
hobbies: MongooseSchema.Types.ObjectId[] | Hobby[];
}
export type PersonDocument = Person & Document;
export const PersonSchema = SchemaFactory.createForClass(Person);
This allows us to do request Person
in this way:
Conclusion
As a developer, we combine different bricks of technologies to set up a viable ecosystem 🌍 for our project. In a javascript ecosystem, the advantage (or the disadvantage) is the abundance of brick and the possibility of combination. I hope to still have some motivation to be able to write a sequel to this blog post which explains the integration of this GraphQL API in a monorepo development environment and thus shares this data model between several applications and libraries.
Top comments (19)
@Prop({ type: [Types.ObjectId], ref: Hobby.name })
Should be
@Prop({ type: [mongoose.Schema.Types.ObjectId], ref: Hobby.name })
Otherwise the references end up as strings instead of ObjectId.
Thank you for your remark. Article updated!
You are welcome!
Nice article. I'd like to throw in another possible set of bricks for getting NestJS and Mongoose working. It's called Typegoose.
typegoose.github.io/typegoose/
(and corresponding NestJS Module for it: kpfromer.github.io/nestjs-typegoose/
Be aware though, using Mongoose with TypeScript is an adventure. They just moved their types to their own a few months ago and they are about 97% ok. But, there might be 3% where you'll be scratching your head. The Typegoose maintainer though works hard to work with the Mongoose team to get these type issues resolved and the upcoming 8.0 of Typegoose is shaping up to be pretty cool.
Hopefully, one day, we'll have a Mongoose written in TypeScript. Crossing my fingers!
Scott
Interesting, I will check Typegoose, I didn't know it. Thank you for your comment.
Thank you for the article!
I have found an issue however - once you query for Person's hobbies without "populate: true", you will get errors from the GraphQL API.
If anyone knows how to allow the endpoint to return both IDs and populated documents, it would help out a lot
There is a way, mentioned in NestJs doc, to create a combine type but it does not work with combining a scalar and object types. ex :
That's why I chose to add the populate attribute, so if we just want the ids, we set it to false, which will save db access for the mongoose populate operation.
Right, I was looking into that as well.
The issue for me was that if we use
populate: false
, the resolver would be returning an array of IDs, which GraphQL would complain about, since it's not of typeHobby
.I have personally used an approach where I specify extra GraphQL field
populatedHobbies
of type[Hobby]
and keephobbies
endpoint, which would return[ID]
Good idea !
Anyone using this technique of overlaying Graphql/Mongoose models using the mongoose discriminators shcema type? I'd like to have one "users" collection that discriminates document data base on the user kind. Is it even possible to get that working? Am I trying to create a nightmare for myself?
I'm testing things out now but having issues with graphql resolution since the gql schema needs to implement union types (I think). I might be going about it wrong. Any suggestions?
Thank you so much!
Overall it is a good article for a beginner to understand about nest and graphql. The only problem i am facing while running the project is the following error.
Error: Cannot determine a GraphQL input type nullfor the "_id". Make sure your class is decorated with an appropriate decorator.
Did you get the error on trying to run the project in the git repository?
Thank you , one of the best article on NestJs and Graphql based api so far.
Not sure if this has been pointed out but execPopulate has now been removed. Could you please caveat the execPopulate references?
stackoverflow.com/a/69444464/10032950
mongoosejs.com/docs/migrating_to_6...
Great article by the way. I found it useful
This blog post is exactly what I was looking for. You did a fantastic job explaining how to achieve this! Thank You! 🙇
If you find the time to write the sequel, I would be a happy reader.
This is exactly what I was trying to do but couldn't figure out without a proper example. Thank you so much!
~Cheers