Mert Özen Her Satırda Daha İleri
Backend Node.js Temelleri — #12

Express ile Bookstore REST API: Router'lar, zod ile Validation, Async Error Handling ve Problem Details

Mert Özen 14 Kas 2026 13 dk 12 görüntülenme
Express ile Bookstore REST API: Router'lar, zod ile Validation, Async Error Handling ve Problem Details

Express ile sağlam bir REST API, katmanları ayırır, her request'i zod şemasıyla doğrulayıp tipi şemadan türetir, hataları tek bir middleware'de RFC 9457 Problem Details'a çevirir ve parayı float değil kuruş cinsinden tam sayı tutar. Gerçek çıktılarla 1339.1999999999998 TL'lik sipariş, 20 milyon satırda 5 kuruş sapan float toplamı, altı hatayı tek cevapta dönen validation, ASP.NET Core ile aynı Problem Details sözleşmesi, sayfalama ve z.toJSONSchema.

Sürüm notu: Bu yazıdaki örnekler Node.js 26.13.1 (LTS), Express 5.3.0, zod 4.6.7 ve TypeScript 7.0.2 ile çalıştırılmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) Ada üç Dune (349,90 TL) ve bir The Hobbit (289,50 TL) alıyor. 11'e kadar API sipariş tutarını şöyle hesaplıyordu:

// 11. yazıya kadar API'nin hesapladığı sipariş toplamı: fiyatlar JavaScript number'ı
const items = [
    { isbn: "9780441172719", title: "Dune", quantity: 3, unitPrice: 349.9 },
    { isbn: "9780547928227", title: "The Hobbit", quantity: 1, unitPrice: 289.5 },
];

const total = items.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0);
console.log(total);
console.log(JSON.stringify({ total }));
console.log(total === 1339.2);

Kâğıt üzerinde cevap 1.339,20. Üç satırda ne yazar, client'a giden JSON'da ne olur?

node labs/12/guess.ts
1339.1999999999998
{"total":1339.1999999999998}
false

JavaScript'in tek sayı tipi number, 64 bitlik binary floating point. 349,90 binary'de tam ifade edilemiyor, çarpınca hata görünür hâle geliyor ve JSON.stringify onu olduğu gibi client'a gönderiyor. C#'ta aynı hesap decimal ile yapılır ve 1339.20 çıkar; JavaScript'te decimal yok. Bu yazıda Bookstore API'sini gerçek bir API'ye dönüştürüyoruz: fiyatları kuruş cinsinden tam sayı olarak tutuyoruz, kodu route → handler → service → repository katmanlarına ayırıyoruz, request'leri zod ile doğruluyoruz ve hataları ASP.NET Core'un kullandığı Problem Details formatında dönüyoruz.

Para: Kuruş Cinsinden Tam Sayı

JavaScript'te parayı doğru tutmanın üç yolu var: tam sayı (kuruş), BigInt ya da bir decimal kütüphanesi. Bookstore'un tutarları küçük: Number.MAX_SAFE_INTEGER yaklaşık 9 katrilyon, yani 90 trilyon liraya kadar kuruş tam sayıları hatasız. Bu yüzden en basitini seçtik: priceMinor, kuruş (EUR için cent) cinsinden bir tam sayı.

// Para, minor unit'lerin (TRY için kuruş, EUR için cent) tam sayı adedi: binary kesir yok
export type Currency = "TRY" | "EUR";

export class InvalidAmountError extends Error {
    constructor(text: string) {
        super(`invalid amount: ${text}`);
        this.name = "InvalidAmountError";
    }
}

// "349,90", "349.9" ya da "349" -> 34990; metin olarak parse edilir, hiç binary float'tan geçmez
export function parseMinorUnits(text: string): number {
    const match = /^(\d{1,9})(?:[.,](\d{1,2}))?$/.exec(text.trim());
    if (!match) {
        throw new InvalidAmountError(text);
    }
    return Number(match[1]) * 100 + Number((match[2] ?? "0").padEnd(2, "0"));
}

