Products API: Fetching a Product by ID

Welcome back! 👋 In the last lesson, you implemented the products collection endpoint: GET /api/products for listing/searching with pagination, and POST /api/products for creating products with validation and conflict handling.

Now we’ll add the next building block: fetching one specific product. This is the endpoint a product details page depends on—when a user clicks a product card, the client needs a reliable way to load that single product record.

Previously…

Previously, you built the collection-level route in src/app/api/products/route.ts, keeping the route handler thin and delegating rules and database work to the service layer. That same structure continues here: the route handler focuses on HTTP concerns (like validating the path parameter), while the service layer focuses on business outcomes (like “product exists” vs “not found”).

The contract of GET /api/products/:id

When a client requests /api/products/:id, there are three meaningful outcomes:

  1. The ID is not a valid UUID → return 400 The request is malformed, so we reject it immediately (and we don’t hit the database).

  2. The ID is valid, but no product exists → return 404 The request is well-formed, but the resource doesn’t exist.

  3. The ID is valid and the product exists → return 200 with the product The happy path.

This endpoint is simple on the surface, but it’s where you learn to be precise about the difference between invalid input and missing data.

Dynamic route handler: src/app/api/products/[id]/route.ts

This file exists under products/[id] because [id] is a dynamic route segment in Next.js. Next.js provides the id value through context.params, and in this project it’s typed as a Promise<{ id: string }>—so we await it inside the handler.

Reading the route param and validating the UUID:

This first portion of the handler pulls id from context.params and rejects malformed UUIDs with a 400.

  • const { id } = await context.params; is the official Next.js pattern this project uses for dynamic route params. It keeps param handling explicit and avoids URL string parsing.
  • We validate the param with isUUID(id) from src/lib/http/validation. This makes “bad IDs” a client error, not a missing resource.
  • Returning a 400 early prevents wasted work and avoids confusing clients. If a client sends , that’s not “not found”—it’s “your request format is wrong.”
Calling the service and handling “not found” vs success

Once the ID is known-good, we delegate to the service layer and map its result to the correct HTTP response.

  • getProductService(id) returns a ServiceResult<Product> (an ok(...) or fail(...) result), instead of throwing for normal “not found” cases.
  • If product.ok is false, the service has already decided the correct domain-level error (for example NOT_FOUND with httpStatus: 404). The route simply forwards that structured error into the API response envelope.
  • On success, success(product.value) returns a consistent JSON response with the product as the data. The status defaults to 200, which is exactly what we want for a successful GET.
  • The try/catch is a final safety net: unexpected exceptions become a structured INTERNAL_ERROR with a 500, rather than crashing the route handler.
Service logic: getProductService in src/lib/services/productsService.ts

The route handler depends on one key service function: getProductService. This service encapsulates the “fetch a product” intent and turns repository outcomes into clear API-level meaning.

Turning repository results into a clean outcome:

  • getProductById(id) (from src/lib/repositories/productsRepo) is the storage operation: it either returns a Product or null/undefined if no row matches.
  • The service translates “no row returned” into a structured failure: { code: 'NOT_FOUND', message: 'Product not found', httpStatus: 404 }. This is how the service layer expresses “missing resource” without mixing in HTTP response construction.
  • Returning ok(product) keeps the happy path explicit and consistent with the rest of the service layer (listProducts, createProductService, etc.).
  • If something truly unexpected happens (database issue, runtime error), handleException(e) converts it into a standard failure shape (including Postgres error mapping where applicable). This ensures callers aren’t forced to catch raw exceptions for normal API behavior.
Why validate the ID in the route instead of the service?

In this project, ID format validation happens in the route handler:

  • The route is the boundary where untrusted HTTP input enters your system, so validating path params here prevents invalid data from ever reaching deeper layers.
  • It also lets you return a clear 400 VALIDATION_ERROR without involving any repository/database calls.
  • The service can then assume its inputs are already well-formed and focus on business outcomes like “exists vs not found.”

This keeps responsibilities clean: routes validate and translate HTTP, services define domain outcomes, repositories handle storage.

Recap

In this lesson, you added a single-resource endpoint for products:

  • GET /api/products/:id is implemented in src/app/api/products/[id]/route.ts.
  • The route extracts id via await context.params, validates it with isUUID, and rejects bad IDs with a 400.
  • The route delegates to getProductService(id) and forwards service failures like 404 NOT_FOUND into the standard error envelope.
  • The service getProductService translates repository results into clear outcomes using ok(...) / fail(...).

Next up, you’ll build on this same pattern for updates—where you’ll validate input, apply patches 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