Skip to main content
BlogWeb Dev

Membangun REST API Type-Safe dengan NestJS dan Prisma

Cara menghubungkan NestJS, Prisma, dan class-validator sehingga kesalahan tipe TypeScript menangkap bug sebelum mencapai produksi.

Matthews Wong

Software Engineer

Juni 202510 menit baca
Membangun REST API Type-Safe dengan NestJS dan Prisma

Foto oleh Unsplash

Error runtime di API REST produksi memalukan dan dapat dihindari. API ERP yang menangani persetujuan penggajian, pembuatan invoice, dan pergerakan inventaris tidak memberi ruang untuk salah — kesalahan memiliki biaya nyata. Kombinasi NestJS (untuk struktur), Prisma (untuk akses DB yang type-safe), dan class-validator (untuk validasi request) membentuk jaring pengaman tiga lapis yang mendorong error ke compile time dan test suite, bukan ke log produksi.

Mengapa Stack Ini?

NestJS memberikan dependency injection gaya Angular dan decorator yang membuat controller, service, dan modul menjadi eksplisit dan dapat diuji. Prisma menghasilkan client yang sepenuhnya bertipe dari schema.prisma — setiap hasil query memiliki tipe TypeScript, dan query yang tidak mungkin gagal saat compile time. class-validator dengan class-transformer memvalidasi body request yang masuk menggunakan decorator, sehingga field wajib yang hilang atau harga negatif mengembalikan 400 dengan pesan error deskriptif.

Siklus Hidup Request

Memahami siklus hidup request NestJS sangat penting untuk menempatkan logika dengan benar. Guard berjalan pertama — mereka menangani autentikasi dan otorisasi. Pipe berjalan berikutnya — di sinilah validasi DTO terjadi melalui ValidationPipe. Kemudian metode controller berjalan, mendelegasikan ke service, yang memanggil Prisma. Akhirnya, interceptor mengubah respons.

Prisma Schema sebagai Sumber Kebenaran

Schema.prisma Prisma-mu adalah definisi kanonik model data-mu. Dari situ, Prisma menghasilkan tipe TypeScript untuk setiap model, setiap relasi, dan setiap hasil query. Ketika kamu mengubah skema — menambahkan field, mengganti nama kolom — menjalankan 'npx prisma generate' meregenerasi client dan TypeScript segera menandai kode apapun yang menggunakan nama field lama.

HTTP Request
      │
      ▼
┌─────────────────┐
│  NestJS Guard   │  (JWT validation, role check)
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  Pipe / DTO     │  (class-validator + class-transformer)
│  Validation     │  (400 Bad Request on failure)
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  Controller     │  (@Get, @Post, @Put, @Delete)
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  Service        │  (business logic, type-safe)
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  Prisma Client  │  (fully typed DB queries)
└────────┬────────┘
         │
         ▼
  PostgreSQL DB

Aktifkan logging query Prisma di development: atur log: ['query'] di opsi PrismaClient. Kamu akan melihat SQL sebenarnya yang dieksekusi, yang mengungkap masalah N+1 query secara langsung. Metode service yang memanggil findMany pada pesanan dan kemudian loop untuk menemukan setiap pelanggan secara individual menjadi jelas ketika kamu melihat 21 query SQL berurutan untuk 20 pesanan.

DTO, Validasi, dan ValidationPipe

Data Transfer Object (DTO) adalah kelas TypeScript biasa yang didekorasi dengan decorator class-validator. Mereka mendefinisikan dengan tepat bentuk apa yang harus dimiliki body request. ValidationPipe NestJS mencegat request yang masuk, menjalankan class-validator pada body, dan secara otomatis mengembalikan 400 dengan pesan error tingkat field jika validasi gagal. Aktifkan secara global di main.ts dengan app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })).

Prisma Service sebagai Singleton

Buat PrismaService khusus yang memperluas PrismaClient dan mengimplementasikan OnModuleInit dan OnModuleDestroy. Ini memastikan koneksi dibuka sekali ketika modul diinisialisasi dan ditutup dengan baik saat shutdown. Injeksi melalui dependency injection NestJS di mana pun kamu membutuhkan akses database. Jangan pernah menginstansiasi PrismaClient langsung di service — kamu akan menghabiskan connection pool di bawah beban.

// prisma/schema.prisma
model Product {
  id          String   @id @default(cuid())
  name        String
  price       Decimal  @db.Decimal(10, 2)
  stock       Int      @default(0)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
  categoryId  String
  category    Category @relation(fields: [categoryId], references: [id])
}

// src/products/dto/create-product.dto.ts
import { IsString, IsNumber, IsPositive, IsNotEmpty, Min } from "class-validator"
import { Type } from "class-transformer"

export class CreateProductDto {
  @IsString()
  @IsNotEmpty()
  name: string

  @IsNumber({ maxDecimalPlaces: 2 })
  @IsPositive()
  @Type(() => Number)
  price: number

  @IsNumber()
  @Min(0)
  @Type(() => Number)
  stock: number

  @IsString()
  @IsNotEmpty()
  categoryId: string
}

// src/products/products.service.ts
import { Injectable, NotFoundException } from "@nestjs/common"
import { PrismaService } from "../prisma/prisma.service"
import { CreateProductDto } from "./dto/create-product.dto"
import { Prisma } from "@prisma/client"

@Injectable()
export class ProductsService {
  constructor(private readonly prisma: PrismaService) {}

  async create(dto: CreateProductDto) {
    return this.prisma.product.create({
      data: dto,
      include: { category: true },
    })
  }

