Lesson: Validating Domain Models and Query Parameters

Welcome back 👋 In the previous lesson, you laid the groundwork for a consistent backend by defining standard success and error envelopes and enforcing them across all API routes. Every endpoint now speaks the same language, whether it succeeds or fails.

In this lesson, we build directly on that foundation. You’ll learn how to validate incoming data early, define clear domain models and DTOs, and safely handle common inputs like query parameters. By the end, your /api/products endpoint will still return an empty list—but it will do so only after carefully validating pagination and search inputs, using reusable validation helpers and strongly typed models.

Previously: Establishing a Consistent API Contract

Previously, you focused on how the API responds rather than what it returns. You introduced:

  • A shared ApiSuccess / ApiError contract
  • Centralized success() and error() helpers
  • Predictable response envelopes for all routes, including stubbed ones

That work matters here because validation failures are just another kind of response. Thanks to that foundation, invalid input can now be rejected cleanly and consistently, without special cases or ad-hoc JSON.

Why Validation and Domain Models Matter

As this backend grows, it will support products, shopping carts, orders, and state transitions like paying or canceling an order. Each step introduces more data, more relationships, and more opportunities for invalid input.

Validation is how you protect the system’s core rules:

  • Query parameters must be well-formed and bounded.
  • Identifiers must look like real IDs.
  • Domain entities must have a single, authoritative shape.
  • Clients must only be allowed to send what they’re responsible for.

This lesson introduces the building blocks that make those guarantees possible.

The Product Domain Model: The Backend’s Source of Truth

A backend needs a canonical definition of what a “product” is. This definition lives in src/lib/types/domain.ts and represents the shape the backend itself trusts—typically mirroring database rows.

Product domain type:

This file defines the authoritative product model used throughout the backend.

  • This interface represents a complete product, including system-managed fields like id, timestamps, and status.
  • Monetary values are stored as integers in cents (price_cents) to avoid floating-point rounding issues.
  • Currency and ProductStatus are constrained unions, which prevents invalid values from entering the system.
  • This type is what the backend and database agree on—it is not what clients are allowed to send directly.
DTOs: Separating Client Input from Domain Truth

Clients should never control the entire domain model. For example, they shouldn’t set id, timestamps, or internal lifecycle states when creating a product.

That’s why this project uses DTOs (Data Transfer Objects), defined in src/lib/types/dto.ts.

Product input DTOs:

These types describe what clients are allowed to provide.

  • CreateProductInput includes only fields a client can reasonably supply when creating a product.
  • System-owned fields (id, timestamps, default status) are intentionally excluded.
  • UpdateProductInput makes all fields optional, reflecting the reality of partial updates.
  • This separation keeps your domain model clean and prevents accidental or malicious overwrites.

Later, these DTOs will be validated against incoming JSON bodies. For now, they establish a clear boundary between external input and internal truth.

Validation Utilities: Small, Reusable Guards

Rather than scattering validation logic throughout route handlers, this project centralizes it in src/lib/http/validation.ts. These helpers answer simple questions like “is this a UUID?” or “is this an integer within bounds?”—and they do so consistently.

UUID validation:

  • This helper checks whether a value looks like a UUID in canonical form.
  • It is intentionally lenient about UUID version/variant to avoid false negatives.
  • The function safely normalizes unknown inputs (including arrays) before checking.
String, integer, and enum guards
  • isString is ideal for validating query parameters like search terms, with optional length constraints.
  • isInt is designed for pagination and counters, with optional minimum and maximum bounds.
  • isEnum ensures a string matches one of a fixed set of allowed values, which is useful for fields like status or currency.
  • These helpers are small by design, making them easy to compose and reuse.
Parsing Optional Integer Query Parameters Safely

Pagination parameters deserve special care: they are optional, arrive as strings, and must fall within safe bounds.

That’s what parseOptionalIntParam() is for.

  • This helper handles the full lifecycle of an optional integer query param.
  • Missing parameters are treated as valid and return undefined.
  • Invalid, empty, or out-of-range values return clear, human-readable error messages.
  • The result-based API (ok: true | false) forces callers to handle validation explicitly.
Validating Query Parameters in /api/products

Now let’s see these pieces in action. In Unit 2, the products endpoint still does not talk to the database—but it does validate its inputs thoroughly.

This logic lives in app/routes/api.products.ts.

GET /api/products with validation:

  • Query parameters are read using URL.searchParams, which is the standard pattern in Remix loaders.
  • query is optional, but if present it must be a string of at most 100 characters.
  • page and pageSize are parsed and validated using parseOptionalIntParam, enforcing sensible bounds.
Recap

In this lesson, you strengthened the backend by validating inputs and defining clear data boundaries:

  • You introduced domain models as the backend’s source of truth.
  • You separated DTOs to control what clients are allowed to send.
  • You used reusable validation helpers for UUIDs, strings, integers, and enums.
  • You validated query parameters with clear bounds and error messages.
  • You returned a typed Product[] success envelope—even without a database.

With these patterns in place, the next step is straightforward: wiring PostgreSQL into the products endpoint without sacrificing safety, clarity, or consistency.

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal