Why another e-commerce backend?
Most "starter" e-commerce APIs treat error handling and auth as an afterthought — a generic try/catch that logs a stack trace and returns { message: "Something went wrong" }. That's fine until you're debugging a production incident at 2 a.m. and every failure looks identical. Shopmate is our team's answer to that: a modular TypeScript backend for PostgreSQL where every failure mode — a duplicate email, a broken foreign key, a blocked user, a bad JWT — resolves to a distinct, typed, machine-readable error before it ever reaches a client.
It's not a toy CRUD demo. It's the foundation we'd actually want under a real storefront: authentication, session tracking, user/person/address modeling, and an error pipeline that turns Prisma's internals into API contracts instead of leaking them.
Stack and why each piece is there
| Layer | Choice | Reason |
|---|---|---|
| Language | TypeScript | Compile-time safety across the whole request lifecycle, from Zod-validated input to Prisma-typed output |
| Framework | Express 5 | Minimal, well-understood, easy to reason about middleware ordering |
| ORM | Prisma + PostgreSQL | Type-safe queries, migration history as code, and structured error codes the team could pattern-match on |
| Auth | JWT (RS256) + argon2 | Asymmetric signing so token verification never needs the private key; argon2 over bcrypt for its memory-hardness against GPU cracking |
| Validation | Zod | Schema-driven request validation with predictable, structured error output |
| Env safety | envalid | Fails fast at boot if required secrets/config are missing, instead of failing mysteriously at request time |
| Logging | Pino | Structured JSON logs suitable for shipping to a log aggregator, not just console noise |
The error pipeline is the actual architecture
The most deliberate design decision in this codebase isn't a route or a model — it's CustomError and the two-stage handler that processes it.
Every operational failure in the app is thrown as a CustomError carrying a status code, an internal ErrorCode, a human description, and a data payload describing what failed and where:
throw new CustomError({
code: ErrorCodes.ItemExists,
status: StatusCodes.CONFLICT,
description: `No Duplicate Allowed`,
data: {
path: [error?.meta?.modelName, error?.meta?.target],
message: `${error?.meta?.modelName} with same ${error?.meta?.target} Already Exists`
}
});
That snippet isn't hand-written per route — it's generated automatically. DetermineErrorType intercepts every thrown error before it reaches the client and checks: is this already a CustomError? If not, is it a Prisma.PrismaClientKnownRequestError? If so, prismaErrorType maps the raw Prisma error code (P2002, P2003, P2025, P2034, …) to a typed CustomError with the right HTTP status and a description that doesn't require the client to know what "P2002" means:
if (error?.code === 'P2002') {
return new CustomError({
code: ErrorCodes.ItemExists,
status: StatusCodes.CONFLICT,
description: `No Duplicate Allowed`,
data: { path: [error?.meta?.modelName, error?.meta?.target], ... }
});
}
A unique-constraint violation becomes 409 Conflict. A broken foreign key becomes 400 Bad Request with the offending field named. A deadlock becomes 409 with an explicit "try again" message instead of a raw Postgres error string bubbling up. This mapping currently covers nine distinct Prisma error codes, each with its own HTTP semantics — the kind of detail that's tedious to write once and invaluable every time a client integration breaks in a new way.
The second stage, ErrorHandler, decides what the client sees versus what gets logged. It distinguishes "trusted" errors (isOperational: true — expected failures like validation or auth) from untrusted ones (unexpected exceptions), logs both, but only exposes trusted error details in the response body. Everything else collapses to a generic 500 so internal implementation details never leak:
private static isTrustedError(error: Error): boolean {
if (error instanceof CustomError) {
return error.isOperational;
}
return false;
}
Every response — success or failure — goes through one responseObject() envelope, so success, data, and errors are always present and consistently shaped. No endpoint gets to invent its own response format.
Auth: RS256 over shared-secret HMAC
Shopmate signs access and refresh tokens with RS256, not the more common HS256. That means a private key signs tokens and a separate public key verifies them. It's more setup — you generate and rotate an RSA key pair instead of one shared secret — but it buys a real architectural property: any service that only needs to verify tokens (an API gateway, a downstream microservice) can hold the public key without ever being able to mint tokens. For a backend meant to grow into a multi-service system, that separation is worth the extra .env entries.
function getToken(payload: object, keyName: IJwtPrivateKey, minutes?: number) {
const options: jwt.SignOptions = { algorithm: 'RS256' };
if (minutes) options.expiresIn = minutes;
return jwt.sign(payload, getKey(keyName), options);
}
Access and refresh tokens use separate key pairs entirely, so a compromised access-token key doesn't compromise refresh-token issuance. Passwords are hashed with argon2 rather than bcrypt — argon2's memory-hard KDF is deliberately expensive to parallelize on GPUs, which matters more than bcrypt's raw compute cost once an attacker has an offline copy of the password table.
The token middleware is intentionally permissive at the edge and strict at the boundary: tokenHandler decodes whatever bearer token is present and attaches userId/jwt to the request if valid, but never blocks the request itself. A separate tokenRequired middleware is composed on top of it per-route:
export function tokenHandler(req, res, next) {
if (req?.headers?.authorization) {
const accessToken = (req.headers.authorization || '').replace(/^Bearer\s/i, '');
const decoded = authServices.verifyToken<IJwtPayload>(accessToken, 'access_token_public_key');
if (decoded) { req.userId = decoded.sub; req.jwt = decoded; }
}
return next();
}
That split means public endpoints can still read req.userId when a token happens to be present (useful for "personalize if logged in, work anyway if not") without every route needing its own auth branching logic.
Data model: relational, not embedded
User, Person, Address, and Session are modeled as proper relational tables rather than nesting profile data as JSON on the user row. User stays lean — credentials and account-state flags (isActive, isVarified, isBlocked) only — while Person owns the human-facing profile and Address hangs off Person as an optional one-to-one. Every entity carries a slug (UUID) alongside its numeric id: internal joins and indexes use the fast integer PK, but nothing about a user's identity is ever exposed to the outside world as a sequential integer that reveals signup order or total user count.
User (credentials, account flags)
└── Person (profile: name, phone)
└── Address (street, city, postal code)
└── Session[] (one-to-many, active/blocked flags)
Sessions are tracked as first-class rows rather than trusted purely from a refresh-token's signature — isActive and isBlocked flags on Session mean a compromised session can be revoked server-side even though the JWT itself is stateless and would otherwise verify successfully until expiry.
Module structure
Each domain (authentication, user, person, address, health) follows the same controller → service → model layering, with path aliases (@modules, @errors, @config, @utils) configured via module-alias so imports read as intent rather than relative-path archaeology (../../../errors/CustomError becomes @errors/CustomError). Request validation lives in Zod schemas under requestSchemaValidator/, decoupled from route handlers, so a schema can be unit-tested or reused across a create/update pair without duplicating logic.
What this project demonstrates
Shopmate isn't trying to impress with novel algorithms — it's a demonstration of what our team actually prioritizes when owning a backend end-to-end: errors that are typed and specific instead of stringly-typed and generic, an auth model that separates signing from verification for future service boundaries, environment validation that fails at boot instead of at 3 a.m. in production, and a data model that keeps identity, profile, and location as separate, independently-evolvable concerns. It's the kind of foundation that makes the next feature cheap to add, because the plumbing — validation, error shape, auth, logging — was solved once, correctly, instead of copy-pasted per route.
