Schemas
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”Mongoose schema สี่ตัว — User, Post, Tag, Comment — คลาสละหนึ่งไฟล์ ใต้ apps/api/src/<feature>/schemas/ โดยใช้สไตล์ decorator ของ @nestjs/mongoose ทั้งสี่ collection นี้คือ persistent state ทั้งหมดของ DevBlog ทุกโมดูลถัดไป (auth, GraphQL resolvers, comments, admin) อ่านหรือเขียนผ่าน model ที่สร้างจาก schema เหล่านี้
erDiagram USER { ObjectId _id string email string passwordHash string displayName string role } POST { ObjectId _id string title string slug string body string status ObjectId author string_array tags } TAG { ObjectId _id string name string slug } COMMENT { ObjectId _id ObjectId post string authorName string authorEmail string body string status }
USER ||--o{ POST : "author (ObjectId ref, required)" POST ||--o{ COMMENT : "post (ObjectId ref, required)" POST }o..o{ TAG : "tags (denormalized slug strings — no ref)"ความสัมพันธ์เส้นทึบสองเส้น (author, post) คือ reference จริง ๆ ที่ resolve ด้วย populate ส่วนเส้นประระหว่าง Post–Tag ไม่ใช่ความสัมพันธ์ในระดับฐานข้อมูลเลย — Post.tags เก็บ tag slug เป็น string ตรง ๆ ไม่มี foreign key กลับไปที่ collection Tag Relationships & indexes จะอธิบายเหตุผล
ทุก collection ต้องมี schema ก่อนที่ส่วนอื่นของคอร์สจะ compile ได้: resolver ต้องการ return type, DTO ต้องการรูปร่างไว้ validate และ Mongoose เองก็ต้องการ schema เพื่อสร้าง model ที่ query ได้ การเขียนทั้งสี่ตัวพร้อมกันตอนนี้ยังทำให้ความสัมพันธ์ระหว่างกันชัดเจนอยู่ในที่เดียว — คุณจะเห็นว่า Comment.post และ Post.author เป็น reference จริงเพียงสองตัวเท่านั้น ก่อนที่จะเขียน resolver สักตัวเดียว
แพ็กเกจ @nestjs/mongoose เพิ่ม decorator สองตัวทับ Mongoose ธรรมดา:
@Schema()ทำเครื่องหมายว่าคลาสนี้เป็น schema definition@Schema({ timestamps: true })บอก Mongoose ให้เพิ่มและจัดการcreatedAt/updatedAtให้อัตโนมัติ — ใช้กับUser,Post, และCommentซึ่งทั้งหมดต้องรู้ว่า document ถูกสร้างหรือแก้ไขล่าสุดเมื่อไหร่ ส่วนTagไม่ใส่ option ใน@Schema()เพราะเป็นแค่คู่ name/slug ที่ไม่มีพฤติกรรมอิงเวลาเลย@Prop()ทำเครื่องหมายว่า property ของคลาสนี้เป็น field ใน schema ตัว option object ของ@Prop()มีรูปร่างเดียวกับ path definition ปกติของ Mongoose (required,unique,enum,default,index,type,ref)@nestjs/mongooseไม่ได้คิดภาษา validation ใหม่ขึ้นมา แค่ให้ประกาศไว้ข้าง ๆ property ของคลาสที่มี type แทนที่จะแยกไปเขียนใน plain object
SchemaFactory.createForClass(User) อ่าน decorator metadata จากคลาส User แล้วสร้าง mongoose.Schema instance จริง ๆ ขึ้นมา — เป็น object ประเภทเดียวกับที่คุณจะได้จาก new mongoose.Schema({...}) ด้วยมือ UserSchema ที่ export ออกมาคือสิ่งที่จะถูกลงทะเบียนพร้อม model name ผ่าน MongooseModule.forFeature() ใน Backend Foundations ที่เป็นจุดที่ @InjectModel(User.name) เริ่มคืนค่า Mongoose Model<UserDocument> ที่ใช้งานได้จริง
HydratedDocument<User> เป็น type ที่แยกจาก User เองโดยตั้งใจ User อธิบาย รูปร่าง ของข้อมูล — สิ่งที่ resolver คืนค่า, สิ่งที่ DTO validate เทียบด้วย ส่วน UserDocument = HydratedDocument<User> อธิบาย document Mongoose ที่มีชีวิต โดยเพิ่ม _id, save(), populate() และ instance method อื่น ๆ ที่ Mongoose ติดให้ตอน runtime โค้ด service ที่เรียก this.userModel.findOne(...) จะได้ UserDocument กลับมา ส่วน resolver ของ GraphQL ที่คืนข้อมูลให้ client ทำงานกับรูปร่าง User ธรรมดา
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”Document model (MongoDB) เทียบกับ relational (PostgreSQL) โพสต์บล็อกหนึ่งโพสต์โดยธรรมชาติคือ document เดียว: title, slug, Markdown body, field แบบ scalar ไม่กี่ตัว และ array tags เล็ก ๆ — การอ่านหรือเขียน “โพสต์หนึ่งโพสต์” คือการอ่านหรือเขียน document เดียว ไม่ต้อง join field ใหม่ ๆ (excerpt, coverImage) เพิ่มได้โดยไม่ต้อง migration ซึ่งสำคัญสำหรับ schema ที่ยังปรับตัวอยู่ในโมดูลต้น ๆ ต้นทุนก็ตามคาด: MongoDB ไม่มี foreign-key constraint จึงไม่มีอะไรกัน Post.author ไม่ให้ชี้ไปที่ User ที่ลบไปแล้ว application layer ต้องกันเอง (หรือยอมรับความเสี่ยงนั้น ตามที่ Relationships & indexes อธิบาย) การ join ข้าม collection (populate) ก็มีประสิทธิภาพน้อยกว่า relational JOIN ที่มี index ตรงกันทั้งสองฝั่ง และ transaction ที่แตะทั้ง Post และ Comment ต้องเปิด Mongoose session เอง ไม่ได้มาแบบ implicit เหมือน SQL transaction เดียว
สำหรับ DevBlog โดยเฉพาะ: ทุก hot read path (render โพสต์ตาม slug, list โพสต์ที่ published, list comment ที่ approved ของโพสต์) ตอบด้วยการ query collection เดียว พร้อม index ไม่ต้อง join หลายตัว — ซึ่งตรงกับสิ่งที่ document database ทำได้ดี TaskFlow โปรเจกต์ #1 ในซีรีส์นี้ใช้ PostgreSQL ด้วยเหตุผลตรงข้าม: ข้อมูล task/project/assignee มี requirement ด้าน relational integrity จริง ๆ ที่ task board พึ่งพา กราฟเนื้อหาของ DevBlog ไม่ต้องการแบบนั้น
เหตุผลที่ tags denormalize อยู่บน Post แต่ Comment เป็น collection แยกที่ reference สองอย่างนี้ดูเหมือนความสัมพันธ์แบบ “one-to-many” เดียวกัน แต่ถูก model ในทางตรงข้ามกันโดยตั้งใจ:
- Tags ถูก embed (เป็น
string[]ของ slug ตรง ๆ บนPost) โพสต์หนึ่งมี tag ได้แค่ไม่กี่ตัว ไม่ค่อยเปลี่ยนแยกจากตัวโพสต์ และ query สองตัวที่ต้องใช้ tag — “render tag ของโพสต์นี้” กับ “list โพสต์ที่ติด tag X” (Post.find({ tags: slug })) — ต่างก็อ่านจากPostโดยตรง การ embed จึงแปลว่า render tag ของโพสต์ได้โดยไม่ต้องpopulateและไม่ต้องมี round trip ที่สอง collectionTagยังอยู่ แต่ใช้เฉพาะงานที่มองเห็น tag เป็น entity ของตัวเอง (หน้าจัดการ tag ฝั่ง admin, การกันชื่อ slug ซ้ำ) ไม่ใช่บน read path - Comments ถูก reference (collection ของตัวเอง พร้อม field
postชี้กลับ) โพสต์ยอดนิยมสามารถสะสม comment ได้ไม่จำกัดจำนวน comment มี lifecycle ของตัวเองแยกจากโพสต์ (ส่งเข้ามา แล้วถูก moderate เป็นapprovedหรือrejected) และหน้าโพสต์แทบไม่เคยต้องโหลด comment ทั้งหมด แบบ inline ต้องการแค่ slice ที่ paginate และกรองตาม status ถ้า embed array ที่โตไม่จำกัดไว้ในPostทุกครั้งที่เพิ่ม comment จะต้องเขียน document โพสต์ทั้งก้อนใหม่ และทุกการอ่านโพสต์จะลาก comment ทุกตัวมาด้วย ไม่งั้นก็ต้องใช้เทคนิค slice array ที่งุ่มง่าม การแยกเป็น collection ต่างหากที่ query และ paginate เองได้ เลี่ยงปัญหาทั้งสองข้อนี้
หลักคร่าว ๆ: embed เมื่อข้อมูลลูกเล็ก มีขอบเขตจำกัด และแทบจะถูกอ่านพร้อมกับ parent เสมอ; reference เมื่อข้อมูลลูกไม่จำกัด มี lifecycle ของตัวเอง หรือมักถูกอ่านเดี่ยว ๆ
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”สร้าง apps/api/src/users/schemas/user.schema.ts:
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';import { HydratedDocument } from 'mongoose';
@Schema({ timestamps: true })export class User { @Prop({ required: true, unique: true }) email: string;
@Prop({ required: true }) passwordHash: string;
@Prop({ required: true }) displayName: string;
@Prop({ required: true, enum: ['author', 'admin'], default: 'author' }) role: 'author' | 'admin';}
export type UserDocument = HydratedDocument<User>;export const UserSchema = SchemaFactory.createForClass(User);email— required และ unique เพราะ email คือสิ่งที่ author ใช้ login บัญชีซ้ำด้วย email เดียวกันจึงต้องเป็นไปไม่ได้unique: trueบังคับกฎนี้ที่ระดับฐานข้อมูล ไม่ใช่แค่ใน application codepasswordHash— ไม่ใช่รหัสผ่านดิบ ๆ Authentication จะ hash รหัสผ่านด้วย bcrypt ก่อนลง field นี้เสมอdisplayName— ชื่อ author สาธารณะที่แสดงบนโพสต์ที่ publish แล้วrole—'author' | 'admin'ค่าเริ่มต้นคือ'author'นี่คือ field ที่ guard ในภายหลังจะเช็คเพื่อกั้น action ที่ admin เท่านั้นทำได้ เช่นการ moderate comment และจัดการ user
สร้าง apps/api/src/posts/schemas/post.schema.ts:
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';import { HydratedDocument, Schema as MongooseSchema, Types } from 'mongoose';
@Schema({ timestamps: true })export class Post { @Prop({ required: true }) title: string;
@Prop({ required: true, unique: true, index: true }) slug: string;
@Prop({ required: true }) body: string;
@Prop() excerpt?: string;
@Prop() coverImage?: string;
@Prop({ required: true, enum: ['draft', 'published'], default: 'draft', index: true }) status: 'draft' | 'published';
@Prop({ type: MongooseSchema.Types.ObjectId, ref: 'User', required: true }) author: Types.ObjectId;
@Prop({ type: [String], default: [] }) tags: string[];
@Prop() publishedAt?: Date;}
export type PostDocument = HydratedDocument<Post>;export const PostSchema = SchemaFactory.createForClass(Post);title— หัวข้อที่มนุษย์อ่านได้ แสดงตามที่เป็นslug— required, unique, มี index นี่คือ lookup key หลักสำหรับ URL หน้าโพสต์สาธารณะ (/posts/:slug) ความไม่ซ้ำกันกันไม่ให้สองโพสต์ชน URL เดียวกัน และ index คือสิ่งที่ทำให้ lookup ตาม slug เร็วแทนที่จะ scan collection ทั้งหมดbody— Markdown source ดิบ ๆ ที่ถูก render ฝั่ง client ด้วยreact-markdownใน Frontend Foundationsexcerpt/coverImage— metadata แบบ optional สำหรับหน้า list และ preview บน social/SEO ไม่ใช่ทุกโพสต์ที่ต้องมี จึงไม่requiredstatus—'draft' | 'published'มี index public API จะ query แค่status: 'published'เท่านั้น ส่วน admin dashboard query ทั้งสองค่า field นี้ถูกกรองตลอดเวลา ซึ่งตรงกับสิ่งที่ index มีไว้ทำauthor— referenceObjectIdแบบ required ไปที่Userไม่ใช่สำเนาข้อมูล author ที่ embed ไว้Userเป็น entity ของตัวเองพร้อม auth lifecycle ของตัวเอง แยกจากโพสต์ใดโพสต์หนึ่ง — การ embed snapshot ของ author จะล้าสมัยทันทีที่displayNameเปลี่ยนtags—string[]ธรรมดาของ tag slug ค่าเริ่มต้นคือ[]denormalize ไว้โดยตั้งใจ — ดู Pros & cons ด้านบนpublishedAt— optional ถูกตั้งค่าเฉพาะตอนโพสต์เปลี่ยนสถานะเป็นstatus: 'published'ใช้เรียงลำดับและแสดง public feed และแยกความต่างระหว่าง “สร้างแล้ว” กับ “publish แล้ว”
สร้าง apps/api/src/tags/schemas/tag.schema.ts:
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';import { HydratedDocument } from 'mongoose';
@Schema()export class Tag { @Prop({ required: true, unique: true }) name: string;
@Prop({ required: true, unique: true }) slug: string;}
export type TagDocument = HydratedDocument<Tag>;export const TagSchema = SchemaFactory.createForClass(Tag);name— รูปแบบที่แสดงของ tag (เช่น"Node.js")slug— รูปแบบที่ปลอดภัยสำหรับ URL ที่ใช้ทั่วทั้งระบบ รวมถึงเป็น string ดิบที่เก็บอยู่ในPost.tagsทั้งสอง field เป็น unique เพราะ collection นี้คือทะเบียนหลักที่คุมความสอดคล้องระหว่าง name กับ slug ของ tag แม้การอ่านส่วนใหญ่จะไม่แตะ collection นี้โดยตรงเลยก็ตาม
สร้าง apps/api/src/comments/schemas/comment.schema.ts:
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';import { HydratedDocument, Schema as MongooseSchema, Types } from 'mongoose';
@Schema({ timestamps: true })export class Comment { @Prop({ type: MongooseSchema.Types.ObjectId, ref: 'Post', required: true, index: true }) post: Types.ObjectId;
@Prop({ required: true }) authorName: string;
@Prop({ required: true }) authorEmail: string;
@Prop({ required: true }) body: string;
@Prop({ required: true, enum: ['pending', 'approved', 'rejected'], default: 'pending', index: true }) status: 'pending' | 'approved' | 'rejected';}
export type CommentDocument = HydratedDocument<Comment>;export const CommentSchema = SchemaFactory.createForClass(Comment);post— referenceObjectIdแบบ required และมี index ไปที่Postcomment ทุกตัวเป็นของโพสต์เดียวเท่านั้น และ “comment ของโพสต์ X” คือ query pattern เดียวที่ comment thread ทั้งหมดพึ่งพาauthorName/authorEmail— plain text ที่บันทึกตอนส่ง commenter สาธารณะไม่ใช่บัญชีUserที่ authenticated ในคอร์สนี้ จึงไม่มี referenceUserที่นี่ — มีแค่ตัวตนแบบ freeform ที่ commenter พิมพ์เข้ามาbody— เนื้อหา commentstatus—'pending' | 'approved' | 'rejected'ค่าเริ่มต้นคือ'pending'และมี index นี่คือประตู moderation ที่ Comments จะต่อยอด: comment ใหม่เริ่มต้นแบบซ่อนไว้ก่อน admin เป็นคนอนุมัติหรือปฏิเสธ และมีแค่approvedเท่านั้นที่ไปถึง public thread
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”ยืนยันว่าไฟล์ทั้งสี่อยู่ในตำแหน่งที่ถูกต้อง:
ls apps/api/src/*/schemasapps/api/src/comments/schemas:comment.schema.ts
apps/api/src/posts/schemas:post.schema.ts
apps/api/src/tags/schemas:tag.schema.ts
apps/api/src/users/schemas:user.schema.tsจากนั้น type-check โปรเจกต์ — error ของ decorator metadata และการพิมพ์ผิดใน options ของ @Prop() จะโผล่ออกมาตรงนี้ ก่อนที่คุณจะไปต่อสายอะไรอื่นเข้ากับ schema พวกนี้:
cd apps/apinpx tsc --noEmitถ้ารันผ่านจะไม่พิมพ์อะไรเลยและ exit 0 ถ้าขึ้น error เรื่อง experimentalDecorators หรือ emitDecoratorMetadata ให้ไปเช็ค apps/api/tsconfig.json ตัว scaffold จาก Nest CLI ใน Backend init เปิดทั้งสอง option ไว้เป็นค่าเริ่มต้นอยู่แล้ว ซึ่ง @Schema()/@Prop() ต้องใช้เพื่ออ่าน class metadata ตอน runtime
Schema สี่ตัว ไฟล์ละหนึ่ง collection ทั้งหมดใช้สไตล์ decorator @Schema()/@Prop() และ SchemaFactory.createForClass() เพื่อแปลงคลาสที่มี type เป็น Mongoose schema จริง ๆ User, Post และ Comment เปิด timestamps ส่วน Tag ไม่ต้องใช้ Document model เข้ากับ read pattern จริงของ DevBlog พอดี คือหนึ่ง collection ต่อหนึ่ง query ที่ hot ไม่ต้อง join บน critical path แลกมาด้วยการไม่มี foreign-key enforcement และการ join ข้าม collection ที่ช้ากว่า SQL
Post.tags denormalize ไว้ (เก็บ slug ตรง ๆ ไม่ผ่าน reference ไป Tag) เพราะ tag เล็ก มีขอบเขตจำกัด และอ่านพร้อมโพสต์เสมอ ส่วน Comment แยกเป็น collection ของตัวเองแบบ reference เพราะ comment โตได้ไม่จำกัดและมี moderation lifecycle ของตัวเอง สำหรับ field reference author กับ post, index ทั้งสี่ตัวที่ประกาศไว้ด้านบน และวิธีที่ populate resolve reference เหล่านี้ เป็นหัวข้อของบทเรียนถัดไป
ถัดไป: Relationships & Indexes →