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”).
When a client requests /api/products/:id, there are three meaningful outcomes:
-
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).
-
The ID is valid, but no product exists → return 404 The request is well-formed, but the resource doesn’t exist.
-
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.
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)fromsrc/lib/http/validation. This makes “bad IDs” a client error, not a missing resource. - Returning a
400early prevents wasted work and avoids confusing clients. If a client sends , that’s not “not found”—it’s “your request format is wrong.”
Once the ID is known-good, we delegate to the service layer and map its result to the correct HTTP response.
getProductService(id)returns aServiceResult<Product>(anok(...)orfail(...)result), instead of throwing for normal “not found” cases.- If
product.okis false, the service has already decided the correct domain-level error (for exampleNOT_FOUNDwithhttpStatus: 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 thedata. The status defaults to200, which is exactly what we want for a successful GET. - The
try/catchis a final safety net: unexpected exceptions become a structuredINTERNAL_ERRORwith a500, rather than crashing the route handler.
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)(fromsrc/lib/repositories/productsRepo) is the storage operation: it either returns aProductornull/undefinedif 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.
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.
In this lesson, you added a single-resource endpoint for products:
GET /api/products/:idis implemented insrc/app/api/products/[id]/route.ts.- The route extracts
idviaawait context.params, validates it withisUUID, 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
getProductServicetranslates repository results into clear outcomes usingok(...)/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.
