A detailed backend reference

Validasi & Transformasi.

Penelusuran dari prinsip dasar tentang aturan dan pedoman yang Anda pegang saat merancang API, gerbang yang dilalui setiap data klien sebelum logika bisnis apa pun berjalan. Mengapa ia ada, di mana ia tinggal, bagaimana ia bekerja di dalamnya, dengan implementasi lengkap di Go dan Python ditampilkan berdampingan.

Data IntegritySecurityController Entry PointGo + Python / full code15 sections Versi Sederhana
Part I / The Mental Model
01

Apa Itu & Kenapa Ada

Validasi dan transformasi bukan satu fitur yang Anda pasang. Mereka seperangkat aturan dan pedoman yang Anda pegang saat merancang API. Hampir semuanya berakar pada dua tujuan, dan dua tujuan itulah lensa yang dipakai mengambil setiap keputusan berikutnya:

Goal 01Data Integrity
Whatever data finally reaches your business logic and your database is in the exact shape your application expects, correct fields, correct types, correct ranges, correct meaning.
Goal 02Security
Untrusted input from the outside world is never allowed to drive your system into an unexpected, unhandled state. The server stays defensive about everything that crosses its boundary.

Apa yang dihitung sebagai "data klien yang masuk"

Batas validasi mencakup setiap jenis data yang bisa didorong klien ke server, bukan hanya body request. Semua yang berikut ini harus diperlakukan tidak tepercaya sampai terbukti sebaliknya:

The one-sentence definition Validation answers "is this data in the exact format my API expects?" Transformation answers "how do I reshape this data into the format my service layer wants?" They run together, at the front door, before anything important happens.
02

Di Mana Ia Tinggal: Tiga Lapisan

Untuk menemukan di mana validasi terjadi, Anda dulu butuh model mental standar backend: tiga lapisan yang ditumpuk, masing-masing satu tugas. Request turun melewati tumpukan dan respons naik kembali.

BottomRepository Layer
Deals only with the database, connections, queries, inserts, updates, deletes. It does not care why a row is being written, only that it is. Works against a relational DB (Postgres), Redis, or any other persistent store.
MiddleService Layer
The business logic. A single service method may call one or more repository methods, send push notifications, email users, store data, fire a webhook, everything an API is expected to actually do.
TopController Layer
The HTTP-facing entry point. It owns everything HTTP: which status code to return, the response format, and, crucially, the validations. It receives client data, calls the matching service method, and returns whatever that method gives back.

Alasan controller dan service sengaja dipisahkan adalah menjaga urusan HTTP (kode error, kode sukses, bentuk respons, validasi) di satu tempat, dan logika bisnis murni di tempat lain. Controller memanggil service; service boleh atau tidak memanggil repository, tergantung kebutuhan request.

CLIENT JSON / query / path / headers CONTROLLER LAYER HTTP concerns / status codes / response shape > VALIDATION & TRANSFORMATION live here < the only layer that touches raw client data SERVICE LAYER business logic / email / notifications webhooks / orchestration REPOSITORY LAYER DB connections / queries / insert / update / delete Postgres / Redis calls service method may call repository
The standard stack, validation is pinned to the top of the controller
03

Siklus Hidup Request

Request tidak muncul dengan ajaib di dalam method controller Anda. Ia tiba, dicocokkan ke route, dan baru setelah itu method controller yang terdaftar untuk route itu dipanggil. Urutan itulah kunci menempatkan validasi dengan benar.

  1. Data tiba. Klien membuka koneksi HTTP dan mengirim request, biasanya payload JSON, plus parameter query dan path serta header.
  2. Pencocokan route berjalan. Router membandingkan path yang masuk dengan pola-polanya dan memilih method controller yang terdaftar untuk route yang cocok.
  3. Pipeline validasi & transformasi menyala. Sebelum logika penting apa pun berjalan, controller yang cocok melewatkan data mentah lewat pipeline.
  4. Logika bisnis berjalan. Hanya jika data lulus, controller memanggil method service.
  5. Service -> repository. Service mengeksekusi logika, kadang memanggil method repository yang menyentuh database.
  6. Respons naik kembali. Apa pun yang dikembalikan service diserahkan ke controller, yang mengembalikannya ke pemanggil lewat koneksi HTTP.