// JSON için 34990 -> 349.9; tam sayıyı 100'e bölmek, decimal(10,2) gibi, en yakın double'ı verir
export function toAmount(minorUnits: number): number {
    return minorUnits / 100;
}

// CSV ve log için 34990 -> "349.90"
export function formatAmount(minorUnits: number): string {
    const sign = minorUnits < 0 ? "-" : "";
    const abs = Math.abs(minorUnits);
    return `${sign}${Math.trunc(abs / 100)}.${String(abs % 100).padStart(2, "0")}`;
}

Kural şu: hesap her zaman tam sayıyla yapılır, ondalık değer yalnız sınırda üretilir. src/db/books.json artık "priceMinor": 34990 tutuyor; 08'deki CSV (virgülle ayrılmış değerler) içe aktarma "349,90"'ı Number("349.90") ile değil, parseMinorUnits ile çeviriyor. Client'a giden JSON'da tutar yine bir sayı (349.9): Angular istemcisinin kullandığı BookSummary sözleşmesi değişmedi. Bir tam sayıyı 100'e bölmek, o ondalık değere en yakın double'ı verir; birikmiş bir hata taşımaz.

Farkın ölçeği ne? 06'daki fiyat raporu 20 milyon satırda KDV (katma değer vergisi) dahil ciroyu float ile topluyordu. Aynı satırları iki yoldan topladım:

float sum (post 06):   19707600000.05
integer sum (post 12): 19707600000.00

Float toplamı 5 kuruş saptı; tam sayı toplamı doğru (sonucu Python'un decimal module'üyle ayrıca kontrol ettim). Raporun kendisi de artık kuruşla hesaplıyor. KDV gibi oranlarda yuvarlama her satırda açıkça yapılıyor: Math.round(quantity * unitPriceMinor * 110 / 100). Bölme olan her yerde yuvarlama kuralını sen seçersin; muhasebe ekibiyle hangi kuralın geçerli olduğunu konuşmak gerekir. Veritabanında bu değerler decimal(10,2) olarak duruyor; 13'te SQL Server'dan okurken kuruşa çevireceğiz.

Katmanlar: Route → Handler → Service → Repository

11'de router'lar doğrudan service'leri çağırıyordu ve service'ler module seviyesinde tek bir instance'tı. Yapıyı ASP.NET Core Web API serisinin "Katmanlı Yapı" yazısındakiyle aynı sorumluluklara ayırıyoruz:

  • src/routes: yalnız hangi path'in hangi handler'a gittiği.
  • src/handlers: HTTP ile domain arasındaki çevirmen. Request'i şemayla doğrular, service'i çağırır, sonucu DTO'ya (Data Transfer Object; dışarıya gönderilen veri şekli) çevirir. İş kuralı yok.
  • src/services: iş kuralları. Fiyatı katalogdan almak, bilinmeyen ISBN'i (ISBN: kitapların uluslararası standart numarası) reddetmek, orderPlaced'i yayınlamak.
  • src/repositories: veri erişimi. Şimdilik bellekte, 13'te SQL Server.

Repository interface'i async, çünkü SQL Server implementasyonu aynı interface'i kullanacak:

// Bilerek async: 13. yazıdaki SQL Server implementasyonu aynı interface'i korur
export interface BookRepository {
    list(query: BookQuery): Promise<{ items: Book[]; totalCount: number }>;
    findByIsbn(isbn: string): Promise<Book | undefined>;
    findManyByIsbn(isbns: readonly string[]): Promise<Map<string, Book>>;
}

Node'da yerleşik bir DI (dependency injection; bağımlılıkların dışarıdan verilmesi) container'ı yok ve küçük bir API için gerek de yok. Service'ler bağımlılıklarını constructor'dan alıyor, hepsini tek bir yerde, composition root'ta birbirine bağlıyoruz. ASP.NET Core'daki Program.cs'in builder.Services.Add... kısmının elle yazılmış karşılığı:

