Lesson: Fetching Specific Products

Welcome back! 👋 Up to now, you’ve built the products collection endpoint: GET /api/products for listing/search with pagination, and POST /api/products for creating products with careful validation and conflict handling.

In this lesson, we add the next essential building block: fetching one specific product by ID. This is the endpoint a product details page relies on—when someone clicks a product in a catalog view, the client needs a precise, predictable way to fetch that single record. Even though this endpoint is “small,” it forces you to be very clear about input validation, not-found semantics, and consistent envelopes across every response.

Previously…

Previously, you implemented app/routes/api.products.ts, where the route validates query parameters, delegates real work to the service layer, and returns a consistent success(...) or error(...) envelope. That same architecture continues here: the route module is still a thin HTTP boundary, and the service layer is where “domain meaning” lives (like “this product doesn’t exist”).

The Contract of GET /api/products/:id

When a client requests /api/products/:id, there are only a few meaningful outcomes, and each one matters:

  1. Invalid ID format → 400 (VALIDATION_ERROR) The request itself is malformed. We should reject it quickly without touching the database.

  2. Valid ID format, but no product exists → 404 (NOT_FOUND) The request is well-formed, but the resource doesn’t exist. This is not the client “formatting something wrong”—it’s simply missing data.

  3. Valid ID and product exists → 200 (success envelope with the Product) The happy path: return the product in the same consistent response envelope used everywhere else.

This endpoint is where you learn to clearly separate bad input from missing data—and that distinction will matter even more when you build updates, deletes/archives, carts, and orders later.

Dynamic Product Route: app/routes/api.products.$id.ts

In Remix, a file name like api.products.$id.ts creates a dynamic segment, so /api/products/<something> becomes /api/products/:id. Remix passes the value of that dynamic segment through the params object inside your loader arguments.

This route does three key things in order:

  • reads params.id from Remix
  • validates that the ID is a UUID before doing any work
  • calls the service layer to fetch the product, then returns either a success or error envelope
Reading and validating the id path parameter

This first part of the loader lives in app/routes/api.products.$id.ts. It’s focused purely on HTTP boundary concerns: extracting input from the URL and rejecting requests that are malformed.

  • params.id is provided by Remix because the file name includes $id. This avoids manual URL parsing and keeps “where the input came from” explicit and easy to reason about.
  • The fallback ?? '' ensures id is always a string when passed into isUUID. This prevents accidental undefined values from slipping deeper into the system and creating confusing errors later.
  • isUUID(id) is a lightweight guard from src/lib/http/validation.ts. Validating at the route boundary is important because it prevents wasted database work and makes the error meaning crystal clear.
  • Returning error('VALIDATION_ERROR', 'Invalid id', ..., 400) tells clients “you formatted the URL incorrectly.” That’s a different class of problem than “the product doesn’t exist,” and clients (and debugging tools) benefit a lot from that clarity.
Calling the service and forwarding structured outcomes

Once the ID is confirmed valid, we delegate to the service layer. This section is still in app/routes/api.products.$id.ts, and it demonstrates a key pattern in this codebase: services return ServiceResult objects, and routes translate them into envelopes.

  • getProductService(id) returns a ServiceResult<Product>, which means the “normal” not-found case is not handled by throwing—it’s handled by a structured { ok: false, error: ... } result.
  • When product.ok is false, the loader doesn’t invent HTTP behavior. It simply forwards the service’s code, message, optional details, and httpStatus into the standard error(...) response helper.
  • On success, success(product.value) returns { data: <Product>, meta: { timestamp } } automatically. This keeps the response shape consistent with and the rest of the API.
Service Logic: getProductService in src/lib/services/productsService.ts

The route’s job is intentionally limited. The service layer is where we translate repository results into meaningful domain outcomes—especially “not found.”

In this project, services typically:

  • call the repository to talk to storage
  • convert “missing row” into a NOT_FOUND result (not an exception)
  • convert unexpected exceptions into a stable failure shape via handleException