CLIENTsends request ROUTEMATCHING VALIDATION &TRANSFORMATIONthe gate, runs first CONTROLLERbusiness path SERVICE-> repository on failure -> 400 Bad Request, never reaches service
The gate sits between route matching and any business logic
04

Titik Eksekusi

Validasi dan transformasi terjadi di tepat satu titik: di dalam lapisan controller, segera setelah route dicocokkan dan sebelum method service apa pun dipanggil atau logika bisnis berjalan. Secara konkret ia diimplementasikan sebagai middleware atau fungsi utilitas yang bisa dipakai ulang, yang Anda beri skema: deskripsi bentuk data yang seharusnya, dan ia menapaki setiap field payload yang masuk terhadap skema itu.

Why the front door, specifically Validating at the entry point guarantees the system never carries an unexpected state any deeper than the front door. Bad data is rejected while it is still cheap to reject, before database connections open, before emails queue, before any side effect fires.
05

Kenapa Kritis: Kisah 500 vs 400

Cara paling jelas merasakan kebutuhan validasi adalah melihat apa yang terjadi tanpa itu. Bayangkan API "create book" yang mengharapkan body JSON berisi field name yang harus string. Klien malah mengirim angka 0:

POST /api/books
Request body (malformed)
{ "name": 0 }

Di skema Postgres Anda, kolom itu hampir pasti dideklarasikan seperti ini, kolom string yang tidak boleh null:

schema.sqlSQL
CREATE TABLE books (
  id    SERIAL PRIMARY KEY,
  name  TEXT NOT NULL   -- expects text, refuses an integer
);

Tanpa validasi, 0 itu meluncur melewati controller, melewati service, turun ke repository, dan menjadi INSERT. Postgres memeriksa tipe kolom, melihat angka di tempat yang menuntut TEXT, dan menolak operasinya. Panggilan database gagal, dan klien menerima:

Response without validation 500 INTERNAL SERVER ERROR
"Something went wrong on the server"
{ "error": "Internal Server Error" }

Itu pengalaman pengguna yang buruk. 500 berbunyi "ada yang tidak terduga rusak di dalam kami" padahal kenyataannya klien yang mengirim input buruk, dan tidak memberi mereka apa pun untuk diperbaiki. Dengan pipeline validasi di titik masuk, server menangkap kesalahannya seketika, database tak pernah tersentuh, dan klien mendapat 400 Bad Request yang bisa ditindaklanjuti:

Response with validation 400 BAD REQUEST
Actionable, returned before any DB call
{ "errors": [ "name: expected a string, received a number" ] }
PATH A / NO VALIDATION {name:0} controller service repository DB REJECTStype mismatch 500 to client"something broke" / unhelpful PATH B / WITH VALIDATION AT THE GATE {name:0} VALIDATIONexpected string, got number 400 to client (instant)exactly what to fix service / DBnever touched
Same bad input, two outcomes, 500 disaster vs a clean 400 that never wakes the database
06

Di Dalam Pipeline: Langkah demi Langkah

Pipeline yang kokoh memproses satu field dalam lapisan, dan urutannya penting: tiap pemeriksaan hanya berjalan jika yang sebelumnya lulus. Ambil satu field, name, yang diwajibkan string dengan panjang 5-100:

  1. Pemeriksaan keberadaan. Apakah name ada di payload sama sekali? Jika tidak -> "name is required", berhenti.
  2. Pemeriksaan tipe. Ada, apakah ia string, atau klien mengirim larik / boolean / angka? Jika tipe salah -> "expected a string", berhenti.
  3. Pemeriksaan batasan. Ia string, apakah memenuhi pembatasan? Panjang 5-100. String 2 karakter maupun satu paragraf penuh sama-sama gagal dengan pesan yang disesuaikan.
incoming field "name" 1 / EXISTENCE CHECK 2 / TYPE CHECK 3 / CONSTRAINT CHECK PASS -> controller "name is required"400 "expected a string"400 "length must be 5-100"400
Each gate only opens if the previous one passed, failure short-circuits to a 400