// Composition root: hangi implementasyonun kullanılacağına karar veren tek yer
export function createServices(): AppServices {
    const bookRepository = new InMemoryBookRepository(books as Book[]);
    const orderService = new OrderService(bookRepository, new InMemoryOrderRepository());
    registerOrderNotifications(orderService);
    return { books: new BookService(bookRepository), orders: orderService };
}

createApp(services) bu nesneleri alıyor ve router'lara veriyor. 16'da testler aynı createApp'e sahte repository'lerle kurulmuş service'ler verecek. Constructor'larda field'ları ayrı satırda tanımlıyoruz (readonly #books: BookRepository;), çünkü parameter property type stripping'de çalışmıyor (05). OrderService'in sipariş oluşturan method'u artık async ve fiyatı her zaman katalogdan alıyor:

async placeOrder(customerId: string, lines: readonly OrderLine[]): Promise<Order> {
    const books = await this.#books.findManyByIsbn(lines.map((l) => l.isbn));
    const unknown = lines.filter((l) => !books.has(l.isbn)).map((l) => l.isbn);
    if (unknown.length > 0) {
        throw new UnknownBookError(unknown);
    }
    const currencies = new Set(lines.map((l) => books.get(l.isbn)!.currency));
    if (currencies.size > 1) {
        throw new MixedCurrencyError();
    }
    // Fiyat request'ten değil, her zaman katalogdan gelir
    const items: OrderItem[] = lines.map((l) => ({ isbn: l.isbn, quantity: l.quantity, unitPriceMinor: books.get(l.isbn)!.priceMinor }));
    const order: Order = {
        id: await this.#orders.nextId(),
        customerId,
        status: "Pending",
        currency: [...currencies][0],
        items,
        createdAtUtc: new Date().toISOString(),
    };
    await this.#orders.add(order);
    log("info", "order placed", { orderId: order.id, items: items.length });
    this.emit("orderPlaced", order);
    return order;
}

Domain hataları (UnknownBookError, BookNotFoundError…) HTTP'yi bilmiyor; status kodlarına çevrilmeleri hata middleware'inin işi.

zod ile Request Şeması

10 ve 11'de parsePlaceOrder'ı elle yazmıştık: 25 satır typeof kontrolü ve ayrıca bir TypeScript tipi. zod ile şemayı bir kez yazıp tipi ondan türetiyoruz:

import { z } from "zod";
import { isbnSchema } from "#schemas/books";

const orderLine = z.object({
    isbn: isbnSchema,
    quantity: z.int().min(1).max(20),
});

// POST /api/orders'ın request sözleşmesi; bilinmeyen alanlar reddedilir
export const placeOrderBody = z.strictObject({
    customerId: z.string().trim().min(1).max(64),
    lines: z
        .array(orderLine)
        .min(1)
        .max(50)
        .refine((lines) => new Set(lines.map((l) => l.isbn)).size === lines.length, { message: "each ISBN may appear only once" }),
});

// TypeScript tipi şemadan türetilir; ikisi birbirinden ayrışamaz
export type PlaceOrderBody = z.infer<typeof placeOrderBody>;
// Regex JSON Schema / OpenAPI'ye geçer; checksum kontrolü yalnız kodda çalışır
export const isbnSchema = z
    .string()
    // abort: format yanlışsa checksum'ı atla ve iki değil tek hata bildir
    .regex(/^97[89]\d{10}$/, { message: "must be 13 digits starting with 978 or 979", abort: true })
    .refine(isValidIsbn13, { message: "must be a valid ISBN-13" });

