Three in one (code first) : NestJs & GraphQl & Mongoose

Lotfi - Nov 1 '20 - - Dev Community

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 ;

-- How to GraphQL

.

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.

-- Filipe Mazzon

.

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
Enter fullscreen mode Exit fullscreen mode

This will initiate our NestJs project:

Alt Text

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 a controller AppController and a provider AppService.

To serve the nest server run :

$ npm start
Enter fullscreen mode Exit fullscreen mode

Alt Text

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:

Alt Text

Don't forget to update the import path of AppModule in main.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[];
}
Enter fullscreen mode Exit fullscreen mode
// hobby.model.ts

export class Hobby {
  _id: string;
  name: string;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The created modules PersonModule and HobbyModule are automatically imported in AppModule.

Now, move each file person.model.ts and hobby.model.ts into its corresponding modules :

Alt Text

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);
Enter fullscreen mode Exit fullscreen mode
// 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);
Enter fullscreen mode Exit fullscreen mode
  • 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 {}
Enter fullscreen mode Exit fullscreen mode
// 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 {}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

we use --flat to not generate a folder for the service.

Alt Text

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();
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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[];
}
Enter fullscreen mode Exit fullscreen mode
// 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();
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
// 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[];
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
// 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);
Enter fullscreen mode Exit fullscreen mode

For more information about @nestjs/graphql decorators ObjectType and Field: 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
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

For more information about @nestjs/graphql decorators Mutation, Resolver,and Query: 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[];
}
Enter fullscreen mode Exit fullscreen mode
// 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;
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode
  • 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 to true
  • playground to activate graqh-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 :

Alt Text

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;
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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);
Enter fullscreen mode Exit fullscreen mode

This allows us to do request Person in this way:

Alt Text


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.

. .
Terabox Video Player