Dalam kode, library mengekspresikan ini sebagai skema deklaratif. Go umumnya memakai struct tag dengan go-playground/validator; Python memakai Pydantic, tempat model yang sama memvalidasi dan mentransformasi. Ini versi lengkap dan bisa dijalankan dari pipeline di masing-masing:

v Go (left) / Python (right), both shown in full, no toggle
pipeline.goGo
package books

import (
    "encoding/json"
    "fmt"
    "net/http"
    "strings"

    "github.com/go-playground/validator/v10"
)

// one shared, reusable validator (the pipeline engine)
var validate = validator.New()

// CreateBook is the SCHEMA. The tags declare every rule:
//   required      -> existence check
//   the Go type   -> type check (string)
//   min=5,max=100 -> constraint check
type CreateBook struct {
    Name string `json:"name" validate:"required,min=5,max=100"`
}

// runPipeline decodes then validates; returns 400-ready messages.
func runPipeline(r *http.Request) (*CreateBook, []string) {
    var body CreateBook

    // 1. TYPE CHECK during decode: a JSON number for `name`
    //    cannot unmarshal into a Go string -> caught here.
    if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
        return nil, []string{"name: expected a string"}
    }
    // 2. EXISTENCE + CONSTRAINT checks in one pass.
    if err := validate.Struct(body); err != nil {
        return nil, formatErrors(err)
    }
    return &body, nil
}

// formatErrors turns validator output into human messages.
func formatErrors(err error) []string {
    var msgs []string
    for _, e := range err.(validator.ValidationErrors) {
        f := strings.ToLower(e.Field())
        switch e.Tag() {
        case "required":
            msgs = append(msgs, fmt.Sprintf("%s: is required", f))
        case "min":
            msgs = append(msgs, fmt.Sprintf("%s: min %s chars", f, e.Param()))
        case "max":
            msgs = append(msgs, fmt.Sprintf("%s: max %s chars", f, e.Param()))
        default:
            msgs = append(msgs, fmt.Sprintf("%s: failed %s", f, e.Tag()))
        }
    }
    return msgs
}
pipeline.pyPython
from fastapi import HTTPException
from pydantic import BaseModel, Field, ValidationError


# CreateBook is the SCHEMA. Each annotation = one rule:
#   no default      -> existence check (required)
#   : str           -> type check
#   min/max_length  -> constraint check
class CreateBook(BaseModel):
    name: str = Field(min_length=5, max_length=100)


def run_pipeline(raw: dict) -> CreateBook:
    """Decode + validate in one call.
    Raises a 400-ready error on failure."""
    try:
        # constructing the model runs all three layers
        # in order: existence -> type -> constraint
        return CreateBook(**raw)
    except ValidationError as e:
        # reshape pydantic errors into clean strings
        messages = [
            f"{err['loc'][0]}: {err['msg']}"
            for err in e.errors()
        ]
        raise HTTPException(status_code=400, detail=messages)


# run_pipeline({})            -> 400 ["name: Field required"]
# run_pipeline({"name": 0})   -> 400 ["name: Input should be
#                                      a valid string"]
# run_pipeline({"name": "ab"})-> 400 ["name: String should
#                                      have at least 5 chars"]
# run_pipeline({"name": "Dune"}) ... still <5 -> 400
# run_pipeline({"name": "The Hobbit"}) -> OK
Part II / The Four Types of Validation

Ini bukan taksonomi tetap yang wajib dihafal, bisa lebih banyak tergantung kebutuhan, tapi dalam praktik empat rasa menutupi hampir semua yang akan Anda temui saat merancang API. Pesan yang sebenarnya adalah pertanyaan yang terus Anda tanyakan ke diri sendiri: seberapa ketat dan seberapa spesifik saya mau terhadap data ini?

07

Validasi Tipe

Rasa paling dasar: apakah field cocok dengan tipe data yang diharapkan API? Apakah ia string, angka, boolean, larik, atau objek JSON bertingkat, dan bukan yang lain? Ia juga bisa berlaku rekursif: field larik mungkin mewajibkan setiap elemen berupa string, jadi elemen 0 yang berupa angka adalah error tipe tersendiri.