  async findAll(page = 1, limit = 20) {
    const skip = (page - 1) * limit
    const [data, total] = await this.prisma.$transaction([
      this.prisma.product.findMany({
        skip,
        take: limit,
        include: { category: { select: { name: true } } },
        orderBy: { createdAt: "desc" },
      }),
      this.prisma.product.count(),
    ])
    return { data, total, page, limit, pages: Math.ceil(total / limit) }
  }

  async findOne(id: string) {
    const product = await this.prisma.product.findUnique({
      where: { id },
      include: { category: true },
    })
    if (!product) throw new NotFoundException(`Product ${id} not found`)
    return product
  }
}

Penanganan Error dan Bentuk Respons yang Konsisten

Prisma melempar error bertipe: PrismaClientKnownRequestError dengan kode P2025 berarti 'record tidak ditemukan', P2002 berarti 'pelanggaran unique constraint'. Tangkap ini di service-mu dan lempar exception NestJS yang sesuai (NotFoundException, ConflictException). Gunakan exception filter untuk menstandarkan respons error: setiap error harus mengembalikan { statusCode, message, error, timestamp, path }.

Pesan error Prisma dapat berisi nama tabel, nama kolom, dan detail query yang mengungkap skema database-mu. Selalu tangkap error Prisma di layer service-mu, log error lengkap di sisi server, dan lempar exception HTTP NestJS yang sudah dibersihkan ke klien. PrismaClientKnownRequestError mentah dalam respons 500 adalah masalah keamanan, bukan hanya masalah UX.

Menguji API Type-Safe

Stack type-safety bersinar dalam pengujian. Gunakan @nestjs/testing untuk membuat modul pengujian, mock PrismaService dengan jest.mock, dan uji setiap metode service secara terpisah. Karena tipe Prisma dihasilkan dari skema-mu, nilai kembalian yang di-mock harus sesuai dengan tipe nyata — TypeScript menangkap bentuk mock yang salah saat compile time.

Menskalakan Pola Ini ke API yang Lebih Besar

Saat API berkembang, perkenalkan pola repository antara service dan Prisma. Repository menangani query Prisma mentah; service menangani logika bisnis. Pemisahan ini membuat service lebih mudah diuji dan memungkinkan kamu mengganti layer database tanpa menulis ulang logika bisnis. Untuk query kompleks dengan banyak relasi, gunakan select dan include Prisma dengan hemat — definisikan objek select yang dapat digunakan kembali sebagai konstanta TypeScript.

Sumber & Bacaan Lanjutan

Artikel Terkait

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Mengapa menggunakan Prisma alih-alih query builder biasa atau SQL mentah di API NestJS?

Prisma menghasilkan client yang sepenuhnya bertipe langsung dari schema.prisma, sehingga setiap hasil query memiliki tipe TypeScript dan query yang tidak valid gagal saat compile time, bukan saat runtime. Saat kamu mengganti nama kolom atau menambah field, menjalankan 'npx prisma generate' langsung menandai kode yang masih memakai nama lama, menutup celah sebelum deployment. Pendekatan schema sebagai sumber kebenaran ini sangat berharga di API ERP tempat nama field yang usang bisa merusak data penggajian atau invoice.

Di mana dalam siklus hidup request NestJS validasi DTO harus ditempatkan, dan mengapa penempatannya penting?

Validasi DTO berada di layer Pipe, yang berjalan setelah Guard tetapi sebelum metode controller dieksekusi. Mengaktifkan ValidationPipe secara global di main.ts memastikan setiap body request diperiksa — field wajib yang hilang atau nilai tidak valid langsung mengembalikan respons 400 sebelum logika bisnis berjalan. Menaruh logika validasi di dalam service atau controller berarti kehilangan format 400 otomatis dan bentuk error yang konsisten.

Bagaimana opsi 'whitelist: true' pada ValidationPipe melindungi dari serangan mass-assignment?

Opsi whitelist memerintahkan ValidationPipe untuk menghapus properti apa pun dari body request yang tidak dideklarasikan di kelas DTO, sehingga penyerang tidak bisa menyuntikkan field tambahan yang memetakan ke kolom database sensitif. Menggabungkannya dengan 'forbidNonWhitelisted: true' lebih jauh lagi — ia mengembalikan error 400 jika ada properti tak dikenal, membuat penolakan menjadi eksplisit, bukan diam-diam.

Mengapa error Prisma tidak boleh dikirim langsung ke klien API?

Pesan error Prisma bisa mengekspos detail internal seperti nama tabel, nama kolom, dan fragmen query mentah yang mengungkap skema database kepada penyerang. Pola yang dianjurkan adalah menangkap PrismaClientKnownRequestError di layer service, mencatat error lengkap di sisi server, lalu melempar exception HTTP NestJS yang sudah dibersihkan — misalnya NotFoundException untuk P2025 atau ConflictException untuk P2002. PrismaClientKnownRequestError mentah dalam respons 500 adalah masalah keamanan, bukan sekadar masalah UX.

Bagaimana pola repository membantu ketika API NestJS + Prisma semakin besar?

Memperkenalkan layer repository antara service dan Prisma memisahkan query database mentah dari logika bisnis, membuat service lebih mudah diuji dan memungkinkan layer database diganti tanpa menulis ulang aturan bisnis. Post ini juga merekomendasikan mendefinisikan objek select Prisma yang dapat digunakan kembali sebagai konstanta TypeScript agar tidak menduplikasi daftar field yang panjang di seluruh codebase seiring bertambahnya relasi dan endpoint.