Cookbook ทำ Design Pattern ให้โปรเจค Next.js

2025-08-30T08:51:59.000+00:00

หลายครั้งผมเปิดโปรเจกต์ Next.js ของตัวเองที่เขียนไปแล้ว 3 เดือน แล้วนั่งงงว่า "เอิ่มม... โค้ดตรงนี้มันทำอะไรวะ?" หรือไม่ก็เวลาจะเพิ่มฟีเจอร์ใหม่แล้วไม่รู้ว่าควรไปแก้ไฟล์ไหน แล้วกลัวว่าไปแตะโค้ดที่ไม่เกี่ยวแล้วระบบพัง?

ผมเลยนั่งเขียน Cookbook เล่มนี้ขึ้นมาเพื่อช่วยให้เราสร้างโปรเจกต์ Next.js ที่:

เหมาะสำหรับผู้ที่มีพื้นฐาน Next.js และ React มาก่อนแล้ว ถ้ายังไม่มี แนะนำให้ลองไปศึกษากันมาก่อนน้าา


เกริ่นนำ: ทำไมต้องมี Design Pattern ด้วย?

เรามาเริ่มด้วยเรื่องจริงกันก่อน Next.js มันเจ๋งมาก flexible มาก แต่... มันก็เหมือนกับให้เครื่องมือฟรีๆ แล้วบอกว่า "ไปสร้างบ้านเอาเองเด้อ"

ปัญหาคือถ้าเราไม่มีแบบแปลน ไม่มีแนวคิดที่ชัดเจน สุดท้ายแล้วเราจะได้บ้านที่:

แล้วจะเกิดอะไรขึ้น?

  1. Technical Debt สะสม - เริ่มจากโค้ด "พอใช้ได้" ค่อยๆ กลายเป็นโค้ดที่ไม่มีใครอยากแตะ
  2. Development ช้าลง - เพิ่มฟีเจอร์ใหม่ใช้เวลานานขึ้นเรื่อยๆ
  3. Bug เยอะขึ้น - แก้ที่หนึ่งแล้วเสียที่อื่น
  4. Team stress - ทุกคนกลัวที่จะแก้โค้ด

ถ้าเกิดว่าเรามีโครงสร้างโปรเจคที่ชัดเจน เราก็จะ:


บทที่ 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 บรรทัด ต้องมานั่งหาต่อว่าบรรทัดไหน 😵‍💫

แล้วเราควรจะจัดยังไง?

หลักคิดง่ายๆ คือ "แยกตามหน้าที่" เหมือนเราจัดบ้าน:

มาดูโครงสร้างที่แนะนำ:

โปรเจกต์-ของ-เรา/
├── 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
ทำไมต้องแยกแบบนี้?

แล้ววาง 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 แบบ แค่หน้านี้จริงๆ ไม่ได้ไปหน้าอื่นๆเลย
}

เมื่อไหร่ใช้:

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

เมื่อไหร่ใช้:

ตัวอย่าง:

// 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

คือ เริ่มจากใกล้ที่สุด → ค่อยขยายออก

  1. เริ่มที่ app/[route]/components/ ก่อนเสมอ
  2. ถ้าเริ่มใช้หน้าอื่น → ย้ายไป features/[domain]/components/
  3. ถ้าเริ่มใช้หลาย domain → ย้ายไป components/ui/

📚 ตัวอย่างจริง

StudentForm → app/students/components/ (ใช้เฉพาะหน้า students)
Button → components/ui/ (ใช้ทุกที่ในแอป)  
StudentCard → features/student/components/ (ใช้ใน student domain)

จำไว้: เริ่มจากใกล้ แล้วค่อยขยาย ตามการใช้งานจริง ไม่ต้องคิดมากตั้งแต่แรกเว้ย

กฎง่ายๆ ที่ควรจำ

✅ ควรทำ:

❌ ไม่ควรทำ:

ตัวอย่างการใช้งานจริง

มาดูตัวอย่างการสร้างระบบจัดการนักเรียนง่ายๆ:

// 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) - คนคุยกับฐานข้อมูล

เข้าใจปัญหาก่อน

ลองนึกภาพว่าเราเป็นเจ้าของร้านอาหาร:

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 }
});

ปัญหาคือ:

หลังมี 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

  1. Pure Function - ให้ input เหมือนกัน ได้ output เหมือนกัน
  2. ไม่มี Business Logic - แค่ CRUD (Create, Read, Update, Delete) เฉยๆ
  3. 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

✅ ควรทำ:

❌ ไม่ควรทำ:

อ่า... ตอนนี้ผมรู้แล้วว่าถ้าอยากเปลี่ยนวิธีการเก็บข้อมูล ก็ไปแก้ที่ DAL ที่เดียว ไม่ต้องไล่แก้ทั้งโปรเจกต์ ทีมสามารถทำงานแยกกันได้ คนนึงดูแล DAL อีกคนทำ service layer ไม่ขัดกัน


บทที่ 3: Service Layer - สมองของระบบ

เข้าใจปัญหาก่อน

ลองนึกถึงการสั่งอาหารที่ร้าน:

พ่อครัว (Service Layer) คือคนที่รู้ว่า:

Service Layer คืออะไร?

