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”).
When a client requests /api/products/:id, there are only a few meaningful outcomes, and each one matters:
-
Invalid ID format → 400 (VALIDATION_ERROR) The request itself is malformed. We should reject it quickly without touching the database.
-
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.
-
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.
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.idfrom 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
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.idis 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
?? ''ensuresidis always a string when passed intoisUUID. This prevents accidentalundefinedvalues from slipping deeper into the system and creating confusing errors later. isUUID(id)is a lightweight guard fromsrc/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.
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 aServiceResult<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.okis false, the loader doesn’t invent HTTP behavior. It simply forwards the service’scode,message, optionaldetails, andhttpStatusinto the standarderror(...)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.
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_FOUNDresult (not an exception) - convert unexpected exceptions into a stable failure shape via
handleException
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 withcode: 'NOT_FOUND'andhttpStatus: 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 likelistProductsandcreateProductService. - The
catchblock funnels unexpected problems throughhandleException(e), which is the project-wide pattern for translating exceptions (including Postgres errors) into structured failures instead of leaking raw thrown values.
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
mapProductRowso the rest of the app deals with consistentProductobjects
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 asnull, not an exception. That makes “not found” a normal, expected outcome. rows[0] ? ... : nullmakes 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 sameid.- The repository does not decide HTTP status codes. It only answers the storage question and returns either a mapped
Productornull.
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).
mapProductRowensures the rest of your backend always works with theProductdomain model (fromsrc/lib/types/domain.ts) rather than raw database row shapes.- Currency and status are normalized through
normalizeCurrencyandnormalizeProductStatus. 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.
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.
In this lesson, you added the “fetch one product” endpoint:
- The dynamic route file
app/routes/api.products.$id.tsimplementsGET /api/products/:id. - The loader extracts
params.id, validates it withisUUID, and returns400 VALIDATION_ERRORfor malformed IDs before any database work happens. - The loader delegates to
getProductService, then forwardsServiceResultfailures (like404 NOT_FOUND) into consistenterror(...)envelopes. - The service uses the repository’s
getProductByIdresult 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.