z.infer sayesinde handler'da body.lines[0].quantity'nin tipi number; şemaya alan eklersen tip de değişir. strictObject bilinmeyen alanları reddediyor: client'ın discount gibi bir alanı gönderip bir yerde işe yarayacağını umması mümkün değil. ASP.NET Core'daki karşılığı Data Annotations ya da FluentValidation (Web API serisinin "Model Validation" yazısı); fark, orada tip zaten class'tan geliyor, burada tip şemadan türüyor. zod'un hatalarını tek bir yardımcı ASP.NET Core'un errors sözlüğüne çeviriyor:

// ["lines", 0, "quantity"] -> "lines[0].quantity", ASP.NET Core'un "errors" içinde kullandığı biçim
function fieldName(path: readonly PropertyKey[]): string {
    return path.reduce<string>((name, key) => (typeof key === "number" ? `${name}[${key}]` : name ? `${name}.${String(key)}` : String(key)), "") || "$";
}

export function parseRequest<T extends z.ZodType>(schema: T, value: unknown): z.infer<T> {
    const result = schema.safeParse(value);
    if (result.success) {
        return result.data;
    }
    const errors: Record<string, string[]> = {};
    for (const issue of result.error.issues) {
        (errors[fieldName(issue.path)] ??= []).push(issue.message);
    }
    throw new RequestValidationError(errors);
}

Handler böylece kısalıyor ve yalnız çevirmen işini yapıyor:

// POST /api/orders: doğrula, service'i çağır, sonucu çevir; burada iş kuralı yok
export function placeOrder(orders: OrderService): RequestHandler {
    return async (req, res) => {
        if (!req.is("application/json")) {
            throw new HttpError(415, "content-type must be application/json");
        }
        const body = parseRequest(placeOrderBody, req.body);
        const order = await orders.placeOrder(body.customerId, body.lines);
        res.status(201).location(`/api/orders/${order.id}`).json(toOrderDto(order));
    };
}

Query string'ler için aynı yaklaşım: req.query'deki her şey string, bu yüzden z.coerce.number() önce sayıya çeviriyor, sonra kuralları uyguluyor; default eksik parametreyi dolduruyor.

// Query string'ler metindir: z.coerce, sayı kuralları çalışmadan önce "2"'yi 2'ye çevirir
export const listBooksQuery = z.object({
    page: z.coerce.number().int().min(1).default(1),
    pageSize: z.coerce.number().int().min(1).max(50).default(10),
    q: z.string().trim().min(2).max(100).optional(),
    author: z.string().trim().min(1).max(100).optional(),
    sort: z.enum(["title", "price", "-price"]).default("title"),
});

Hata Yönetimi ve Problem Details

HTTP API'lerinde hata cevaplarının standart biçimini tanımlayan belge (RFC 9457), hata cevabı için type, title, status, detail ve instance alanlarını ve application/problem+json content type'ını tanımlıyor. ASP.NET Core'un ProblemDetails'ı bu formatı kullanıyor; doğrulama hatalarına bir errors sözlüğü, her cevaba bir traceId ekliyor. Aynı sözleşmeyi Node tarafında da dönersek Angular istemcisi API'nin hangi dilde yazıldığını fark etmez. Hata middleware'i artık her hata türünü tek yerde Problem Details'a çeviriyor:

// Domain hatası -> HTTP status, tek bir yerde
const DOMAIN_STATUS = new Map<Function, number>([
    [BookNotFoundError, 404],
    [OrderNotFoundError, 404],
    [UnknownBookError, 422],
    [MixedCurrencyError, 422],
]);