POST /api/valid/type
Wrong types sent
{ "stringField":"x", "numberField":"x", "arrayField":"x", "boolField":"x" }
Response 400
numberField: expected number, received string
arrayField:  expected array,  received string
boolField:   expected boolean, received string
Then an array of numbers where strings are required
{ "arrayField": [1, 2] }  ->  arrayField[0]: expected string, received number
Fully corrected -> 200
{ "stringField":"something", "numberField":10, "arrayField":["one","two"], "boolField":false }
type_validation.goGo
package validate

import (
    "encoding/json"
    "errors"
    "net/http"
)

// *bool so we can tell "false" apart from "missing".
type TypePayload struct {
    StringField string   `json:"stringField" validate:"required"`
    NumberField float64  `json:"numberField" validate:"required"`
    // dive = recurse INTO the slice; each element required
    ArrayField  []string `json:"arrayField" validate:"required,dive,required"`
    BoolField   *bool    `json:"boolField" validate:"required"`
}

func parseTypes(r *http.Request) (*TypePayload, error) {
    var p TypePayload
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields() // reject stray keys

    // json.Decode enforces the BASE types: a string for
    // numberField / arrayField / boolField fails to unmarshal.
    if err := dec.Decode(&p); err != nil {
        return nil, errors.New("a field has the wrong data type")
    }
    // existence + recursive element check
    if err := validate.Struct(p); err != nil {
        return nil, err
    }
    return &p, nil
}

// {"numberField":"x"} -> expected number, received string
// {"arrayField":[1,2]} -> arrayField[0]: expected string
type_validation.pyPython
from pydantic import BaseModel
from typing import List


class TypePayload(BaseModel):
    stringField: str
    numberField: float
    arrayField:  List[str]   # recursive: EVERY element a string
    boolField:   bool


# Strict, explicit type errors:
#
# TypePayload(stringField="x", numberField="x",
#             arrayField="x", boolField="x")
#   -> numberField: Input should be a valid number
#   -> arrayField:  Input should be a valid list
#   -> boolField:   Input should be a valid boolean
#
# TypePayload(..., arrayField=[1, 2], ...)
#   -> arrayField.0: Input should be a valid string
#   -> arrayField.1: Input should be a valid string
#
# TypePayload(stringField="something", numberField=10,
#             arrayField=["one","two"], boolField=False) -> OK
08

Validasi Sintaksis

Di sini tipe sudah benar (string) tapi stringnya harus mengikuti pola struktural tertentu. Algoritma validasi memeriksa bentuk nilainya:

POST /api/valid/syntactic
Empty body
{ }
Response 400, the errors double as live documentation
email: required   phone: required   date: required
Bad values
{ "email":"randomstring", "phone":1234567, "date":"2025-11-05" }
Response 400
email: invalid email format
phone: expected a string, received a number
Corrected
{ "email":"test@test.com", "phone":"1234567", "date":"2025-11-05" }  ->  200
A free benefit Even a client that hits the API blind, with an empty payload, gets back the list of required fields and their expected shapes. When the API docs are missing or out of date, the validation error messages themselves are a perfectly good way to discover what the endpoint demands.
syntactic.goGo
package validate

import (
    "errors"
    "regexp"
)

// optional + then 7-15 digits (country code + national no.)
var phoneRe = regexp.MustCompile(`^\+?[0-9]{7,15}$`)

type Contact struct {
    Email string `validate:"required,email"`            // local @ domain.tld
    Phone string `validate:"required"`                  // checked below
    Date  string `validate:"required,datetime=2006-01-02"` // YYYY-MM-DD
}

// checkSyntax handles the phone pattern (validator's
// built-ins cover email + date structure already).
func (c Contact) checkSyntax() error {
    if !phoneRe.MatchString(c.Phone) {
        return errors.New("phone: invalid phone number format")
    }
    return nil
}