Turning repository results into a clean outcome

This code lives in src/lib/services/productsService.ts. It handles the core business meaning of “fetch a product.”

  • getProductById(id) is the repository call that actually hits the database. Repositories answer storage questions like “is there a row with this ID?” without deciding HTTP semantics.
  • If no row exists, the service returns a structured fail(...) result with code: 'NOT_FOUND' and httpStatus: 404. This is how the service communicates “missing resource” in a stable, reusable way.
  • Returning ok(product) makes the success path explicit and consistent with other services like listProducts and createProductService.
  • The catch block funnels unexpected problems through handleException(e), which is the project-wide pattern for translating exceptions (including Postgres errors) into structured failures instead of leaking raw thrown values.
Repository Logic: `getProductById` and row mapping in `src/lib/repositories/productsRepo.ts`

Even though this lesson focuses on “fetch one product,” it’s important to see what the repository does: it executes SQL and converts raw database rows into the Product domain model.

Two key ideas matter here:

  • the query is parameterized ($1) to keep it safe
  • the result is mapped through mapProductRow so the rest of the app deals with consistent Product objects
Fetching a row by ID

This code lives in src/lib/repositories/productsRepo.ts. It’s intentionally small and focused: one SQL query, then a map step.

  • query<ProductRow>(...) is the database client wrapper that executes SQL and returns typed rows. Passing [id] as a parameter (instead of interpolating it into the string) prevents SQL injection and avoids quoting bugs.
  • The function returns Product | null, which is a very intentional contract: “no row found” is represented as null, not an exception. That makes “not found” a normal, expected outcome.
  • rows[0] ? ... : null makes it explicit we only expect one row for a primary key lookup. If the database is healthy, there should never be multiple matches for the same id.
  • The repository does not decide HTTP status codes. It only answers the storage question and returns either a mapped Product or null.
Mapping rows safely into the domain model

The mapping function is also in src/lib/repositories/productsRepo.ts. It bridges the database world (strings, raw columns) into your domain model (typed fields and normalized values).

  • mapProductRow ensures the rest of your backend always works with the Product domain model (from src/lib/types/domain.ts) rather than raw database row shapes.
  • Currency and status are normalized through normalizeCurrency and normalizeProductStatus. Even if the database contains unexpected values (bad seed, manual edits, older schema), your API returns something safe and predictable.
  • This mapping layer is especially valuable as the codebase grows: it centralizes “how DB rows become domain objects,” so you don’t repeat conversion logic in every endpoint.
  • Keeping mapping in the repository also prevents routes/services from needing to know about low-level database quirks or legacy values.
Why ID Validation Happens in the Route (Not the Service)

You could validate IDs in the service, but in this project we do it at the route boundary for a few reasons:

  • The route is the point where untrusted HTTP input enters your system. Validating here prevents malformed input from reaching deeper layers, which reduces the chance of confusing errors later.
  • It lets you return a clean 400 VALIDATION_ERROR without touching the database. That saves work and makes the error meaning clearer.
  • The service can then focus on domain meaning—like “does a product exist?”—without mixing in request-format concerns.

This division becomes more important as endpoints get more complex (updates, archive operations, cart actions), because you want each layer to have a clear job.

Recap

In this lesson, you added the “fetch one product” endpoint:

  • The dynamic route file app/routes/api.products.$id.ts implements GET /api/products/:id.
  • The loader extracts params.id, validates it with isUUID, and returns 400 VALIDATION_ERROR for malformed IDs before any database work happens.
  • The loader delegates to getProductService, then forwards ServiceResult failures (like 404 NOT_FOUND) into consistent error(...) envelopes.
  • The service uses the repository’s getProductById result to distinguish “missing product” (null) from “unexpected failure” (exception), keeping outcomes explicit.

Next, you’ll build on this exact pattern for update and archive operations—where you’ll validate input, apply changes safely, and keep error handling just as precise.

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