Skip to content

Tags resolver

TagsModule, apps/api/src/tags/tags.service.ts (TagsService), and apps/api/src/tags/tags.resolver.ts (TagsResolver) — the first time TagsModule exists in this course; Mongoose connection named it as one of the feature modules following PostsModule’s shape “later in the course,” and this is that lesson. It uses the Tag schema from Schemas and the Tag GraphQL type from Code-first basics.

tags is a public, unguarded query — the same reasoning posts/post in Posts resolver already covered applies here: reading the list of tags is not privileged information, and the public site’s tag pages (Public Blog) need it with no session at all. createTag requires @UseGuards(GqlAuthGuard) only — any signed-in author can introduce a new tag while writing a post, matching createPost’s “any authenticated user can write” stance from the previous lesson, not deletePost’s admin-only one.

Post.tags is a plain string[] of tag slugsSchemas already marked this as a deliberately denormalized field with a dotted }o..o{ edge in its ER diagram, not a real Mongoose ref. That means creating a Tag document and adding its slug to some Post.tags array are two entirely independent writes — nothing in MongoDB enforces that a slug appearing in Post.tags also exists as a real Tag document, or vice versa. TagsService.create slugifying name the same way PostsService.create slugifies title (previous lesson) is what keeps the two collections speaking the same slug format; posts(tag: "nestjs") in Pagination filters by exactly that string.

Rejecting a duplicate tag name with ConflictException vs. silently returning the existing tag. TagsService.create throws 409 on a name that slugifies to something already in the collection, the same choice UsersService.create already made for a duplicate email in Password hashing — consistent behavior across the app for “you tried to create something that already exists,” at the cost of a client needing to catch that error and fall back to using the existing tag itself (a lookup this lesson doesn’t add a query for, since tags already returns the full list). The alternative — return the existing Tag document instead of erroring — reads as more convenient for a “create-if-missing” caller, but it means createTag can no longer distinguish “you created something new” from “this already existed,” which matters if a caller ever needs to know which one happened (e.g., to show a “new tag added” toast only on genuine creation).

Create apps/api/src/tags/tags.service.ts:

import { ConflictException, Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Tag, TagDocument } from './schemas/tag.schema';
@Injectable()
export class TagsService {
constructor(
@InjectModel(Tag.name) private readonly tagModel: Model<TagDocument>,
) {}
findAll(): Promise<TagDocument[]> {
return this.tagModel.find().sort({ name: 1 }).exec();
}
async create(name: string): Promise<TagDocument> {
// TODO(Content Workflow): a shared slugify() util replaces this inline copy —
// see the identical placeholder in PostsService.create.
const slug = name
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
const existing = await this.tagModel.findOne({ slug }).exec();
if (existing) {
throw new ConflictException(`Tag "${name}" already exists`);
}
const created = new this.tagModel({ name, slug });
return created.save();
}
}

Create apps/api/src/tags/tags.resolver.ts:

import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { TagsService } from './tags.service';
import { Tag } from './models/tag.model';
import { TagDocument } from './schemas/tag.schema';
import { GqlAuthGuard } from '../auth/gql-auth.guard';
@Resolver(() => Tag)
export class TagsResolver {
constructor(private readonly tagsService: TagsService) {}
@Query(() => [Tag])
tags(): Promise<TagDocument[]> {
return this.tagsService.findAll();
}
@Mutation(() => Tag)
@UseGuards(GqlAuthGuard)
createTag(@Args('name', { type: () => String }) name: string): Promise<TagDocument> {
return this.tagsService.create(name);
}
}
  • tags takes no arguments and needs none — the whole collection is expected to stay small (a blog’s tag vocabulary, not its post count), so there’s no pagination here the way Pagination adds for posts.
  • createTag takes a bare name: String! argument, not a CreateTagInput — a single required scalar doesn’t earn its own @InputType() the way CreatePostInput’s five fields do; introducing one here would be pure ceremony over one string.
  • Neither method has a @ResolveField()Tag has no relationship to resolve. Contrast with Post.author in Posts resolver, which needed one precisely because it references another entity by ObjectId.

Create apps/api/src/tags/tags.module.ts:

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Tag, TagSchema } from './schemas/tag.schema';
import { TagsService } from './tags.service';
import { TagsResolver } from './tags.resolver';
@Module({
imports: [MongooseModule.forFeature([{ name: Tag.name, schema: TagSchema }])],
providers: [TagsService, TagsResolver],
})
export class TagsModule {}

Register TagsModule in apps/api/src/app.module.ts, alongside PostsModule, UsersModule, and AuthModule:

import { join } from 'node:path';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default';
import { Logger } from '@nestjs/common';
import { Connection } from 'mongoose';
import * as Joi from 'joi';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PostsModule } from './posts/posts.module';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
import { TagsModule } from './tags/tags.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '../../.env',
validationSchema: Joi.object({
MONGODB_URI: Joi.string().uri().required(),
JWT_SECRET: Joi.string().min(10).required(),
API_PORT: Joi.number().port().default(4000),
WEB_ORIGIN: Joi.string().uri().required(),
}),
validationOptions: {
allowUnknown: true,
abortEarly: false,
},
}),
MongooseModule.forRootAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
uri: configService.getOrThrow<string>('MONGODB_URI'),
onConnectionCreate: (connection: Connection) => {
connection.on('connected', () =>
new Logger('MongooseModule').log('MongoDB connected'),
);
return connection;
},
}),
}),
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
sortSchema: true,
playground: false,
plugins: [ApolloServerPluginLandingPageLocalDefault()],
context: ({ req }) => ({ req }),
}),
PostsModule,
UsersModule,
AuthModule,
TagsModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
Terminal window
npm run start:dev

With an Authorization header from a login/register mutation still in the Sandbox’s Headers panel:

mutation CreateTag {
createTag(name: "NestJS") {
id
name
slug
}
}
{
"data": {
"createTag": { "id": "...", "name": "NestJS", "slug": "nestjs" }
}
}

Run the same mutation again with the same name and it fails with a 409-carrying GraphQL error — TagsService.create’s ConflictException, confirming the duplicate-slug guard is live. Then run:

query Tags {
tags {
name
slug
}
}
{
"data": {
"tags": [{ "name": "NestJS", "slug": "nestjs" }]
}
}

tags succeeds with no Authorization header at all, confirming it’s genuinely open. Finally, filter Posts resolver’s posts query by this exact slug:

query PostsByTag {
posts(tag: "nestjs") {
total
items {
title
}
}
}

A post created with tags: ["nestjs", ...] in the previous lesson comes back — confirming Post.tags and Tag.slug agree on the same string, even though nothing in MongoDB enforces that agreement directly.

TagsService.findAll/create back a fully public tags query and a guarded createTag mutation, the same “any signed-in user can write, no role required” stance createPost took in the previous lesson. TagsResolver has no @ResolveField() — unlike Post.author, Tag has no reference to another entity to bridge. TagsService.create slugifies name with the same placeholder logic PostsService.create uses for title, keeping Post.tags (a denormalized string[] of slugs, not a real relationship) and the Tag collection speaking the same slug format until Content Workflow replaces both with a shared, collision-handling slugify().

Next: Pagination →