// "randomstring"            -> email: invalid email format
// phone sent as JSON number -> received a number, want string
// "2025-13-40"              -> date: does not match YYYY-MM-DD
syntactic.pyPython
from datetime import date
from pydantic import BaseModel, EmailStr, Field


class Contact(BaseModel):
    email: EmailStr                       # local @ domain.tld
    # country code + 7-15 digits
    phone: str = Field(pattern=r"^\+?[0-9]{7,15}$")
    date:  date                           # only accepts YYYY-MM-DD


# Contact(email="randomstring", phone=1234567,
#         date="2025-11-05")
#   -> email: value is not a valid email address
#   -> phone: Input should be a valid string
#
# Contact(email="bad@", ...)
#   -> email: there must be something after the @-sign
#
# Contact(email="test@test.com", phone="1234567",
#         date="2025-11-05")  -> OK
09

Validasi Semantik

Tipe benar dan sintaks benar, tapi apakah nilainya masuk akal di dunia nyata? Pemeriksaan semantik meng-encode logika domain:

POST /api/valid/semantic
Future DOB
{ "dateOfBirth":"2026-06-12", "age":43 }
Response 400
dateOfBirth: date of birth cannot be in the future
Impossible age
{ "dateOfBirth":"1995-06-12", "age":430 }
Response 400
age: number must be less than or equal to 120
Sensible
{ "dateOfBirth":"1995-06-12", "age":43 }  ->  200
semantic.goGo
package validate

import (
    "errors"
    "time"
)

type Profile struct {
    DateOfBirth string `validate:"required,datetime=2006-01-02"`
    // gte/lte cover the "430 is impossible" semantic bound
    Age int `validate:"required,gte=1,lte=120"`
}

// Type & syntax can't express "not in the future"
// semantics need real logic against the real clock.
func (p Profile) checkSemantics() error {
    dob, err := time.Parse("2006-01-02", p.DateOfBirth)
    if err != nil {
        return errors.New("dateOfBirth: invalid date")
    }
    if dob.After(time.Now()) {
        return errors.New(
            "dateOfBirth: date of birth cannot be in the future")
    }
    return nil
}

// {"dateOfBirth":"2026-06-12"} -> cannot be in the future
// {"age":430}                  -> age: must be 120 or less
semantic.pyPython
from datetime import date
from pydantic import BaseModel, Field, field_validator


class Profile(BaseModel):
    dateOfBirth: date
    age: int = Field(ge=1, le=120)   # 430 -> must be <= 120

    @field_validator("dateOfBirth")
    @classmethod
    def not_in_future(cls, v: date) -> date:
        if v > date.today():
            raise ValueError(
                "date of birth cannot be in the future")
        return v


# Profile(dateOfBirth="2026-06-12", age=43)
#   -> dateOfBirth: date of birth cannot be in the future
# Profile(dateOfBirth="1995-06-12", age=430)
#   -> age: Input should be less than or equal to 120
# Profile(dateOfBirth="1995-06-12", age=43) -> OK
10

Validasi Kompleks / Dependen

Rasa paling kuat: aturan sebuah field bergantung pada field lain. Pipeline bisa meng-encode logika lintas-field yang arbitrer:

POST /api/valid/complex
Mismatched + short password
{ "password":"random", "passwordConfirmation":"another", "married":false }
Response 400
password: string must contain at least 8 characters
passwordConfirmation: passwords don't match
married=true but no partner
{ "password":"random12", "passwordConfirmation":"random12", "married":true }
Response 400
partner: partner name is required when married is true
Complete
{ "password":"random12", "passwordConfirmation":"random12", "married":true, "partner":"Sam" }  ->  200
complex.goGo
package validate

type Signup struct {
    Password string `json:"password" validate:"required,min=8"`
    // eqfield: must equal another field on the struct
    PasswordConf string `json:"passwordConfirmation" \
        validate:"required,eqfield=Password"`
    Married *bool `json:"married" validate:"required"`
    // required_if: partner required only if married == true
    Partner string `json:"partner" \
        validate:"required_if=Married true"`
}