Service Layer เป็น "สมอง" ของฟีเจอร์ มีหน้าที่:

  1. Business Logic - กฎเกณฑ์ทางธุรกิจ
  2. Validation - ตรวจสอบความถูกต้อง
  3. Authorization - เช็คสิทธิ์การเข้าถึง
  4. Orchestration - ประสานงานกับส่วนอื่น (ส่งอีเมล, เรียก API อื่น)
  5. 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 ถึงสำคัญ?

เรื่องความปลอดภัย:

เรื่องความถูกต้อง:

เรื่องการทำงาน:

ตัวอย่าง 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

✅ ควรทำ:

❌ ไม่ควรทำ:

ตอนนี้ผมก็เข้าใจแล้วว่าทำไมบางครั้งเวลาสร้าง 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 เป็นเหมือนพนักงานเสิร์ฟ ที่:

ก่อนหน้า Server Actions เราต้องทำยังไง?

สมัยก่อน (ซึ่งก็ไม่นานเท่าไหร่) เวลาเราจะส่งข้อมูลจากฟอร์มหน้าเว็บไปหลังบ้าน เราต้องทำอะไรบ้าง?

  1. สร้าง API endpoint แยกต่างหาก (เช่น /api/students)
  2. เขียนโค้ด fetch ในฝั่ง Client เพื่อยิงไปที่ API นั้น
  3. จัดการกับ loading, error, success states ด้วยตัวเอง
  4. กังวลเรื่อง 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

✅ ควรทำ:

❌ ไม่ควรทำ:

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 จนกว่าจะต้องเป็น 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>
  );
}

เห็นอะไรมั้ยครับ?

นี่คือการแบ่งงานที่สมบูรณ์แบบเลยแหละ เราได้ทั้งความเร็วและ SEO จาก Server Component และได้ Interaction ที่ลื่นไหลจาก Client Component

กฎเหล็กของการ Rendering

✅ ควรทำ:

❌ ไม่ควรทำ:


บทที่ 6: รับมือเมื่อครัวไฟไหม้ (Error Handling)

เราพูดถึง AppError ใน Service Layer และการ try/catch ใน Server Actions ไปแล้ว แต่ยังไม่จบ... เราต้องมีแผนรับมือ "หายนะ" ที่ผู้ใช้จะได้ไม่เจอหน้าเว็บขาวๆ หรือข้อความ error ที่น่าเกลียด

Next.js มีเครื่องมือให้เรา 2 อย่าง:

  1. error.tsx: ไฟล์นี้จะถูกแสดงผลเมื่อมี Error ที่ ไม่คาดคิด (500 Internal Error) เกิดขึ้นใน Component ลูกๆ ของมัน (ทำงานในฝั่ง Client)
  2. 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:

บทที่ 7: ชิมก่อนเสิร์ฟเสมอ (Testing)

เราเขียน Unit Test สำหรับ Service Layer ไปแล้ว ซึ่งสำคัญที่สุด แต่การเทสไม่ได้มีแค่นั้น:

คำแนะนำ: โฟกัสที่ Unit Tests ของ Service Layer ให้ครอบคลุมที่สุด เพราะมันคือสมองของระบบ ถ้าสมองทำงานถูกต้อง ส่วนอื่นๆ ก็มักจะถูกต้องตามไปด้วย

สรุปปปปป

ยินดีด้วยครับผมม คุณได้เดินทางผ่านสูตรลับทั้งหมดใน Cookbook เล่มนี้แล้ว

หัวใจสำคัญไม่ได้อยู่ที่การจำโค้ดได้ทุกบรรทัด แต่อยู่ที่การเข้าใจ "หลักการ" ที่อยู่เบื้องหลัง ได้แก่:

  1. จัดระเบียบให้ดี (Project Structure): แยก /app (หน้าร้าน) ออกจาก /src/features (ห้องครัว)
  2. แบ่งหน้าที่ให้ชัด (Separation of Concerns):
    • DAL: คุยกับ DB เท่านั้น
    • Service Layer: คือสมองและหัวใจของ Business Logic
    • Server Actions: เป็นแค่พนักงานเสิร์ฟที่ "บาง" และฉลาด
    • UI Components: ทำหน้าที่แสดงผลให้สวยงาม
  3. เลือกเครื่องมือให้ถูก (Rendering): เริ่มจาก Server Components เสมอ แล้วใช้ Client Components เท่าที่จำเป็นจริงๆ
  4. เตรียมแผนสำรอง (Error Handling & Testing): เตรียมรับมือกับข้อผิดพลาดและทดสอบโค้ดของคุณเสมอ

การวางโครงสร้างที่ดีในตอนแรกอาจจะดูเหมือนเสียเวลา แต่เชื่อผมเถอะครับว่ามันคือการลงทุนที่คุ้มค่าที่สุด มันจะช่วยให้คุณ (ละก็ทีมของคุณด้วย) สร้างโปรเจกต์ Next.js ที่ไม่เพียงแต่ทำงานได้ดีในวันนี้ แต่ยังง่ายต่อการดูแลรักษา ต่อเติม และส่งต่อในวันข้างหน้า

ขอบคุณที่อ่านจนจบครับ