หลายครั้งผมเปิดโปรเจกต์ Next.js ของตัวเองที่เขียนไปแล้ว 3 เดือน แล้วนั่งงงว่า "เอิ่มม... โค้ดตรงนี้มันทำอะไรวะ?" หรือไม่ก็เวลาจะเพิ่มฟีเจอร์ใหม่แล้วไม่รู้ว่าควรไปแก้ไฟล์ไหน แล้วกลัวว่าไปแตะโค้ดที่ไม่เกี่ยวแล้วระบบพัง?
ผมเลยนั่งเขียน Cookbook เล่มนี้ขึ้นมาเพื่อช่วยให้เราสร้างโปรเจกต์ Next.js ที่:
- ดูแลง่าย - 3 เดือนต่อมายังจำได้ว่าโค้ดอยู่ตรงไหน ทำอะไร
- เพิ่มฟีเจอร์ได้แบบชิลๆ - ไม่ต้องกลัวว่าจะไปทำอะไรพัง
- ทำงานเป็นทีมได้ - เพื่อนร่วมงานเอาโค้ดเราไปต่อได้โดยไม่ต้องมานั่งถาม
- Scale ได้ - จะโปรเจกต์เล็กหรือใหญ่ก็ไม่พัง
เหมาะสำหรับผู้ที่มีพื้นฐาน Next.js และ React มาก่อนแล้ว ถ้ายังไม่มี แนะนำให้ลองไปศึกษากันมาก่อนน้าา
เกริ่นนำ: ทำไมต้องมี Design Pattern ด้วย?
เรามาเริ่มด้วยเรื่องจริงกันก่อน Next.js มันเจ๋งมาก flexible มาก แต่... มันก็เหมือนกับให้เครื่องมือฟรีๆ แล้วบอกว่า "ไปสร้างบ้านเอาเองเด้อ"
ปัญหาคือถ้าเราไม่มีแบบแปลน ไม่มีแนวคิดที่ชัดเจน สุดท้ายแล้วเราจะได้บ้านที่:
- วางของไม่รู้ว่าอยู่ห้องไหน (โค้ดกระจัดกระจาย)
- เวลาจะซ่อมอะไรต้องงัดทั้งบ้าน (แก้นิดเดียวแต่กระทบหลายที่)
- คนใหม่เข้ามาแล้วหลงทาง (onboarding ใช้เวลานาน)
แล้วจะเกิดอะไรขึ้น?
- Technical Debt สะสม - เริ่มจากโค้ด "พอใช้ได้" ค่อยๆ กลายเป็นโค้ดที่ไม่มีใครอยากแตะ
- Development ช้าลง - เพิ่มฟีเจอร์ใหม่ใช้เวลานานขึ้นเรื่อยๆ
- Bug เยอะขึ้น - แก้ที่หนึ่งแล้วเสียที่อื่น
- Team stress - ทุกคนกลัวที่จะแก้โค้ด
ถ้าเกิดว่าเรามีโครงสร้างโปรเจคที่ชัดเจน เราก็จะ:
- ประหยัดเวลาในระยะยาว (ตอนแรกอาจช้าหน่อย แต่ต่อไปจะเร็วมาก)
- น้อย bug (เพราะโครงสร้างชัดเจน)
- Happy team (ไม่ต้องทายเดาว่าโค้ดอยู่ไหน)
- และก็ Sleep well (ไม่ต้องกังวลว่าระบบจะพัง)
บทที่ 1: จัดโครงสร้างโปรเจกต์ให้เป็นระเบียบ
ปัญหาที่เจอบ่อยๆ
เคยไหมที่เปิดโฟลเดอร์โปรเจกต์ Next.js แล้วเจอหน้าตาแบบนี้:
/app
/api
/users
route.ts # รวม GET/POST/PUT/DELETE + logic + utility
/orders
route.ts # รวม API + processing logic + helper functions
/payments
route.ts
/reports
route.ts
/everything-else
route.ts
/login
page.tsx # หน้า login + API call + validation + helper functions
/dashboard
page.tsx # หน้า dashboard + business logic + API call + utility
/users
page.tsx # หน้า users + API call + processing logic + components
/orders
page.tsx # หน้า orders + API call + calculations + inline components
/random-component
page.tsx # component เล็กๆ + utility functions
/lib
utils.ts # รวมทุก helper function เล็กใหญ่
api-calls.ts # ฟังก์ชันเรียก API ทุกตัว (แต่บางหน้าเรียก API แบบ inline ด้วย)
business-logic.ts # logic ทุกตัว แต่หลายครั้งถูก override ใน page.tsx
แล้วพอจะแก้อะไรสักอย่าง ต้องมานั่งเดาว่าโค้ดอยู่ไฟล์ไหน Logic อยู่ตรงไหน กว่าจะเจออาจเสียเวลาไป 30 นาที... แล้วพอเจอแล้ว ไฟล์นั้นมันยาว 500 บรรทัด ต้องมานั่งหาต่อว่าบรรทัดไหน 😵💫
แล้วเราควรจะจัดยังไง?
หลักคิดง่ายๆ คือ "แยกตามหน้าที่" เหมือนเราจัดบ้าน:
- ห้องครัว = ทำอาหาร (Business Logic)
- ห้องนั่งเล่น = รับแขก (UI/Frontend)
- ห้องเก็บของ = เก็บของ (Database/Storage)
มาดูโครงสร้างที่แนะนำ:
โปรเจกต์-ของ-เรา/
├── src/
│ ├── app/ # 🏠 หน้าบ้าน (UI + Routing)
│ │ ├── students/
│ │ │ ├── page.tsx # หน้าแสดงรายชื่อนักเรียน
│ │ │ ├── [id]/
│ │ │ │ └── page.tsx # หน้าดูรายละเอียดนักเรียน
│ │ │ └── components/
│ │ │ ├── student-table.tsx
│ │ │ └── student-form.tsx
│ │ └── layout.tsx
│ │
│ ├── features/ # 🧠 สมอง (Business Logic)
│ │ └── student/
│ │ ├── student.actions.ts # รับคำสั่งจากหน้าเว็บ
│ │ ├── student.service.ts # ความคิด/กฎเกณฑ์
│ │ ├── student.dal.ts # คุยกับฐานข้อมูล
│ │ ├── student.types.ts # กำหนดรูปแบบข้อมูล
│ │ └── student.test.ts # ทดสอบการทำงาน
│ │
│ ├── components/ # 🧩 ของเล่น (Global Reusable UI)
│ │ ├── ui/
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ └── modal.tsx
│ │ ├── editors/
│ │ │ ├── markdown-editor.tsx
│ │ │ └── rich-text-editor.tsx
│ │ └── data-display/
│ │ ├── data-table.tsx
│ │ └── chart.tsx
│ │
│ └── lib/ # 🔧 เครื่องมือ (Utilities)
│ ├── db.ts # ติดต่อฐานข้อมูล
│ ├── errors.ts # จัดการข้อผิดพลาด
│ └── utils.ts # เครื่องมือทั่วไป
│
├── package.json
└── next.config.js
ทำไมต้องแยกแบบนี้?
- จะดูหน้าเว็บ → ไป
/app
- จะแก้ business logic → ไป
/features
- หา component → ไป
/components
- ทีมทำงานพร้อมกันได้ (แยกไฟล์ชัดเจน)
- Code review ง่ายขึ้น
แล้ววาง Components ไว้ตรงไหนดี?
คำถามสำคัญที่มักเจอคือ "Component นี้ควรเอาไว้ในโฟลเดอร์ไหน?" ให้ลองใช้ Decision Tree นี้:
Component ใหม่ → ใช้แค่หน้าเดียว?
├── YES → app/[route]/components/ (เช่น StudentTable)
└── NO → มี business logic เฉพาะ domain?
├── YES → features/[domain]/components/ (เช่น StudentCard)
└── NO → components/ (เช่น Button, MarkdownEditor)
โอเค มาดูตัวอย่างหลายๆกรณีกัน
app/[route]/components/ - ของใช้ส่วนตัว
// app/students/components/student-table.tsx
// ใช้เฉพาะในหน้า students เท่านั้น
export function StudentTable() {
// logic เฉพาะหน้า students แบบ แค่หน้านี้จริงๆ ไม่ได้ไปหน้าอื่นๆเลย
}
เมื่อไหร่ใช้:
- Component ผูกติดกับหน้านั้นเท่านั้น
- ไม่มีแผนจะใช้ที่อื่น
- มี logic เฉพาะหน้า
components/ - ของใช้ส่วนรวม
components/
├── ui/ # primitive components
│ ├── button.tsx
│ ├── input.tsx
│ ├── dialog.tsx
│ └── card.tsx
├── editors/ # complex components
│ ├── markdown-editor.tsx
│ └── rich-text-editor.tsx
├── data-display/
│ ├── data-table.tsx
│ └── chart.tsx
└── forms/
├── form-builder.tsx
└── file-uploader.tsx
เมื่อไหร่ใช้:
- ui/: UI primitives พื้นฐาน (Button, Input, Dialog, Card)
- อื่นๆ: Complex components ที่ใช้ซ้ำได้ทั่วแอป
- ไม่มี business logic เฉพาะ domain
- สามารถใช้ใน feature ไหนก็ได้
ตัวอย่าง:
// components/ui/button.tsx - primitive
export function Button() { /* basic button */ }
// components/editors/markdown-editor.tsx - complex but reusable
export function MarkdownEditor() { /* full-featured editor */ }
features/[domain]/components/ - ของใช้เฉพาะแผนก
// features/student/components/student-card.tsx
// บัตรประจำตัวนักศึกษา ซึ่งจะใช้ใน domain student หลายหน้า
export function StudentCard({ student }) {
// logic เฉพาะ student domain ซึ่งอาจจะแชร์ไปหน้าอื่นๆในโดเมน student บ้าง เช่น app/student-list app/student-admid อะไรประมาณนี้
}
เมื่อไหร่ใช้:
- Component เฉพาะ domain แต่ใช้หลายหน้า
- มี business logic ของ domain นั้น
- ตัวอย่าง:
StudentCard
(ใช้ใน list, detail, dashboard)
กฎทองในการย้าย Component
คือ เริ่มจากใกล้ที่สุด → ค่อยขยายออก
- เริ่มที่
app/[route]/components/
ก่อนเสมอ - ถ้าเริ่มใช้หน้าอื่น → ย้ายไป
features/[domain]/components/
- ถ้าเริ่มใช้หลาย domain → ย้ายไป
components/ui/
📚 ตัวอย่างจริง
StudentForm → app/students/components/ (ใช้เฉพาะหน้า students)
Button → components/ui/ (ใช้ทุกที่ในแอป)
StudentCard → features/student/components/ (ใช้ใน student domain)
จำไว้: เริ่มจากใกล้ แล้วค่อยขยาย ตามการใช้งานจริง ไม่ต้องคิดมากตั้งแต่แรกเว้ย
กฎง่ายๆ ที่ควรจำ
✅ ควรทำ:
- ใช้ TypeScript (จริงๆ นะครับ มันช่วยได้เยอะ)
- แต่ละฟีเจอร์แยกโฟลเดอร์
- ตั้งชื่อไฟล์ให้เข้าใจง่าย เช่น
student.service.ts
ไม่ใช่services.ts
,student-table.tsx
ไม่ใช่table.tsx
- แยก logic ออกจาก UI
❌ ไม่ควรทำ:
- เอา business logic ไปใส่ใน
/app
(มันจะวุ่นวาย) - วาง business logic ใน
/components
- สร้างไฟล์ยักษ์ที่มีทุกอย่าง
- ลืมเขียน test (เดี๋ยวปลุกขึ้นมาแก้ 500 Internal rrror กลางดึกได้)
ตัวอย่างการใช้งานจริง
มาดูตัวอย่างการสร้างระบบจัดการนักเรียนง่ายๆ:
// src/features/student/student.types.ts
export interface Student {
id: number;
name: string;
age: number;
email: string;
createdAt: Date;
}
export interface CreateStudentInput {
name: string;
age: number;
email: string;
}
ไฟล์นี้เก็บ "รูปแบบ" ของข้อมูลนักเรียน ทุกไฟล์อื่นจะมาดูที่นี่ว่านักเรียนมีข้อมูลอะไรบ้าง
// src/app/students/page.tsx
import { getStudents } from "@/features/student/student.service";
import { StudentTable } from "./components/student-table";
// หน้าแสดงรายชื่อนักเรียน - ทำงานฝั่ง server
export default async function StudentsPage() {
const students = await getStudents();
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">รายชื่อนักเรียน</h1>
<StudentTable students={students} />
</div>
);
}
ไฟล์นี้เป็น "หน้าเว็บ" ที่แสดงรายชื่อนักเรียน มีหน้าที่แค่เรียกข้อมูลมาแสดง ไม่ได้สนใจว่าข้อมูลมาจากไหน
บทที่ 2: Data Access Layer (DAL) - คนคุยกับฐานข้อมูล
เข้าใจปัญหาก่อน
ลองนึกภาพว่าเราเป็นเจ้าของร้านอาหาร:
- พ่อครัว (Service Layer) ต้องการเนื้อมาทำอาหาร
- คนซื้อของ (DAL) ไปตลาดซื้อเนื้อมา
- พ่อครัวไม่จำเป็นต้องรู้ว่าเนื้อซื้อจากร้านไหน ราคาเท่าไหร่ แค่ได้เนื้อที่ดีมาทำอาหาร
DAL ก็เป็นแบบนี้แหละ มีหน้าที่ "คุยกับฐานข้อมูล" เท่านั้น ไม่สนใจว่าใครต้องการข้อมูล หรือจะเอาไปทำอะไร
ทำไมต้องแยก DAL?
ก่อนมี DAL - โค้ดจะหน้าตาแบบนี้:
// ใน server component หรือ API route
const user = await prisma.user.findFirst({
where: { email },
include: { profile: true }
});
// ในที่อื่น
const users = await prisma.user.findMany({
orderBy: { name: 'asc' }
});
// อีกที่หนึ่ง
const updateUser = await prisma.user.update({
where: { id },
data: { name, email }
});
ปัญหาคือ:
- โค้ด query กระจัดกระจาย
- เปลี่ยน ORM จาก Prisma เป็น Drizzle ต้องไปแก้ทุกที่ (กด Ctrl + Shift + F กันทั้งวัน)
- Test ยาก (ต้อง mock database)
หลังมี DAL:
// src/features/student/student.dal.ts
import { db } from "@/lib/prisma";
import type { CreateStudentInput, Student } from "./student.types";
export const studentDAL = {
// ค้นหานักเรียนตาม id
async findById(id: number): Promise<Student | null> {
return db.student.findUnique({
where: { id }
});
},
// ดูนักเรียนทั้งหมด
async findAll(): Promise<Student[]> {
return db.student.findMany({
orderBy: { name: 'asc' }
});
},
// สร้างนักเรียนใหม่
async create(data: CreateStudentInput): Promise<Student> {
return db.student.create({
data
});
},
// อัปเดตข้อมูลนักเรียน
async update(id: number, data: Partial<CreateStudentInput>): Promise<Student> {
return db.student.update({
where: { id },
data
});
},
// ลบนักเรียน
async delete(id: number): Promise<void> {
await db.student.delete({
where: { id }
});
},
// ค้นหาตามอีเมล (สำหรับการตรวจสอบ)
async findByEmail(email: string): Promise<Student | null> {
return db.student.findUnique({
where: { email }
});
}
};
แนวคิดสำคัญของ DAL
- Pure Function - ให้ input เหมือนกัน ได้ output เหมือนกัน
- ไม่มี Business Logic - แค่ CRUD (Create, Read, Update, Delete) เฉยๆ
- Type Safe - ใช้ TypeScript เต็มที่
ตัวอย่างการใช้งาน DAL ที่ซับซ้อนขึ้น
// src/features/student/student.dal.ts (ต่อจากด้านบน)
export const studentDAL = {
// ... methods อื่นๆ
// ค้นหานักเรียนที่มีอายุในช่วงที่กำหนด
async findByAgeRange(minAge: number, maxAge: number): Promise<Student[]> {
return db.student.findMany({
where: {
age: {
gte: minAge,
lte: maxAge
}
}
});
},
// นับจำนวนนักเรียนทั้งหมด
async countAll(): Promise<number> {
return db.student.count();
},
// เช็คว่ามีอีเมลนี้ในระบบแล้วมั้ย
async emailExists(email: string, excludeId?: number): Promise<boolean> {
const count = await db.student.count({
where: {
email,
...(excludeId && { id: { not: excludeId } })
}
});
return count > 0;
}
};
แล้วถ้าต้องเปลี่ยนฐานข้อมูล?
สมมติวันนึงเราอยากเปลี่ยนจาก Prisma เป็น Drizzle ก็แค่แก้ใน DAL:
// เปลี่ยนจาก Prisma เป็น Drizzle
import { db } from "@/lib/drizzle"; // ตอนนี้เป็น Drizzle แล้ว
import { studentsTable } from "@/lib/schema";
import { eq } from "drizzle-orm";
export const studentDAL = {
async findById(id: number): Promise<Student | null> {
const results = await db
.select()
.from(studentsTable)
.where(eq(studentsTable.id, id))
.limit(1);
return results[0] || null;
},
// methods อื่นๆ เปลี่ยนไปตาม Drizzle syntax
};
ข้อดี: Service Layer ไม่ต้องเปลี่ยนแม้แต่บรรทัดเดียว!
กฎการใช้ DAL
✅ ควรทำ:
- เขียน method ที่ชื่อสื่อความหมาย เช่น
findByEmail
,countActive
- ใช้ TypeScript แบบเข้มงวด
- เทส DAL แยกต่างหาก (ใช้ test database)
❌ ไม่ควรทำ:
- ใส่ validation หรือ business logic ใน DAL (อันนี้เอาไว้ทำใน service)
- เรียกใช้ DAL โดยตรงจาก UI layer
- ทำ DAL ให้มีความรับผิดชอบมากเกินไป
อ่า... ตอนนี้ผมรู้แล้วว่าถ้าอยากเปลี่ยนวิธีการเก็บข้อมูล ก็ไปแก้ที่ DAL ที่เดียว ไม่ต้องไล่แก้ทั้งโปรเจกต์ ทีมสามารถทำงานแยกกันได้ คนนึงดูแล DAL อีกคนทำ service layer ไม่ขัดกัน
บทที่ 3: Service Layer - สมองของระบบ
เข้าใจปัญหาก่อน
ลองนึกถึงการสั่งอาหารที่ร้าน:
- ลูกค้า (UI): "เอาข้าวผัดหมูหนึ่งจาน"
- พนักงานเสิร์ฟ (Server Actions): รับออเดอร์แล้วส่งให้ครัว
- พ่อครัว (Service Layer): ตรวจสอบว่ามีหมูมั้ย? เกลือเพียงพอมั้ย? แล้วทำอาหาร
- คนช่วย (DAL): ไปเอาของในตู้เย็น
พ่อครัว (Service Layer) คือคนที่รู้ว่า:
- ต้องใส่เกลือเท่าไหร่ถึงจะอร่อย (Business Rules)
- ถ้าไม่มีหมูให้เปลี่ยนเป็นไก่แทนได้มั้ย (Error Handling)
- ก่อนให้ลูกค้าต้องโรยผักชีด้วย (Orchestration)
Service Layer คืออะไร?
Service Layer เป็น "สมอง" ของฟีเจอร์ มีหน้าที่:
- Business Logic - กฎเกณฑ์ทางธุรกิจ
- Validation - ตรวจสอบความถูกต้อง
- Authorization - เช็คสิทธิ์การเข้าถึง
- Orchestration - ประสานงานกับส่วนอื่น (ส่งอีเมล, เรียก API อื่น)
- Error Handling - จัดการข้อผิดพลาด
ตัวอย่าง Service Layer ที่สมจริง
// src/features/student/student.service.ts
import { studentDAL } from "./student.dal";
import { AppError } from "@/lib/errors";
import { notificationService } from "@/features/notification/notification.service";
import type { CreateStudentInput, Student } from "./student.types";
export const studentService = {
// ดูข้อมูลนักเรียน
async getStudent(id: number, currentUserId: number): Promise<Student> {
// 🔐 เช็คสิทธิ์ก่อน
const canViewStudent = await checkViewPermission(currentUserId, id);
if (!canViewStudent) {
throw new AppError("ไม่มีสิทธิ์ดูข้อมูลนักเรียนคนนี้", "FORBIDDEN");
}
// 📊 ดึงข้อมูล
const student = await studentDAL.findById(id);
if (!student) {
throw new AppError("ไม่พบนักเรียน", "NOT_FOUND");
}
return student;
},
// สร้างนักเรียนใหม่
async createStudent(
input: CreateStudentInput,
currentUserId: number
): Promise<Student> {
// 🔐 เช็คสิทธิ์
if (!await hasCreatePermission(currentUserId)) {
throw new AppError("ไม่มีสิทธิ์สร้างนักเรียน", "FORBIDDEN");
}
// 📋 ตรวจสอบ Business Rules
await validateStudentCreation(input);
// 💾 บันทึกข้อมูル
const newStudent = await studentDAL.create(input);
// 📧 ส่งอีเมลต้อนรับ (Orchestration)
await notificationService.sendWelcomeEmail(
newStudent.email,
newStudent.name
);
// 📝 Log การทำงาน
console.log(`Student created: ${newStudent.id} by user: ${currentUserId}`);
return newStudent;
},
// อัปเดตข้อมูลนักเรียน
async updateStudent(
id: number,
input: Partial<CreateStudentInput>,
currentUserId: number
): Promise<Student> {
// เช็คว่ามีนักเรียนคนนี้มั้ย
const existingStudent = await studentDAL.findById(id);
if (!existingStudent) {
throw new AppError("ไม่พบนักเรียน", "NOT_FOUND");
}
// เช็คสิทธิ์
if (!await canEditStudent(currentUserId, id)) {
throw new AppError("ไม่มีสิทธิ์แก้ไขข้อมูล", "FORBIDDEN");
}
// ถ้ามีการเปลี่ยนอีเมล ต้องเช็คว่าซ้ำมั้ย
if (input.email && input.email !== existingStudent.email) {
const emailExists = await studentDAL.emailExists(input.email, id);
if (emailExists) {
throw new AppError("อีเมลนี้ถูกใช้แล้ว", "DUPLICATE_EMAIL");
}
}
// อัปเดต
const updatedStudent = await studentDAL.update(id, input);
return updatedStudent;
}
};
// 🔧 Helper functions
async function validateStudentCreation(input: CreateStudentInput) {
// ตรวจสอบอายุ
if (input.age < 15) {
throw new AppError("อายุต้องมากกว่า 15 ปี", "INVALID_AGE");
}
if (input.age > 100) {
throw new AppError("อายุไม่ควรเกิน 100 ปี", "INVALID_AGE");
}
// เช็คว่าอีเมลซ้ำมั้ย
const emailExists = await studentDAL.emailExists(input.email);
if (emailExists) {
throw new AppError("อีเมลนี้ถูกใช้แล้ว", "DUPLICATE_EMAIL");
}
// เช็คชื่อไม่ให้เป็นคำไม่สุภาพ
const badWords = ['admin', 'test', 'null'];
if (badWords.some(word => input.name.toLowerCase().includes(word))) {
throw new AppError("ชื่อไม่เหมาะสม", "INAPPROPRIATE_NAME");
}
}
async function checkViewPermission(userId: number, studentId: number): Promise<boolean> {
// ในระบบจริง อาจเช็คจาก database
// เช่น teacher สามารถดูได้เฉพาะนักเรียนในห้องของตัเอง
return true; // simplified for example
}
async function hasCreatePermission(userId: number): Promise<boolean> {
// เช็คว่า user นี้เป็น admin หรือ teacher
return true; // simplified
}
async function canEditStudent(userId: number, studentId: number): Promise<boolean> {
// เช็คสิทธิ์การแก้ไข
return true; // simplified
}
ทำไม Service Layer ถึงสำคัญ?
เรื่องความปลอดภัย:
- เช็คสิทธิ์ทุกครั้งก่อนทำงาน
- Validate ข้อมูลก่อนบันทึก
- จัดการ sensitive operation
เรื่องความถูกต้อง:
- Business rules อยู่ที่เดียว
- ลด duplicate code
- การเปลี่ยนแปลง rule แก้ที่เดียว
เรื่องการทำงาน:
- Orchestrate หลาย service ร่วมกัน
- Handle complex workflow
- Retry mechanism และ fallback
ตัวอย่าง Service ที่ซับซ้อนขึ้น
// src/features/student/student.service.ts (ต่อ)
export const studentService = {
// ... methods อื่นๆ
// ลงทะเบียนเรียน (ซับซ้อนหน่อย)
async enrollCourse(
studentId: number,
courseId: number,
currentUserId: number
): Promise<{ success: boolean; message: string }> {
// 🔍 เช็คข้อมูลพื้นฐาน
const student = await studentDAL.findById(studentId);
if (!student) {
throw new AppError("ไม่พบนักเรียน", "STUDENT_NOT_FOUND");
}
const course = await courseDAL.findById(courseId);
if (!course) {
throw new AppError("ไม่พบคอร์สเรียน", "COURSE_NOT_FOUND");
}
// 📅 เช็คว่าคอร์สยังเปิดรับสมัครอยู่มั้ย
const now = new Date();
if (course.registrationDeadline < now) {
throw new AppError("หมดเวลาลงทะเบียน", "REGISTRATION_CLOSED");
}
// 👥 เช็คที่นั่ง
const currentEnrollments = await enrollmentDAL.countByCourse(courseId);
if (currentEnrollments >= course.maxStudents) {
throw new AppError("คอร์สเต็มแล้ว", "COURSE_FULL");
}
// 📚 เช็คว่าลงทะเบียนซ้ำมั้ย
const existingEnrollment = await enrollmentDAL.findByStudentAndCourse(
studentId,
courseId
);
if (existingEnrollment) {
throw new AppError("ลงทะเบียนคอร์สนี้แล้ว", "ALREADY_ENROLLED");
}
// 💰 เช็ค prerequisite (ถ้ามี)
if (course.prerequisiteIds?.length > 0) {
const completedCourses = await enrollmentDAL.getCompletedCourses(studentId);
const hasPrerequisites = course.prerequisiteIds.every(reqId =>
completedCourses.some(completed => completed.courseId === reqId)
);
if (!hasPrerequisites) {
throw new AppError("ไม่ผ่านเงื่อนไขพื้นฐาน", "PREREQUISITE_NOT_MET");
}
}
// 🎯 ลงทะเบียนจริง
const enrollment = await enrollmentDAL.create({
studentId,
courseId,
enrolledAt: new Date(),
status: 'active'
});
// 📧 ส่งอีเมลยืนยัน
await notificationService.sendEnrollmentConfirmation(
student.email,
course.name,
course.startDate
);
// 📊 อัปเดตสถิติ
await analyticsService.trackEnrollment({
studentId,
courseId,
timestamp: new Date()
});
return {
success: true,
message: `ลงทะเบียน ${course.name} สำเร็จแล้ว!`
};
},
// ดูรายงานสรุป (สำหรับ admin)
async getStudentSummary(currentUserId: number) {
// เช็คว่าเป็น admin
if (!await isAdmin(currentUserId)) {
throw new AppError("ไม่มีสิทธิ์ดูรายงาน", "FORBIDDEN");
}
const [
totalStudents,
activeStudents,
newThisMonth,
topCourses
] = await Promise.all([
studentDAL.countAll(),
studentDAL.countActive(),
studentDAL.countNewThisMonth(),
courseDAL.getTopEnrolledCourses(5)
]);
return {
totalStudents,
activeStudents,
newThisMonth,
topCourses,
generatedAt: new Date()
};
}
};
การจัดการ Error ใน Service Layer
// src/lib/errors.ts
export class AppError extends Error {
constructor(
message: string,
public code: string = "UNKNOWN_ERROR",
public statusCode: number = 400
) {
super(message);
this.name = "AppError";
}
}
// Error codes ที่ใช้บ่อย
export const ERROR_CODES = {
VALIDATION: "VALIDATION_ERROR",
NOT_FOUND: "NOT_FOUND",
FORBIDDEN: "FORBIDDEN",
DUPLICATE: "DUPLICATE_ENTRY",
BUSINESS_RULE: "BUSINESS_RULE_VIOLATION"
} as const;
Testing Service Layer
// src/features/student/student.service.test.ts
import { studentService } from "./student.service";
import { studentDAL } from "./student.dal";
import { AppError } from "@/lib/errors";
// Mock DAL
jest.mock("./student.dal");
const mockStudentDAL = studentDAL as jest.Mocked<typeof studentDAL>;
describe("Student Service", () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe("createStudent", () => {
it("should create student successfully", async () => {
// Arrange
const input = {
name: "นาย ทดสอบ",
age: 20,
email: "test@example.com"
};
const expectedStudent = { id: 1, ...input };
mockStudentDAL.emailExists.mockResolvedValue(false);
mockStudentDAL.create.mockResolvedValue(expectedStudent);
// Act
const result = await studentService.createStudent(input, 1);
// Assert
expect(result).toEqual(expectedStudent);
expect(mockStudentDAL.emailExists).toHaveBeenCalledWith(input.email);
expect(mockStudentDAL.create).toHaveBeenCalledWith(input);
});
it("should reject underage student", async () => {
// Arrange
const input = {
name: "เด็กเล็ก",
age: 10,
email: "kid@example.com"
};
// Act & Assert
await expect(
studentService.createStudent(input, 1)
).rejects.toThrow(new AppError("อายุต้องมากกว่า 15 ปี", "INVALID_AGE"));
});
it("should reject duplicate email", async () => {
// Arrange
const input = {
name: "คนใหม่",
age: 20,
email: "duplicate@example.com"
};
mockStudentDAL.emailExists.mockResolvedValue(true);
// Act & Assert
await expect(
studentService.createStudent(input, 1)
).rejects.toThrow(new AppError("อีเมลนี้ถูกใช้แล้ว", "DUPLICATE_EMAIL"));
});
});
});
กฎการเขียน Service Layer
✅ ควรทำ:
- เขียน business logic ทั้งหมดไว้ที่นี่
- ใช้ meaningful error messages
- เทสทุก method (จริงๆ นะเว้ย)
- ใช้ TypeScript อย่างเข้มงวด
- Log สิ่งสำคัญ
❌ ไม่ควรทำ:
- ให้ UI เรียก DAL โดยตรง
- ใส่ UI logic ใน service (เช่น redirect)
- ลืม validate input
- ปล่อย error ให้ thrown ออกไปโดยไม่จัดการ
- เขียน service ที่ยาวเกินไป (แยก concerns)
ตอนนี้ผมก็เข้าใจแล้วว่าทำไมบางครั้งเวลาสร้าง user ใหม่มันต้องเช็คหลายอย่าง ปกติคิดว่ามันแค่ save ลง database นี่หว่า ละก็ Service layer แบบนี้ทำให้ทีมสามารถแยก concern ได้ชัดเจน คนหนึ่งดูแล business logic คนหนึ่งดูแล UI ไม่ปะทะกัน อีกอย่าง Service layer ที่มี proper logging และ error handling แบบนี้ช่วยให้ monitor และ debug ง่ายมาก เห็น error code ก็รู้ว่าปัญหาอยู่ตรงไหน
บทที่ 4: Server Actions - พนักงานต้อนรับที่เก่งที่สุด
ทำความเข้าใจก่อนว่า Server Actions คืออะไร
มันคือฟังก์ชันฝั่ง Server ที่เราสามารถเรียกใช้ได้โดยตรงจาก Component ฝั่ง Client (ที่ใส่ "use client"
) เหมือนเรียกฟังก์ชันธรรมดาๆ เลย ถ้าเทียบกับร้านอาหาร Server Actions เป็นเหมือนพนักงานเสิร์ฟ ที่:
- รับออเดอร์ (Input) - รับข้อมูลจากฟอร์ม (
FormData
) - ตรวจความเรียบร้อยของออเดอร์ (Validation) - เช็คว่าลูกค้ากรอกข้อมูลครบถ้วนและถูกต้องตามรูปแบบเบื้องต้นมั้ย? (ใช้
zod
คือดีที่สุด!) - ส่งออเดอร์เข้าครัว (Call Service Layer) - ส่งข้อมูลที่ผ่านการตรวจสอบแล้วไปให้ "พ่อครัวใหญ่" (Service Layer) จัดการต่อ
- จัดการหลังครัวทำอาหารเสร็จ (Handle Response) - เมื่อ Service Layer ทำงานเสร็จ (ไม่ว่าจะสำเร็จหรือล้มเหลว) ก็รับผลลัพธ์กลับมา
- แจ้งลูกค้าและจัดโต๊ะใหม่ (Return Value & Revalidation) - ส่งข้อความกลับไปบอก Client ว่าผลเป็นยังไง และสั่งให้ Next.js "จัดโต๊ะใหม่" (
revalidatePath
) เพื่อให้ข้อมูลหน้าเว็บอัปเดต
ก่อนหน้า Server Actions เราต้องทำยังไง?
สมัยก่อน (ซึ่งก็ไม่นานเท่าไหร่) เวลาเราจะส่งข้อมูลจากฟอร์มหน้าเว็บไปหลังบ้าน เราต้องทำอะไรบ้าง?
- สร้าง API endpoint แยกต่างหาก (เช่น
/api/students
) - เขียนโค้ด
fetch
ในฝั่ง Client เพื่อยิงไปที่ API นั้น - จัดการกับ
loading
,error
,success
states ด้วยตัวเอง - กังวลเรื่อง Type Safety ระหว่าง Client กับ Server
มันเหมือนกับลูกค้า (UI) ต้องเขียนใบสั่งอาหารเอง (สร้าง JSON), เดินไปส่งที่ครัวเอง (เรียก fetch
), แล้วก็ยืนรออาหารเอง... วุ่นวายใช่ไหมล่ะ?
Server Actions เข้ามาเปลี่ยนเกมนี้ไปเลยครับ มันคือ "พนักงานเสิร์ฟอัจฉริยะ" ที่ Next.js จัดมาให้
กฎทองของ Server Actions คือ จง "บาง" เข้าไว้ (Keep it thin!) พนักงานเสิร์ฟไม่จำเป็นต้องรู้สูตรทำอาหารฉันใด Server Actions ก็ไม่ควรมี Business Logic ฉันนั้น! หน้าที่ของมันคือ "ตัวกลาง" ที่ดีเท่านั้น
วิธีใหม่ด้วย Server Actions
// src/features/student/student.actions.ts
"use server";
import { z } from "zod";
import { studentService } from "./student.service";
import { revalidatePath } from "next/cache";
import { AppError } from "@/lib/errors";
// 📋 Schema สำหรับ validation
const createStudentSchema = z.object({
name: z.string()
.min(1, "กรุณากรอกชื่อ")
.max(100, "ชื่อยาวเกินไป"),
age: z.coerce.number()
.min(15, "อายุต้องมากกว่า 15 ปี")
.max(100, "อายุไม่ควรเกิน 100 ปี"),
email: z.string()
.email("รูปแบบอีเมลไม่ถูกต้อง")
.toLowerCase()
});
// 📤 Action สำหรับสร้างนักเรียนใหม่
export async function createStudentAction(
prevState: any,
formData: FormData
) {
try {
// 🔍 Parse และ validate ข้อมูล
const rawData = {
name: formData.get("name"),
age: formData.get("age"),
email: formData.get("email")
};
const validatedData = createStudentSchema.parse(rawData);
// 🔐 ดึงข้อมูล user ปัจจุบัน (ในโปรเจกต์จริง)
// const session = await getServerSession();
// const currentUserId = session?.user?.id;
const currentUserId = 1; // สำหรับตัวอย่าง
// 🎯 เรียก service layer
const newStudent = await studentService.createStudent(
validatedData,
currentUserId
);
// 🔄 อัปเดต cache
revalidatePath("/students");
// ✅ ส่งผลลัพธ์สำเร็จ
return {
success: true,
message: `สร้างนักเรียน ${newStudent.name} สำเร็จแล้ว!`,
data: newStudent,
errors: null
};
} catch (error) {
console.error("Create student error:", error);
// 🔍 จัดการ validation errors
if (error instanceof z.ZodError) {
return {
success: false,
message: "ข้อมูลไม่ถูกต้อง",
data: null,
errors: error.flatten().fieldErrors
};
}
// 🔍 จัดการ business logic errors
if (error instanceof AppError) {
return {
success: false,
message: error.message,
data: null,
errors: null
};
}
// 🔍 จัดการ unknown errors
return {
success: false,
message: "เกิดข้อผิดพลาดไม่ทราบสาเหตุ",
data: null,
errors: null
};
}
}
// 📝 Action สำหรับอัปเดต
export async function updateStudentAction(
id: number,
prevState: any,
formData: FormData
) {
try {
const rawData = {
name: formData.get("name"),
age: formData.get("age"),
email: formData.get("email")
};
// ใช้ partial schema สำหรับการอัปเดต
const updateSchema = createStudentSchema.partial();
const validatedData = updateSchema.parse(rawData);
// อัปเดตข้อมูล
const currentUserId = 1;
const updatedStudent = await studentService.updateStudent(
id,
validatedData,
currentUserId
);
revalidatePath("/students");
revalidatePath(`/students/${id}`);
return {
success: true,
message: "อัปเดตข้อมูลสำเร็จ",
data: updatedStudent,
errors: null
};
} catch (error) {
// Error handling เหมือนเดิม...
return handleActionError(error);
}
}
// 🗑️ Action สำหรับลบ
export async function deleteStudentAction(id: number) {
try {
const currentUserId = 1;
await studentService.deleteStudent(id, currentUserId);
revalidatePath("/students");
return {
success: true,
message: "ลบนักเรียนสำเร็จ"
};
} catch (error) {
return handleActionError(error);
}
}
// 🔧 Helper function สำหรับจัดการ error
function handleActionError(error: unknown) {
console.error("Action error:", error);
if (error instanceof z.ZodError) {
return {
success: false,
message: "ข้อมูลไม่ถูกต้อง",
errors: error.flatten().fieldErrors
};
}
if (error instanceof AppError) {
return {
success: false,
message: error.message,
errors: null
};
}
return {
success: false,
message: "เกิดข้อผิดพลาดไม่ทราบสาเหตุ",
errors: null
};
}
การใช้งาน Server Actions ในหน้าเว็บ
// src/app/students/components/student-form.tsx
"use client";
import { useFormState } from "react-dom";
import { createStudentAction } from "@/features/student/student.actions";
const initialState = {
success: false,
message: "",
data: null,
errors: null
};
export function StudentForm() {
const [state, formAction] = useFormState(createStudentAction, initialState);
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="name">ชื่อ-นามสกุล</label>
<input
type="text"
name="name"
id="name"
required
className="w-full p-2 border rounded"
/>
{state.errors?.name && (
<p className="text-red-500 text-sm">{state.errors.name[0]}</p>
)}
</div>
<div>
<label htmlFor="age">อายุ</label>
<input
type="number"
name="age"
id="age"
required
className="w-full p-2 border rounded"
/>
{state.errors?.age && (
<p className="text-red-500 text-sm">{state.errors.age[0]}</p>
)}
</div>
<div>
<label htmlFor="email">อีเมล</label>
<input
type="email"
name="email"
id="email"
required
className="w-full p-2 border rounded"
/>
{state.errors?.email && (
<p className="text-red-500 text-sm">{state.errors.email[0]}</p>
)}
</div>
<button
type="submit"
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
บันทึก
</button>
{/* แสดงผลลัพธ์ */}
{state.message && (
<div className={`p-3 rounded ${
state.success
? "bg-green-100 text-green-700"
: "bg-red-100 text-red-700"
}`}>
{state.message}
</div>
)}
</form>
);
}
Advanced Server Actions Patterns
1. File Upload Action
// src/features/student/student.actions.ts (ต่อ)
export async function uploadStudentPhotoAction(
studentId: number,
prevState: any,
formData: FormData
) {
try {
const file = formData.get("photo") as File;
if (!file || file.size === 0) {
return {
success: false,
message: "กรุณาเลือกไฟล์รูปภาพ"
};
}
// เช็คขนาดไฟล์ (5MB)
if (file.size > 5 * 1024 * 1024) {
return {
success: false,
message: "ไฟล์รูปภาพใหญ่เกินไป (สูงสุด 5MB)"
};
}
// เช็คประเภทไฟล์
if (!file.type.startsWith("image/")) {
return {
success: false,
message: "กรุณาเลือกไฟล์รูปภาพเท่านั้น"
};
}
// อัปโลดไฟล์ (สามารถใช้ Cloudinary, AWS S3, etc.)
const photoUrl = await uploadFile(file);
// อัปเดตข้อมูลในฐานข้อมูล
await studentService.updatePhoto(studentId, photoUrl, 1);
revalidatePath(`/students/${studentId}`);
return {
success: true,
message: "อัปโลดรูปภาพสำเร็จ",
data: { photoUrl }
};
} catch (error) {
return handleActionError(error);
}
}
2. Bulk Actions
export async function bulkUpdateStudentsAction(
studentIds: number[],
updates: Partial<CreateStudentInput>
) {
try {
if (studentIds.length === 0) {
return {
success: false,
message: "กรุณาเลือกนักเรียนอย่างน้อย 1 คน"
};
}
const results = await studentService.bulkUpdate(
studentIds,
updates,
1 // currentUserId
);
revalidatePath("/students");
return {
success: true,
message: `อัปเดตข้อมูลนักเรียน ${results.updated} คน สำเร็จ`,
data: results
};
} catch (error) {
return handleActionError(error);
}
}
การจัดการ Loading States
// src/app/students/components/student-form-with-loading.tsx
"use client";
import { useFormState, useFormStatus } from "react-dom";
import { createStudentAction } from "@/features/student/student.actions";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 disabled:opacity-50"
>
{pending ? "กำลังบันทึก..." : "บันทึก"}
</button>
);
}
export function StudentFormWithLoading() {
const [state, formAction] = useFormState(createStudentAction, {
success: false,
message: "",
errors: null
});
return (
<form action={formAction} className="space-y-4">
{/* form fields... */}
<SubmitButton />
{/* error/success messages... */}
</form>
);
}
Best Practices สำหรับ Server Actions
✅ ควรทำ:
- ใช้ Zod สำหรับ validation เสมอ อย่าไว้ใจด่านหน้าเด็ดขาด
- ทำให้ "บาง" ที่สุด หน้าที่คือรับ-ตรวจ-ส่ง-ตอบ เท่านั้น
- จัดการทุกประเภทของ error
try/catch
คือเพื่อนแท้ของเรา จัดการ ZodError, AppError, และ Error ทั่วไปให้ครบ - ใช้ revalidatePath/revalidateTag หลังจากอัปเดตข้อมูล เพื่อให้ server UI อัปเดตข้อมูลล่าสุดเสมอ
- Log error สำหรับการ debug
- ใช้ TypeScript อย่างเข้มงวด
❌ ไม่ควรทำ:
- ยัด business logic ใน Server Actions ถ้าคุณเริ่มเขียน
if/else
ซับซ้อนใน Action... คุณกำลังมาผิดทางแล้ว! ย้ายมันไปที่ Service Layer ซะ - เรียก DAL โดยตรง
- ลืมทำ validation
- Return sensitive data
- ละเลยการจัดการ error
- ทำ Actions ที่ซับซ้อนเกินไป
Server Actions ทำให้เราไม่ต้องเขียน API แยก และยังได้ type safety มาด้วย Form handling ง่ายขึ้นเยอะเลย pattern นี้ช่วยให้ทีมเขียน form ได้เร็วขึ้น และ error handling ที่ consistent ทำให้ maintenance ง่าย
บทที่ 5: UI & Rendering - ศิลปะแห่งการจัดจาน
เข้าใจปัญหาก่อน
เรามีครัว (Backend Logic) ที่สุดยอดแล้ว แต่ถ้าหน้าร้าน (UI) จัดจานไม่สวย จัดโต๊ะช้า ลูกค้าก็หนีหมด! ในโลกของ Next.js การ "จัดจาน" คือการเลือกใช้ Server Components และ Client Components ให้ถูกที่ถูกเวลา
คิดง่ายๆ แบบนี้:
- Server Component (Default): เหมือน "อาหารที่ปรุงเสร็จจากครัว" ส่งมาถึงโต๊ะลูกค้าแบบพร้อมทานเลย รวดเร็ว, สวยงาม, เหมาะสำหรับแสดงผล (ลูกค้าแค่ดูและกิน ไม่ต้องทำอะไรกับมัน)
- Client Component (
"use client"
): เหมือน "หมูกระทะบนโต๊ะ" ที่ลูกค้าต้องลงมือปิ้งย่างเอง มีปฏิสัมพันธ์ (Interaction) เช่น การกดปุ่ม, การกรอกฟอร์ม, การเรียงข้อมูลในตาราง
กฎทองคำ: "ทุกอย่างเป็น Server Component จนกว่าจะต้องเป็น Client Component"
ตัวอย่างการจัดหน้าเว็บที่ถูกต้อง
ลองดูหน้า StudentsPage
ของเราอีกครั้ง แต่คราวนี้เราจะใส่ใจเรื่องการ "จัดจาน" มากขึ้น
// src/app/students/page.tsx (🏔️ Server Component)
import { Suspense } from "react";
import { studentService } from "@/features/student/student.service";
import { StudentTable } from "./components/student-table";
import { StudentForm } from "./components/student-form";
import { StudentCount } from "./components/student-count";
// นี่คือ "โต๊ะอาหาร" หลักของเรา
export default async function StudentsPage() {
// การเรียกข้อมูลเกิดขึ้นที่ Server ทั้งหมด!
const students = await studentService.getAllStudents();
return (
<div className="container p-6">
<h1 className="text-3xl font-bold mb-4">ระบบจัดการนักเรียน</h1>
<div className="grid md:grid-cols-3 gap-6">
<div className="md:col-span-2">
<h2 className="text-xl font-semibold mb-2">รายชื่อนักเรียน</h2>
{/* ส่วนนี้เป็น "หมูกระทะ" ที่ต้องให้ลูกค้าปิ้งเอง */}
<StudentTable students={students} />
</div>
<div>
<h2 className="text-xl font-semibold mb-2">เพิ่มนักเรียนใหม่</h2>
{/* ฟอร์มก็เป็น "หมูกระทะ" เช่นกัน */}
<StudentForm />
<div className="mt-6">
<h2 className="text-xl font-semibold mb-2">ข้อมูลสรุป</h2>
{/* ส่วนนี้ซับซ้อน อาจจะโหลดช้า เราเลยใช้ Suspense ห่อไว้ */}
<Suspense fallback={<p>กำลังโหลดข้อมูลสรุป...</p>}>
<StudentStats />
</Suspense>
</div>
</div>
</div>
</div>
);
}
// อีกหนึ่ง Server Component ที่ดึงข้อมูลของตัวเอง
async function StudentStats() {
const total = await studentService.countAllStudents();
// สมมติว่ารอ 2 วินาทีเพื่อให้เห็นผลของ Suspense
await new Promise(resolve => setTimeout(resolve, 2000));
return <p>มีนักเรียนในระบบทั้งหมด: {total} คน</p>;
}
แล้ว StudentTable
ล่ะ?
// src/app/students/components/student-table.tsx (🍳 Client Component)
"use client";
import { useState, useMemo } from "react";
import type { Student } from "@/features/student/student.types";
export function StudentTable({ students: initialStudents }: { students: Student[] }) {
const [searchTerm, setSearchTerm] = useState("");
const filteredStudents = useMemo(() => {
if (!searchTerm) return initialStudents;
return initialStudents.filter(s =>
s.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [searchTerm, initialStudents]);
return (
<div>
<input
type="text"
placeholder="ค้นหาชื่อ..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full p-2 border rounded mb-4"
/>
{/* ... โค้ดแสดงตาราง ... */}
<table>
{/* ... */}
<tbody>
{filteredStudents.map(student => (
<tr key={student.id}>
<td>{student.name}</td>
<td>{student.email}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
เห็นอะไรมั้ยครับ?
StudentsPage
(Server Component) ทำหน้าที่หนักในการดึงข้อมูลและวางโครงสร้าง- มันส่งข้อมูล (
students
) ที่ปรุงเสร็จแล้วไปให้StudentTable
StudentTable
(Client Component) รับข้อมูลนั้นมา แล้วจัดการแค่ส่วนของ Interaction (การค้นหา) ในฝั่ง Client
นี่คือการแบ่งงานที่สมบูรณ์แบบเลยแหละ เราได้ทั้งความเร็วและ SEO จาก Server Component และได้ Interaction ที่ลื่นไหลจาก Client Component
กฎเหล็กของการ Rendering
✅ ควรทำ:
- เริ่มจาก Server Component: คิดเสมอว่า "หน้านี้แสดงข้อมูลเฉยๆ ได้มั้ย?" ถ้าได้ ก็ใช้ Server Component
- แยก Client Component ให้เล็กที่สุด: อย่าครอบทั้งหน้าด้วย
"use client"
! ให้แยกเฉพาะส่วนที่มีปุ่ม, ฟอร์ม, หรือuseState
/useEffect
ออกมาเป็น Component เล็กๆ - ส่งข้อมูลจาก Server -> Client: ให้ Server Component ดึงข้อมูลแล้วส่งเป็น props ให้ Client Component อย่า
fetch
ข้อมูลใน Client Component ถ้าไม่จำเป็นจริงๆ (เช่น การทำ polling) - ใช้
Suspense
: สำหรับส่วนที่โหลดข้อมูลนาน เพื่อให้ผู้ใช้เห็นส่วนอื่นของหน้าเว็บไปก่อน ไม่ต้องรอทั้งหน้า
❌ ไม่ควรทำ:
- ใช้
"use client"
พร่ำเพรื่อ: การทำแบบนี้จะทำให้คุณเสียประโยชน์จาก Server-Side Rendering ไปโดยใช่เหตุ - ดึงข้อมูลใน Client Component: ทำให้เกิด request ไปกลับที่ไม่จำเป็น และทำให้หน้าเว็บโหลดช้าลง
- ใส่โค้ดลับใน Client Component: โค้ดใน Client Component จะถูกส่งไปให้ Browser เห็นทั้งหมด! อย่าใส่ API keys หรือโค้ดที่เชื่อมต่อ Database โดยตรงเด็ดขาด
บทที่ 6: รับมือเมื่อครัวไฟไหม้ (Error Handling)
เราพูดถึง AppError
ใน Service Layer และการ try/catch
ใน Server Actions ไปแล้ว แต่ยังไม่จบ... เราต้องมีแผนรับมือ "หายนะ" ที่ผู้ใช้จะได้ไม่เจอหน้าเว็บขาวๆ หรือข้อความ error ที่น่าเกลียด
Next.js มีเครื่องมือให้เรา 2 อย่าง:
error.tsx
: ไฟล์นี้จะถูกแสดงผลเมื่อมี Error ที่ ไม่คาดคิด (500 Internal Error) เกิดขึ้นใน Component ลูกๆ ของมัน (ทำงานในฝั่ง Client)not-found.tsx
: ไฟล์ที่แสดงผลเมื่อเรียกฟังก์ชันnotFound()
หรือเมื่อเข้า URL ที่ไม่มีอยู่จริง (404 Not Found Error)
ตัวอย่าง:
// src/app/students/[id]/page.tsx (หน้าดูรายละเอียดนักเรียน)
import { notFound } from "next/navigation";
import { studentService } from "@/features/student/student.service";
export default async function StudentDetailPage({ params }: { params: { id: string } }) {
try {
const student = await studentService.getStudent(Number(params.id), 1); // สมมติ actorId=1
// ... แสดงข้อมูลนักเรียน
} catch (error) {
if (error instanceof AppError && error.code === 'NOT_FOUND') {
notFound(); // << บอก Next.js ให้ไปแสดงหน้า not-found.tsx
}
// ถ้าเป็น error อื่นที่ไม่คาดคิด ให้มันโยนออกไปเพื่อให้ error.tsx ทำงาน
throw error;
}
}
// src/app/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
return (
<div className="text-center">
<h2 className="text-2xl font-bold">ไม่พบข้อมูล</h2>
<p>ขออภัย เราไม่พบหน้าที่คุณต้องการค้นหา</p>
<Link href="/" className="text-blue-500">กลับสู่หน้าหลัก</Link>
</div>
);
}
// src/app/error.tsx
"use client"; // Error components ต้องเป็น Client Components
export default function Error({ error, reset }: { error: Error; reset: () => void; }) {
useEffect(() => {
// อาจจะส่ง error ไปที่ระบบ monitoring เช่น Sentry
console.error(error);
}, [error]);
return (
<div className="text-center">
<h2 className="text-2xl font-bold">โอ๊ะ! เกิดข้อผิดพลาดบางอย่าง</h2>
<button onClick={() => reset()} className="bg-blue-500 text-white p-2 rounded mt-4">
ลองอีกครั้ง
</button>
</div>
);
}
กฎเหล็กของการรับมือ Error:
- จัดการ Error อย่างเป็นระบบ: ใช้
AppError
สำหรับ Business Logic, ใช้notFound()
สำหรับข้อมูลที่ไม่มี, และปล่อยให้error.tsx
จัดการกับสิ่งที่เหลือ - ให้ Feedback ที่ดีกับผู้ใช้: อย่าแสดงหน้าขาวๆ หรือข้อความทางเทคนิค บอกผู้ใช้ว่าเกิดอะไรขึ้น (แบบง่ายๆ) และควรทำอะไรต่อ
บทที่ 7: ชิมก่อนเสิร์ฟเสมอ (Testing)
เราเขียน Unit Test สำหรับ Service Layer ไปแล้ว ซึ่งสำคัญที่สุด แต่การเทสไม่ได้มีแค่นั้น:
- Unit Tests (Jest/Vitest): ทดสอบ Logic เพียวๆ ใน Service Layer และ DAL (สำคัญมาก 90%)
- Integration Tests: ทดสอบว่าเมื่อ Server Action ถูกเรียก มันเรียก Service Layer และ DAL ถูกต้องหรือไม่
- End-to-End (E2E) Tests (Cypress/Playwright): ทดสอบการใช้งานจริงของผู้ใช้ เหมือนมีหุ่นยนต์มานั่งคลิกเว็บเราตั้งแต่หน้าแรกจนจบกระบวนการ (ทำเฉพาะ flow ที่สำคัญๆ เช่น สมัครสมาชิก, สั่งซื้อของ)
คำแนะนำ: โฟกัสที่ Unit Tests ของ Service Layer ให้ครอบคลุมที่สุด เพราะมันคือสมองของระบบ ถ้าสมองทำงานถูกต้อง ส่วนอื่นๆ ก็มักจะถูกต้องตามไปด้วย
สรุปปปปป
ยินดีด้วยครับผมม คุณได้เดินทางผ่านสูตรลับทั้งหมดใน Cookbook เล่มนี้แล้ว
หัวใจสำคัญไม่ได้อยู่ที่การจำโค้ดได้ทุกบรรทัด แต่อยู่ที่การเข้าใจ "หลักการ" ที่อยู่เบื้องหลัง ได้แก่:
- จัดระเบียบให้ดี (Project Structure): แยก
/app
(หน้าร้าน) ออกจาก/src/features
(ห้องครัว) - แบ่งหน้าที่ให้ชัด (Separation of Concerns):
- DAL: คุยกับ DB เท่านั้น
- Service Layer: คือสมองและหัวใจของ Business Logic
- Server Actions: เป็นแค่พนักงานเสิร์ฟที่ "บาง" และฉลาด
- UI Components: ทำหน้าที่แสดงผลให้สวยงาม
- เลือกเครื่องมือให้ถูก (Rendering): เริ่มจาก Server Components เสมอ แล้วใช้ Client Components เท่าที่จำเป็นจริงๆ
- เตรียมแผนสำรอง (Error Handling & Testing): เตรียมรับมือกับข้อผิดพลาดและทดสอบโค้ดของคุณเสมอ
การวางโครงสร้างที่ดีในตอนแรกอาจจะดูเหมือนเสียเวลา แต่เชื่อผมเถอะครับว่ามันคือการลงทุนที่คุ้มค่าที่สุด มันจะช่วยให้คุณ (ละก็ทีมของคุณด้วย) สร้างโปรเจกต์ Next.js ที่ไม่เพียงแต่ทำงานได้ดีในวันนี้ แต่ยังง่ายต่อการดูแลรักษา ต่อเติม และส่งต่อในวันข้างหน้า
ขอบคุณที่อ่านจนจบครับ