// {password:"random", passwordConfirmation:"another",
//  married:false}
//   -> password: must be at least 8 characters
//   -> passwordConfirmation: passwords don't match
//
// {password:"random12", passwordConfirmation:"random12",
//  married:true}
//   -> partner: required when married is true
complex.pyPython
from pydantic import BaseModel, Field, model_validator


class Signup(BaseModel):
    password: str = Field(min_length=8)
    passwordConfirmation: str
    married: bool
    partner: str | None = None

    # mode="after": runs once all fields are parsed,
    # so it can compare them against each other.
    @model_validator(mode="after")
    def cross_field_rules(self):
        if self.password != self.passwordConfirmation:
            raise ValueError("passwords don't match")
        if self.married and not self.partner:
            raise ValueError(
                "partner name is required when married is true")
        return self


# Signup(password="random", passwordConfirmation="another",
#        married=False)
#   -> password: String should have at least 8 characters
#   -> Value error, passwords don't match
THE FOUR FLAVORS, INCREASING STRICTNESS & CONTEXT -> TYPEis it the rightdata type?string / numberbool / array SYNTACTICright structuralpattern?email / phonedate format SEMANTICdoes the valuemake sense?DOB not futureage <= 120 COMPLEXdepends onother fields?password matchmarried->partner
From "right type" to "right relationship", pick how strict the endpoint needs to be
Part III / Transformation
11

Transformasi sebagai Type Casting

Transformasi berarti mengeksekusi operasi pada data yang masuk untuk mengonversinya ke format yang diinginkan, entah sebelum validasi (supaya ia bisa lulus) atau setelahnya (untuk menyiapkannya bagi lapisan service). Kasus buku teksnya adalah parameter query pagination.

Anggap GET /bookmarks?page=2&limit=20 dengan syarat-syarat ini:

The catch Query parameters are always strings by default. Every value in the query string arrives at your server as a string, they have no other native type. So page arrives as the string "2", not the number 2. Validation that demands a number fails on its very first check, even though the client did nothing wrong, strings are simply how query params work.

Perbaikannya bukan error. Ia tanggung jawab server untuk meng-cast string menjadi angka lebih dulu. "Casting" adalah memaksa satu tipe data menjadi tipe lain. Cast "2" -> 2, lalu jalankan validasi numeriknya. Langkah casting itulah transformasinya.

?page="2"arrives as STRING TRANSFORMcast "2" -> 2(force the type) page = 2now a NUMBER VALIDATE0 < n < 500
Transformation runs before validation here, cast the string, then the numeric rules can even apply
pagination.goGo
package validate

import (
    "errors"
    "net/url"
    "strconv"
)

type Pagination struct {
    Page  int
    Limit int
}

// Query params are ALWAYS strings, so we must CAST
// (transform) before we can VALIDATE the numbers.
func parsePagination(q url.Values) (*Pagination, error) {
    // TRANSFORM: force the string "2" into the int 2
    page, err := strconv.Atoi(q.Get("page"))
    if err != nil {
        return nil, errors.New("page: must be a number")
    }
    limit, err := strconv.Atoi(q.Get("limit"))
    if err != nil {
        return nil, errors.New("limit: must be a number")
    }

    // VALIDATE the now-numeric values
    if page <= 0 || page >= 500 {
        return nil, errors.New("page: must be 1..499")
    }
    if limit <= 0 || limit >= 10000 {
        return nil, errors.New("limit: must be 1..9999")
    }
    return &Pagination{Page: page, Limit: limit}, nil
}
pagination.pyPython
from pydantic import BaseModel, Field


class Pagination(BaseModel):
    # Pydantic AUTO-CASTS the incoming string "2" into
    # int 2 (transform), THEN enforces gt/lt (validate)
    # -- both steps in one schema, in the right order.
    page:  int = Field(gt=0, lt=500)
    limit: int = Field(gt=0, lt=10_000)


# request: /bookmarks?page=2&limit=20
# (both values arrive as strings)
#
# Pagination(page="2", limit="20")
#   -> Pagination(page=2, limit=20)   # real ints
#
# Pagination(page="0", limit="20")
#   -> page: Input should be greater than 0
#
# Pagination(page="abc", limit="20")
#   -> page: Input should be a valid integer
12

