Documentation Index
Fetch the complete documentation index at: https://mintlify.com/astrxnomo/shop-microservers/llms.txt
Use this file to discover all available pages before exploring further.
All four backend services in Shop Microservers — auth, catalog, cart, and orders — follow the same set of conventions to ensure consistent behaviour, predictable API contracts, and straightforward debugging. These patterns are not enforced by a shared library; instead, each service carries its own copy of the shared utilities (response.ts, AppError.ts, errorHandler.ts). Understanding these conventions is essential before adding a new route, service, or client call.
Response Shape
Every route in every service returns the same JSON envelope regardless of whether the request succeeded or failed. This consistency means the frontend and any inter-service client can always parse the same structure.
{ success: boolean; data: T | null; error: string | null }
The respond() helper function in each service’s src/lib/response.ts is the single place that constructs this envelope. All route handlers and the error handler call respond() directly — no service constructs a response object by hand.
import { Response } from "express";
export function respond(
res: Response,
status: number,
data: unknown,
error?: string,
) {
res.status(status).json({
success: !error,
data: error ? null : data,
error: error ?? null,
});
}
Success response — pass data, omit error:
{
"success": true,
"data": { "id": "clxyz...", "name": "Auriculares Inalámbricos Pro", "price": 89.99 },
"error": null
}
Error response — pass an error string, data is automatically set to null:
{
"success": false,
"data": null,
"error": "Product not found"
}
Error Handling
Structured errors are represented by the AppError class, which extends the native Error with an HTTP status code. Throwing an AppError anywhere in service logic (controllers, services, or clients) is the canonical way to signal a known failure condition.
export class AppError extends Error {
constructor(
public override message: string,
public status: number,
) {
super(message);
this.name = "AppError";
}
}
Usage example:
import { AppError } from "../lib/AppError";
// Throw from a service or controller — the error handler catches it
if (!product) {
throw new AppError("Product not found", 404);
}
The errorHandler Express middleware is registered as the last middleware in every service’s app.ts. It intercepts all errors passed to next() or thrown in async route handlers, determines the correct HTTP status, and returns a standard response.
import { NextFunction, Request, Response } from "express";
import { AppError } from "../lib/AppError";
import { respond } from "../lib/response";
export function errorHandler(
err: Error,
_req: Request,
res: Response,
_next: NextFunction,
) {
const status = err instanceof AppError ? err.status : 500;
if (status >= 500) console.error(err);
respond(
res,
status,
null,
status < 500 ? err.message : "Internal server error",
);
}
Errors with a status of 500 or above are logged to console.error and the client receives the generic message "Internal server error" — not the raw error message. This prevents leaking internal details. Errors with a status below 500 (e.g. 400, 404) have their message forwarded to the client as-is.
JWT Middleware
The cart and orders services protect all routes with JWT authentication. The requireAuth middleware reads the Authorization header, verifies the token against JWT_SECRET, and attaches the userId (from the sub claim) to the request object for use in downstream handlers.
import jwt from "jsonwebtoken";
import { NextFunction, Request, Response } from "express";
import { config } from "../config";
export interface AuthRequest extends Request {
userId?: string;
}
export function requireAuth(
req: AuthRequest,
res: Response,
next: NextFunction,
) {
const header = req.headers.authorization;
if (!header?.startsWith("Bearer ")) {
res.status(401).json({
success: false,
data: null,
error: "Unauthorized",
});
return;
}
try {
const payload = jwt.verify(header.slice(7), config.jwtSecret) as {
sub: string;
};
req.userId = payload.sub;
next();
} catch {
res.status(401).json({
success: false,
data: null,
error: "Invalid token",
});
}
}
JWT_SECRET must be the same value across all services that sign or verify tokens. In Docker Compose this is set via the JWT_SECRET environment variable defined in .env. When running services locally, export the same value in every terminal session.
All services use Zod for request body validation. Schemas live in src/schemas/ and are named after the resource they validate. Every route that accepts a request body calls Schema.safeParse(req.body) before touching any service logic.
import { z } from "zod";
export const AddItemSchema = z.object({
productId: z.string(),
name: z.string(),
price: z.number().positive(),
imageUrl: z.string().url(),
quantity: z.number().int().positive().default(1),
});
export type AddItemInput = z.infer<typeof AddItemSchema>;
A typical route handler uses the pattern:
const result = AddItemSchema.safeParse(req.body);
if (!result.success) {
return respond(res, 400, null, result.error.errors[0].message);
}
// result.data is now fully typed and validated
Export a TypeScript type from every schema using z.infer<typeof Schema>. This gives you end-to-end type safety from the HTTP boundary all the way through the service layer without duplicating type definitions.
Inter-Service Communication
The orders service is the only service that calls other services. It communicates with catalog (to decrement stock) and cart (to read and then clear the user’s cart) over the internal Docker shop network using Docker’s built-in service name DNS.
All inter-service calls are wrapped in dedicated client modules under src/clients/. These clients propagate the user’s JWT by forwarding the Authorization header, so the target service can authenticate the request normally.
catalog.client.ts — decrements a product’s stock at checkout:
import { config } from "../config";
import { AppError } from "../lib/AppError";
export async function decrementStock(
productId: string,
quantity: number,
): Promise<void> {
const res = await fetch(
`${config.catalogUrl}/products/${productId}/stock`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ decrement: quantity }),
},
);
if (!res.ok) throw new AppError(`Failed to decrement stock for ${productId}`, 502);
}
cart.client.ts — fetches and clears the user’s cart:
import { config } from "../config";
import { AppError } from "../lib/AppError";
export type CartItem = {
productId: string;
name: string;
price: number;
quantity: number;
imageUrl: string;
};
export type CartData = { items: CartItem[]; total: number };
type ApiResponse<T> = {
success: boolean;
data: T | null;
error: string | null;
};
export async function fetchCart(token: string): Promise<CartData> {
const res = await fetch(`${config.cartUrl}/`, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new AppError("Failed to fetch cart", 502);
const payload = (await res.json()) as ApiResponse<CartData>;
if (!payload.success || !payload.data) {
throw new AppError(payload.error ?? "Invalid cart response", 502);
}
return payload.data;
}
export async function clearCart(token: string): Promise<void> {
const res = await fetch(`${config.cartUrl}/`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new AppError("Failed to clear cart", 502);
}
The frontend never calls individual services directly. All HTTP traffic from the browser flows through the Nginx gateway at port 80. The gateway routes /api/auth/, /api/catalog/, /api/cart/, and /api/orders/ to the appropriate internal service. Bypassing the gateway would skip rate limiting, routing, and any future cross-cutting middleware added at the gateway level.
Health Checks
Every service exposes a GET /health endpoint that returns HTTP 200 with no body. Docker Compose uses these endpoints in its healthcheck configuration to determine when a service is ready to accept traffic before dependent services start.
app.get("/health", (_req, res) => {
res.sendStatus(200);
});
When writing a new service or adding a new container to the Compose file, always include a /health route and a corresponding healthcheck block so that the gateway and dependent services wait for readiness before routing traffic.