// Dört parametre: Express bir hata handler'ını böyle tanır
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
    const context = currentRequestContext();
    if (context?.signal.aborted) {
        log("warn", "client went away", { method: req.method, path: req.path });
        return;
    }
    if (res.headersSent) {
        // Status için çok geç; Express'in varsayılan handler'ı bağlantıyı kapatır
        log("error", "error after the response started", { error: (err as Error).message });
        return next(err);
    }
    const common = { instance: req.originalUrl, traceId: context?.requestId };

    if (err instanceof RequestValidationError) {
        return sendProblem(res, 400, { title: err.message, ...common, errors: err.errors });
    }
    if (err instanceof DomainError) {
        const status = DOMAIN_STATUS.get(err.constructor) ?? 422;
        return sendProblem(res, status, { detail: err.message, ...common });
    }
    if (err instanceof HttpError) {
        return sendProblem(res, err.status, { detail: err.message, ...common });
    }
    // body-parser hataları status ve type taşır; mesajları client'a gösterilmez
    if (err?.type === "entity.parse.failed") {
        return sendProblem(res, 400, { detail: "request body is not valid JSON", ...common });
    }
    if (err?.type === "entity.too.large") {
        // Body'nin geri kalanı hâlâ yolda: hepsini okumak yerine bağlantıyı kapat
        res.setHeader("connection", "close");
        return sendProblem(res, 413, { detail: `request body is larger than ${err.limit} bytes`, ...common });
    }
    // Geri kalan her şey bir bug: ayrıntı log'a, cevaba hiçbiri
    log("error", "unhandled error", { error: (err as Error).stack });
    sendProblem(res, 500, common);
};

sendProblem, status'a göre ASP.NET Core'un kullandığı type adresini ve başlığı seçip application/problem+json ile gönderiyor; traceId olarak 07'deki correlation id'yi kullanıyoruz. Deneyelim. Önce geçerli bir sipariş:

HTTP/1.1 201 Created
Location: /api/orders/ORD-1001
Content-Type: application/json; charset=utf-8
{"id":"ORD-1001","customerId":"ada","status":"Pending","createdAtUtc":"2026-11-14T19:15:50.122Z","items":[{"isbn":"9780441172719","quantity":3,"unitPrice":{"amount":349.9,"currency":"TRY"},"lineTotal":{"amount":1049.7,"currency":"TRY"}},{"isbn":"9780547928227","quantity":1,"unitPrice":{"amount":289.5,"currency":"TRY"},"lineTotal":{"amount":289.5,"currency":"TRY"}}],"total":{"amount":1339.2,"currency":"TRY"}}

Önce Tahmin Et'teki sipariş, toplam 1339.2. Şimdi her şeyi yanlış olan bir body: boş müşteri, checksum'ı tutmayan bir ISBN, tireli bir ISBN, sıfır ve küsuratlı adet, bir de bilinmeyen discount alanı:

curl -s -X POST http://127.0.0.1:3000/api/orders -H "content-type: application/json" -H "x-request-id: req-12b" \
  -d '{"customerId":" ","lines":[{"isbn":"9780441172718","quantity":0},{"isbn":"978-0441172719","quantity":2.5}],"discount":50}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"instance":"/api/orders","traceId":"req-12b","errors":{"customerId":["Too small: expected string to have >=1 characters"],"lines[0].isbn":["must be a valid ISBN-13"],"lines[0].quantity":["Too small: expected number to be >=1"],"lines[1].isbn":["must be 13 digits starting with 978 or 979"],"lines[1].quantity":["Invalid input: expected int, received number"],"$":["Unrecognized key: \"discount\""]}}

Altı hatanın hepsi tek cevapta; customerId boşlukları kırpıldıktan sonra boş kaldığı için reddedildi. Tireli ISBN yalnız format hatası aldı, çünkü regex'teki abort: true checksum kontrolünü atlatıyor; o olmadan aynı alan için iki mesaj geliyordu. Diğer hata türleri:

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.21","title":"Unprocessable Content","status":422,"detail":"unknown ISBN: 9780306406157","instance":"/api/orders","traceId":"req-12c"}
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"detail":"request body is not valid JSON","instance":"/api/orders","traceId":"req-12d"}
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"instance":"/api/orders","traceId":"req-12i","errors":{"lines":["each ISBN may appear only once"]}}
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"detail":"book 9780306406157 was not found","instance":"/api/books/9780306406157","traceId":"req-12f"}

Geçerli ama katalogda olmayan bir ISBN 422 aldı: istek doğru biçimde, ama işlenemiyor. Bozuk JSON'da 11'deki V8 (Node'un JavaScript engine'i) mesajı artık client'a gitmiyor. Son olarak beklenmeyen bir hata: labs/12/bug-500.ts, createApp'e hata fırlatan bir BookService veriyor:

500 application/problem+json; charset=utf-8
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An error occurred while processing your request.","status":500,"instance":"/api/books","traceId":"req-12z"}
{"time":"2026-11-14T19:15:24.240Z","level":"error","requestId":"req-12z","msg":"unhandled error","error":"TypeError: Cannot read properties of undefined (reading 'priceMinor')\n    at Object.list (file:///home/user/bookstore-api/labs/12/bug-500.ts:8:15)\n    at file:///home/user/bookstore-api/src/handlers/booksHandlers.ts:11:34\n    ..."}

Client yalnız genel bir başlık ve traceId görüyor; stack trace log'da aynı id ile duruyor. Destek ekibine gelen bir şikâyette traceId'den log satırına tek adımda ulaşılıyor.

Sayfalama ve Filtre

Liste endpoint'i Web API serisinin "Pagination, Filtering ve Sorting" yazısındaki zarfı kullanıyor: items, page, pageSize, totalCount, totalPages, hasNextPage. Seed kataloğu 12 kitaba çıkardım:

curl -s "http://127.0.0.1:3000/api/books?page=2&pageSize=5&sort=-price"
curl -s -H "x-request-id: req-12e" "http://127.0.0.1:3000/api/books?page=0&pageSize=500&sort=year"
{"items":[{"isbn":"9780547928227","title":"The Hobbit","price":{"amount":289.5,"currency":"TRY"}},{"isbn":"9780141439563","title":"Great Expectations","price":{"amount":279,"currency":"TRY"}},{"isbn":"9780141441146","title":"Jane Eyre","price":{"amount":265,"currency":"TRY"}},{"isbn":"9780141439587","title":"Emma","price":{"amount":245,"currency":"TRY"}},{"isbn":"9780141439556","title":"Wuthering Heights","price":{"amount":239,"currency":"TRY"}}],"page":2,"pageSize":5,"totalCount":12,"totalPages":3,"hasNextPage":true}
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"instance":"/api/books?page=0&pageSize=500&sort=year","traceId":"req-12e","errors":{"page":["Too small: expected number to be >=1"],"pageSize":["Too big: expected number to be <=50"],"sort":["Invalid option: expected one of \"title\"|\"price\"|\"-price\""]}}

pageSize'ın üst sınırı önemli: sınırsız bir liste, 06'da gördüğümüz büyük JSON.stringify maliyetine ve kolay bir DoS (Denial of Service; servisi meşgul edip kullanılamaz hâle getirme) yoluna kapı açar. sort'un bir enum olması da 13'te SQL'e geçtiğimizde kolon adını kullanıcı girdisinden hiç üretmememizi sağlayacak.

OpenAPI ve Versiyonlama

zod 4 bir şemayı JSON Schema'ya çevirebiliyor; OpenAPI dokümanının ham maddesi bu:

import { z } from "zod";
import { placeOrderBody } from "#schemas/orders";

// zod 4 bir şemayı JSON Schema olarak dışarı verebilir; OpenAPI dokümanının ham maddesi
console.log(JSON.stringify(z.toJSONSchema(placeOrderBody), null, 2));
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "customerId": {
      "type": "string",
      "minLength": 1,
      "maxLength": 64
    },
    "lines": {
      "minItems": 1,
      "maxItems": 50,
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "isbn": {
            "type": "string",
            "pattern": "^97[89]\\d{10}$"
          },
          "quantity": {
            "type": "integer",
            "minimum": 1,
            "maximum": 20
          }
        },
        "required": [
          "isbn",
          "quantity"
        ],
        "additionalProperties": false
      }
    }
  },
  "required": [
    "customerId",
    "lines"
  ],
  "additionalProperties": false
}

