Tags resolver
What we’re building
Section titled “What we’re building”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 slugs — Schemas 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.
Pros & cons
Section titled “Pros & cons”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).
Set it up
Section titled “Set it up”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); }}tagstakes 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 forposts.createTagtakes a barename: String!argument, not aCreateTagInput— a single required scalar doesn’t earn its own@InputType()the wayCreatePostInput’s five fields do; introducing one here would be pure ceremony over one string.- Neither method has a
@ResolveField()—Taghas no relationship to resolve. Contrast withPost.authorin Posts resolver, which needed one precisely because it references another entity byObjectId.
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 {}Verify
Section titled “Verify”npm run start:devWith 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 →