Transformasi sebagai Normalisasi

Transformasi juga membersihkan data setelah lolos validasi, membentuk ulang menjadi yang disukai lapisan service. Server menormalkan payload dengan tenang sebelum melakukan apa pun dengannya:

POST /api/valid/transform
What the client sent
{ "email":"Test@TEST.com", "phone":"1234567", "date":"2025-11-05" }
What the server returns 200, normalized
{ "email":"test@test.com", "phone":"+1234567", "date":"2025-11-05" }
normalize.goGo
package validate

import "strings"

// normalize runs AFTER validation passes and BEFORE
// the data is handed to the service layer.
func (c *Contact) normalize() {
    // lowercase + trim the email
    c.Email = strings.ToLower(strings.TrimSpace(c.Email))

    // inject the leading + if it is missing
    c.Phone = strings.TrimSpace(c.Phone)
    if !strings.HasPrefix(c.Phone, "+") {
        c.Phone = "+" + c.Phone
    }
}

// "Test@TEST.com" -> "test@test.com"
// "1234567"       -> "+1234567"
normalize.pyPython
from pydantic import BaseModel, EmailStr, field_validator


class Contact(BaseModel):
    email: EmailStr
    phone: str

    # field_validators double as transformers: whatever
    # they return REPLACES the incoming value.
    @field_validator("email")
    @classmethod
    def lower_email(cls, v: str) -> str:
        return v.strip().lower()       # normalize case

    @field_validator("phone")
    @classmethod
    def add_plus(cls, v: str) -> str:
        v = v.strip()
        return v if v.startswith("+") else "+" + v


# Contact(email="Test@TEST.com", phone="1234567")
#   -> email="test@test.com", phone="+1234567"
13

Satu Pipeline Gabungan

Dalam praktik validasi dan transformasi dipasangkan menjadi satu pipeline. Alasannya lokalitas: seluruh "lapisan data input", setiap syarat dan setiap operasi yang dilakukan pada data masuk sebelum logika bisnis berjalan, tinggal di satu tempat. Anda tak pernah harus berburu lintas file untuk menemukan apa yang diharapkan sebuah endpoint atau apa yang diam-diam ia lakukan pada data. Pipeline bisa transformasi -> validasi, atau validasi -> transformasi, urutan mana pun, sesuai tuntutan kebutuhan.

VALIDATION & TRANSFORMATION PIPELINE, one place transform validate transform cast query strings type / syntax / semantic / complex lowercase / prefix / reformat
All input-layer logic, requirements and operations, collected in a single reusable middleware
Part IV / The Golden Rule
14

Validasi Frontend vs Backend

Kesalahan umum dan berbahaya adalah memperlakukan validasi frontend sebagai pengganti validasi backend. Keduanya tidak bisa dipertukarkan, mereka melayani tujuan berbeda, dan Anda butuh keduanya untuk setiap API.

FrontendFor UX
A web form validates as the user types and on submit, giving immediate, friendly feedback ("that doesn't look like an email") before any API call is made. Its entire job is user experience. It is not about security or data integrity.
BackendFor Security
Server-side validation is mandatory and exists for security and data integrity. It does not matter what the client is or whether the client validated at all, the server must be as strict and specific as possible on its own.

Kenapa backend tidak bisa memercayai frontend? Karena server bisa punya banyak klien berbeda. Aplikasi web yang rapi mungkin memvalidasi dengan indah, tapi API yang sama bisa juga dipukul langsung lewat API client seperti Postman atau Insomnia, tempat tidak ada frontend yang bertindak sebagai proxy sehingga tidak ada validasi frontend sama sekali. Jika backend pernah bergantung pada frontend untuk keamanan atau integritas, server akan rusak begitu klien berubah.

WEB APPhas FE validation (UX) POSTMAN / INSOMNIANO FE validation at all MOBILE / 3RD PARTYvalidation unknown SERVERBACKEND VALIDATIONstrict / specific / mandatorysecurity & data integrity, for ALL clients
One server, many clients, only the backend gate is guaranteed to run, so it must never be skippable