Dikkat: refine ile yazdığımız kurallar (ISBN checksum'ı, tekrar eden ISBN) JSON Schema'da görünmüyor; yalnız regex, sınırlar ve strictObject'in additionalProperties: false'u geçiyor. ISBN'e regex'i bu yüzden ayrıca ekledik. Bu şemalardan tam bir OpenAPI dokümanı üreten kütüphaneler var; seride ayrıntısına girmiyoruz. Swagger/OpenAPI tarafının .NET karşılığı ve API versiyonlama stratejileri (URL, header, query string) Web API serisinde anlatıldı; Express'te en sade yolu app.use("/api/v2/orders", ordersRouterV2) gibi ayrı bir router bağlamak.

Üretimde Dikkat

Parayı float ile hesaplama. Tam sayı minor unit kullan; ondalık sayı yalnız sınırda üretilsin. Oranlı hesaplarda yuvarlama kuralını açıkça yaz ve test et.

Fiyatı client'tan alma. Sipariş request'i yalnız ISBN ve adet taşır; fiyat sunucuda katalogdan gelir. Aksi hâlde biri 1 kuruşa kitap alır.

Hata cevaplarında iç ayrıntı verme. Stack trace, SQL mesajı ve parser mesajı log'a, client'a yalnız Problem Details ve traceId.

Her liste endpoint'ine sınır koy. Sayfa boyutu üst sınırı, filtre alanlarının uzunluk sınırı ve izinli sıralama alanları şemada dursun.

Sık Düşülen Tuzaklar

Doğrulanmamış req.body'yi service'e geçirmek. TypeScript tipi runtime'da hiçbir şeyi kontrol etmez; req.body her zaman unknown gibi ele alınmalı.

Stack trace'i client'a sızdırmak. Bir hata middleware'i olmadan Express'in varsayılan sayfası development modunda stack trace gösterir (11).

Şemayı ve tipi ayrı ayrı yazmak. Biri değişip diğeri unutulur; z.infer ile tipi şemadan türet.

z.object'in fazla alanları sessizce atmasını unutmak. Varsayılan davranış bilinmeyen alanları silmek; client'ın yanlış alan adı gönderdiğini fark etmek istiyorsan z.strictObject kullan.

0.1 + 0.2'yi toFixed(2) ile "düzeltmek". Görüntüyü düzeltir, toplamdaki hatayı düzeltmez.

Küçük Bir Deneme

★☆☆ parseMinorUnits'i "1.234,50" (binlik ayraçlı Türkçe yazım) ile çağır. Ne oluyor? Desteklemek istersen regex'i nasıl değiştirirsin, hangi yeni belirsizlik doğar?

★★☆ Siparişe %10 indirim kuralı ekle: 20 ve üzeri adette kurumsal indirim (Kurumsal Sipariş). İndirimi satır bazında mı yoksa toplamda mı yuvarlarsın? İki yolu da kuruşla hesapla ve 3 × 349,90 için farkı göster.

★★★ GET /api/books'a minPrice ve maxPrice query parametrelerini ekle. Client tutarı lira cinsinden gönderiyor ("199.90"); şemada bunu parseMinorUnits ile kuruşa çeviren bir z.string().transform(...) yaz ve minPrice > maxPrice durumunu tek bir hata olarak döndür.

API'nin şekli artık yerinde, ama veri hâlâ bellekte. Bir sonraki yazıda repository'leri SQL Server'daki BookstoreDb'ye bağlıyoruz: mssql paketi, connection pool, parameterized query, transaction ve decimal(10,2) ile kuruş arasındaki dönüşüm.