In Part 2, we set up our database with Prisma and implemented our repositories. Now, in Part 3, we will implement the GraphQL resolvers and wire everything together using InversifyJS to create a running Apollo Server.
Step 1: Defining the Context
First, let's define the Context interface that will be passed to our resolvers. This usually contains the request object and any other global state.
Create src/graphql/models/Context.ts:
import type express from 'express';
export interface Context {
readonly request: express.Request;
}
Step 2: Updating Repositories
Before we implement the resolvers, we need to add some methods to our repositories to support the queries and mutations we'll be implementing.
Category Repository
Update src/category/repositories/CategoryRepository.ts to add createOne, findPaginatedAll, and findOneById:
import { inject, injectable } from "inversify";
- import { PrismaClient } from "../../../generated/index.js";
+ import { Category, PrismaClient } from "../../../generated/index.js";
@injectable()
export class CategoryRepository {
readonly #client: PrismaClient;
constructor(@inject(PrismaClient) client: PrismaClient) {
this.#client = client;
}
public async count(): Promise<number> {
return this.#client.category.count();
}
+
+ public async createOne(name: string, slug: string): Promise<Category> {
+ return this.#client.category.create({
+ data: {
+ name,
+ slug,
+ },
+ });
+ }
+
+ public async findPaginatedAll(
+ first: number,
+ after?: string,
+ ): Promise<Category[]> {
+ return this.#client.category.findMany({
+ cursor: after ? { id: after } : undefined,
+ skip: after ? 1 : 0,
+ take: first,
+ });
+ }
+
+ public async findOneById(id: string): Promise<Category | undefined> {
+ const category = await this.#client.category.findUnique({
+ where: { id },
+ });
+ return category ?? undefined;
+ }
}
Product Repository
Update src/product/repositories/ProductRepository.ts to add createOne, findPaginatedAllByCategoryId, countByCategoryId, and findOneById:
import { inject, injectable } from "inversify";
- import { PrismaClient } from "../../../generated/index.js";
+ import { PrismaClient, Product } from "../../../generated/index.js";
@injectable()
export class ProductRepository {
readonly #client: PrismaClient;
constructor(@inject(PrismaClient) client: PrismaClient) {
this.#client = client;
}
public async count(): Promise<number> {
return this.#client.product.count();
}
+
+ public async createOne(
+ categoryId: string,
+ title: string,
+ description: string,
+ currency: string,
+ price: number,
+ ): Promise<Product> {
+ return this.#client.product.create({
+ data: {
+ categoryId,
+ currency,
+ description,
+ price,
+ title,
+ },
+ });
+ }
+
+ public async findPaginatedAllByCategoryId(
+ categoryId: string,
+ first: number,
+ after?: string,
+ ): Promise<Product[]> {
+ return this.#client.product.findMany({
+ cursor: after ? { id: after } : undefined,
+ skip: after ? 1 : 0,
+ take: first,
+ where: { categoryId },
+ });
+ }
+
+ public async countByCategoryId(categoryId: string): Promise<number> {
+ return this.#client.product.count({
+ where: { categoryId },
+ });
+ }
+
+ public async findOneById(id: string): Promise<Product | undefined> {
+ const product = await this.#client.product.findUnique({
+ where: { id },
+ });
+ return product ?? undefined;
+ }
}
Step 3: Implementing Resolvers
We will implement our resolvers as injectable classes. This allows us to inject our repositories directly into them.
Category Resolvers
We need to implement the Category field resolvers, specifically for the products field, to handle the relationship between Category and Product. We'll implement full pagination logic here.
Create src/category/resolvers/CategoryResolvers.ts:
import { inject, injectable } from 'inversify';
import { type Product as ProductDb } from '../../../generated/index.js';
import { Context } from '../../graphql/models/Context.js';
import type * as graphqlModels from '../../graphql/models/types.js';
import { ProductRepository } from '../../product/repositories/ProductRepository.js';
const MAX_ELEMENTS_PER_PAGE: number = 20;
@injectable()
export class CategoryResolvers implements graphqlModels.CategoryResolvers<Context> {
readonly #productRepository: ProductRepository;
constructor(
@inject(ProductRepository)
productRepository: ProductRepository,
) {
this.#productRepository = productRepository;
}
public id(parent: graphqlModels.Category): string {
return parent.id;
}
public name(parent: graphqlModels.Category): string {
return parent.name;
}
public async products(
parent: graphqlModels.Category,
args: Partial<graphqlModels.CategoryProductsArgs>,
): Promise<graphqlModels.ProductConnection> {
const firstValue: number = Math.max(
Math.min(args.first ?? MAX_ELEMENTS_PER_PAGE, MAX_ELEMENTS_PER_PAGE),
0,
);
const [productsDb, totalCount]: [ProductDb[], number] = await Promise.all([
this.#productRepository.findPaginatedAllByCategoryId(
parent.id,
firstValue,
args.after ?? undefined,
),
this.#productRepository.countByCategoryId(parent.id),
]);
const endCursor: string | null =
productsDb.length > 0
? (productsDb[productsDb.length - 1]?.id ?? null)
: null;
const startCursor: string | null =
productsDb.length > 0 ? (productsDb[0]?.id ?? null) : null;
return {
edges: productsDb.map((product: ProductDb) => ({
cursor: product.id,
node: {
currency: product.currency,
description: product.description,
id: product.id,
price: product.price,
title: product.title,
},
})),
pageInfo: {
endCursor,
hasNextPage: productsDb.length === firstValue,
hasPreviousPage: args.after != null,
startCursor,
},
totalCount,
};
}
public slug(parent: graphqlModels.Category): string {
return parent.slug;
}
}
Query Resolvers
Now let's implement the root Query resolvers, including pagination for categories and fetching individual items.
Create src/app/resolvers/QueryResolvers.ts:
import { inject, injectable } from 'inversify';
import {
type Category as CategoryDb,
type Product as ProductDb,
} from '../../../generated/index.js';
import { CategoryRepository } from '../../category/repositories/CategoryRepository.js';
import { type Context } from '../../graphql/models/Context.js';
import type * as graphqlModels from '../../graphql/models/types.js';
import { ProductRepository } from '../../product/repositories/ProductRepository.js';
const MAX_ELEMENTS_PER_PAGE: number = 20;
@injectable()
export class QueryResolvers implements graphqlModels.QueryResolvers<Context> {
readonly #categoryRepository: CategoryRepository;
readonly #productRepository: ProductRepository;
constructor(
@inject(CategoryRepository)
categoryRepository: CategoryRepository,
@inject(ProductRepository)
productRepository: ProductRepository,
) {
this.#categoryRepository = categoryRepository;
this.#productRepository = productRepository;
}
public async categories(
_parent: unknown,
args: graphqlModels.QueryCategoriesArgs,
): Promise<graphqlModels.CategoryConnection> {
const firstValue: number = Math.max(
Math.min(args.first ?? MAX_ELEMENTS_PER_PAGE, MAX_ELEMENTS_PER_PAGE),
0,
);
const [categoriesDb, totalCount]: [CategoryDb[], number] =
await Promise.all([
this.#categoryRepository.findPaginatedAll(
firstValue,
args.after ?? undefined,
),
this.#categoryRepository.count(),
]);
const endCursor: string | null =
categoriesDb.length > 0
? (categoriesDb[categoriesDb.length - 1]?.id ?? null)
: null;
const startCursor: string | null =
categoriesDb.length > 0 ? (categoriesDb[0]?.id ?? null) : null;
return {
edges: categoriesDb.map((category: CategoryDb) => ({
cursor: category.id,
node: {
id: category.id,
name: category.name,
slug: category.slug,
},
})),
pageInfo: {
endCursor,
hasNextPage: categoriesDb.length === firstValue,
hasPreviousPage: args.after != null,
startCursor,
},
totalCount,
};
}
public async category(
_parent: unknown,
args: graphqlModels.QueryCategoryArgs,
): Promise<Partial<graphqlModels.Category> | null> {
const categoryDb: CategoryDb | undefined =
await this.#categoryRepository.findOneById(args.id);
if (categoryDb === undefined) {
return null;
}
return {
id: categoryDb.id,
name: categoryDb.name,
slug: categoryDb.slug,
};
}
public async product(
_parent: unknown,
args: graphqlModels.QueryProductArgs,
): Promise<graphqlModels.Product | null> {
const productDb: ProductDb | undefined =
await this.#productRepository.findOneById(args.id);
if (productDb === undefined) {
return null;
}
return {
currency: productDb.currency,
description: productDb.description,
id: productDb.id,
price: productDb.price,
title: productDb.title,
};
}
}
Mutation Resolvers
Create src/app/resolvers/MutationResolvers.ts:
import { inject, injectable } from 'inversify';
import {
type Category as CategoryDb,
type Product as ProductDb,
} from '../../../generated/index.js';
import { CategoryRepository } from '../../category/repositories/CategoryRepository.js';
import { type Context } from '../../graphql/models/Context.js';
import type * as graphqlModels from '../../graphql/models/types.js';
import { ProductRepository } from '../../product/repositories/ProductRepository.js';
@injectable()
export class MutationResolvers implements graphqlModels.MutationResolvers<Context> {
readonly #categoryRepository: CategoryRepository;
readonly #productRepository: ProductRepository;
constructor(
@inject(CategoryRepository)
categoryRepository: CategoryRepository,
@inject(ProductRepository)
productRepository: ProductRepository,
) {
this.#categoryRepository = categoryRepository;
this.#productRepository = productRepository;
}
public async createCategory(
_parent: unknown,
args: graphqlModels.MutationCreateCategoryArgs,
): Promise<graphqlModels.Category> {
const category: CategoryDb = await this.#categoryRepository.createOne(
args.input.name,
args.input.slug,
);
return {
id: category.id,
name: category.name,
products: {
edges: [],
pageInfo: {
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
},
totalCount: 0,
},
slug: category.slug,
};
}
public async createProduct(
_parent: unknown,
args: graphqlModels.MutationCreateProductArgs,
): Promise<graphqlModels.Product> {
const product: ProductDb = await this.#productRepository.createOne(
args.input.categoryId,
args.input.title,
args.input.description,
args.input.currency,
args.input.price,
);
return {
currency: product.currency,
description: product.description,
id: product.id,
price: product.price,
title: product.title,
};
}
}
App Resolvers
Finally, we aggregate all resolvers into a single AppResolvers class.
Create src/app/resolvers/AppResolvers.ts:
import { inject, injectable } from 'inversify';
import { CategoryResolvers } from '../../category/resolvers/CategoryResolvers.js';
import { type Context } from '../../graphql/models/Context.js';
import type * as graphqlModels from '../../graphql/models/types.js';
import { MutationResolvers } from './MutationResolvers.js';
import { QueryResolvers } from './QueryResolvers.js';
@injectable()
export class AppResolvers implements Partial<graphqlModels.Resolvers<Context>> {
public readonly Category: graphqlModels.CategoryResolvers<Context>;
public readonly Mutation: graphqlModels.MutationResolvers<Context>;
public readonly Query: graphqlModels.QueryResolvers<Context>;
constructor(
@inject(CategoryResolvers)
categoryResolvers: CategoryResolvers,
@inject(MutationResolvers)
mutationResolvers: MutationResolvers,
@inject(QueryResolvers)
queryResolvers: QueryResolvers,
) {
this.Category = categoryResolvers;
this.Mutation = mutationResolvers;
this.Query = queryResolvers;
}
}
Step 4: Wiring the Container
We need to bind our resolvers in the Inversify container.
Update Category Container Module
We need to update the CategoryContainerModule to bind the CategoryResolvers.
Update src/category/modules/CategoryContainerModule.ts:
import { ContainerModule, type ContainerModuleLoadOptions } from 'inversify';
import { CategoryRepository } from '../repositories/CategoryRepository.js';
+ import { CategoryResolvers } from '../resolvers/CategoryResolvers.js';
export class CategoryContainerModule extends ContainerModule {
constructor() {
super((options: ContainerModuleLoadOptions): void => {
options.bind(CategoryRepository).toSelf().inSingletonScope();
+ options.bind(CategoryResolvers).toSelf().inSingletonScope();
});
}
}
Create App Container Module
We also need a module for the application-level resolvers.
Create src/app/modules/AppContainerModule.ts:
import { ContainerModule, type ContainerModuleLoadOptions } from 'inversify';
import { AppResolvers } from '../resolvers/AppResolvers.js';
import { MutationResolvers } from '../resolvers/MutationResolvers.js';
import { QueryResolvers } from '../resolvers/QueryResolvers.js';
export class AppContainerModule extends ContainerModule {
constructor() {
super((options: ContainerModuleLoadOptions): void => {
options.bind(AppResolvers).toSelf().inSingletonScope();
options.bind(MutationResolvers).toSelf().inSingletonScope();
options.bind(QueryResolvers).toSelf().inSingletonScope();
});
}
}
Step 5: Server Bootstrap
Now we will create the entry point of our application, where we set up the Inversify container, load all modules, and start the Apollo Server.
Create src/app/scripts/bootstrap.ts:
import type http from 'node:http';
import { type ExpressContextFunctionArgument } from '@as-integrations/express5';
import {
type InversifyApolloProvider,
inversifyApolloProviderServiceIdentifier,
} from '@inversifyjs/apollo-core';
import { ApolloExpressServerContainerModule } from '@inversifyjs/apollo-express';
import { readSchemas } from '@inversifyjs/graphql-codegen';
import { InversifyExpressHttpAdapter } from '@inversifyjs/http-express';
import { Container } from 'inversify';
import { CategoryContainerModule } from '../../category/modules/CategoryContainerModule.js';
import { PrismaModule } from '../../foundation/db/modules/PrismaModule.js';
import { type Context } from '../../graphql/models/Context.js';
import { ProductContainerModule } from '../../product/modules/ProductContainerModule.js';
import { AppContainerModule } from '../modules/AppContainerModule.js';
import { AppResolvers } from '../resolvers/AppResolvers.js';
const PORT: number = 3000;
const container: Container = new Container();
await container.load(
new AppContainerModule(),
ApolloExpressServerContainerModule.graphServerFromOptions<Context>(
{
controllerOptions: {
path: '',
},
getContext: async (
arg: ExpressContextFunctionArgument,
): Promise<Context> => ({
request: arg.req,
}),
},
{
resolverServiceIdentifier: AppResolvers,
typeDefs: await readSchemas({
glob: {
patterns: ['./graphql/schemas/**/*.graphql'],
},
}),
},
),
new CategoryContainerModule(),
new PrismaModule(),
new ProductContainerModule(),
);
const adapter: InversifyExpressHttpAdapter = new InversifyExpressHttpAdapter(
container,
);
await adapter.build();
const inversifyApolloProvider: InversifyApolloProvider<http.Server> =
await container.getAsync(inversifyApolloProviderServiceIdentifier);
await new Promise<void>((resolve: () => void) => {
inversifyApolloProvider.server.listen(PORT, () => {
resolve();
});
});
console.log(`Server is running on http://localhost:${PORT.toString()}`);
And finally, update src/index.ts to import the bootstrap script:
import './app/scripts/bootstrap.js';
Conclusion
We have successfully implemented our GraphQL resolvers and set up the Apollo Server using InversifyJS. We can now run our application and interact with our API!
In the next part, we will implement graphql subscriptions on top of web sockets and a Redis Pub/Sub pattern.
Top comments (0)