Saat keduanya dihubungkan dengan benar, mereka saling melengkapi: dalam form web, email tak valid memblokir submit dan menampilkan error inline, panggilan API bahkan tidak dibuat. Perbaiki ke alamat sungguhan dan submit mengirim request yang layak; payload terkirim, server memvalidasi lagi, dan mengembalikan 200. Frontend memberi umpan balik instan; backend memberi jaminannya.

The rule, stated plainly Design your server-side validation to be as strict and specific as possible without ever thinking about what the client validation looks like. Frontend validation is for user experience; backend validation is for security and data integrity. You always need both.
15

Controller Beranotasi Lengkap

Merangkai gambaran utuh: sebuah controller lengkap yang mencocokkan route, menjalankan pipeline gabungan (transformasi -> validasi -> normalisasi) sebelum logika bisnis apa pun, mengembalikan 400 yang bersih saat gagal, dan baru setelah itu memanggil lapisan service, yang memanggil repository. Kedua ujung ditampilkan lengkap.

main.goGo
package main

import (
    "context"
    "encoding/json"
    "net/http"
    "strings"

    "github.com/go-playground/validator/v10"
)

var validate = validator.New()

// ---- schema (the gate) ----
type CreateBook struct {
    Name string `json:"name" validate:"required,min=5,max=100"`
}

type Book struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

// ---- helper ----
func writeJSON(w http.ResponseWriter, status int, b any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(b)
}

// ---- CONTROLLER: owns HTTP + the validation gate ----
func createBookHandler(w http.ResponseWriter, r *http.Request) {
    var body CreateBook

    // === GATE, runs before ANY business logic ===
    if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
        writeJSON(w, 400, map[string]string{
            "error": "name: expected a string"})
        return // 400, the DB is never touched
    }
    body.Name = strings.TrimSpace(body.Name) // transform
    if err := validate.Struct(body); err != nil {
        writeJSON(w, 400, map[string]string{
            "error": err.Error()})
        return // 400 Bad Request, not a confusing 500
    }

    // === only now: business logic (service -> repo) ===
    book, err := createBook(r.Context(), body.Name)
    if err != nil {
        writeJSON(w, 500, map[string]string{
            "error": "could not create book"})
        return
    }
    writeJSON(w, 201, book)
}

// ---- service + repository (sketched) ----
func createBook(ctx context.Context, name string) (*Book, error) {
    // service logic ... repository INSERT ... then:
    return &Book{ID: 1, Name: name}, nil
}

func main() {
    http.HandleFunc("/api/books", createBookHandler)
    http.ListenAndServe(":8080", nil)
}
main.pyPython
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field, field_validator

app = FastAPI()


# ---- schema (the gate) ----
class CreateBook(BaseModel):
    name: str = Field(min_length=5, max_length=100)

    @field_validator("name")
    @classmethod
    def trim(cls, v: str) -> str:
        return v.strip()          # transform / normalize


class Book(BaseModel):
    id: int
    name: str


# ---- service + repository (sketched) ----
def create_book(name: str) -> Book:
    # service logic ... repository INSERT ... then:
    return Book(id=1, name=name)


# ---- CONTROLLER ----
# FastAPI runs the CreateBook schema (the GATE) BEFORE
# this function body. A bad payload never enters the
# function, the client gets an automatic 422/400, so
# the DB is never touched and no client mistake leaks
# out as a confusing 500.
@app.post("/api/books", status_code=201)
def create_book_endpoint(body: CreateBook) -> Book:
    # validation already passed, straight to logic
    try:
        return create_book(body.name)  # service -> repo
    except Exception:
        raise HTTPException(
            status_code=500, detail="could not create book")


# run:  uvicorn main:app --reload
The whole topic, in one breath Validations and transformations are a small set of rules you keep in mind while designing APIs. They live at the entry point of the controller, run before any business logic, reject bad data with a clear 400 instead of a confusing 500, come in four flavors (type, syntactic, semantic, complex), pair with transformations (casting and normalization) into one pipeline, and on the backend are mandatory for security and data integrity, no matter what the client does.