From Cart to Order

Introduction to Checkout in Our API

Checkout is where an e-commerce backend stops being “a shopping list” and starts being “a real transaction.” In this lesson we’ll implement (and understand) the exact workflow our codebase uses to convert an open cart into a pending order, while keeping the data consistent even if two requests happen at the same time.

You’ll see how the checkout logic is organized across a repository layer and a service layer, and how a Next.js route turns service results into a consistent HTTP response. By the end, you’ll know exactly where “snapshotting” happens, why we use database locks during checkout, and what rules the system enforces before it will create an order.

Snapshotting: Why Orders Copy Cart Items

A cart is editable, temporary, and “live.” An order is a historical record that should not change just because product prices or inventories change later.

In our project, snapshotting happens by copying each cart_items row into a new order_items row at the moment of checkout. The copied data includes the product_id, quantity, and the unit_price_cents that the cart item had at that moment. That gives us a reliable receipt-like record for the order.

Where the Checkout Workflow Lives

Checkout is implemented across three layers:

  • Repository layer: src/lib/repositories/ordersRepo.ts Talks to PostgreSQL, performs transactional work, and snapshots cart items into order items.
  • Service layer: src/lib/services/ordersService.ts Converts repository errors into API-friendly ServiceResults and enforces order-state rules for pay/cancel.
  • Route handler: src/app/api/carts/[id]/checkout/route.ts Validates the URL param, calls the service, and returns standardized success/error envelopes.

Let’s walk through each part, starting with the repository, because that’s where the most important checkout guarantees are enforced.

Reading Orders and Mapping Database Rows

This first part of src/lib/repositories/ordersRepo.ts defines row shapes (what Postgres returns) and maps them into our domain types (Order and OrderItem). These helpers keep the rest of the repo code clean and ensure our API deals with consistent, typed objects.

// src/lib/repositories/ordersRepo.ts
import { DbClient } from '@/lib/db/types';
import { query, withTransaction } from '@/lib/db/client';
import { Order, OrderItem } from '@/lib/types/domain';
import { randomUUID } from 'node:crypto';
import { computeTaxCents } from '@/lib/money/tax';

export interface OrderRow {
  id: string;
  cart_id: string;
  status: string;
  subtotal_cents: number;
  tax_cents: number;
  total_cents: number;
  currency: string;
  created_at: string;
  updated_at: string;
}

export interface OrderItemRow {
  id: string;
  order_id: string;
  product_id: string;
  quantity: number;
  unit_price_cents: number;
  created_at: string;
  updated_at: string;
}

function mapOrderRow(r: OrderRow): Order {
  return {
    id: r.id,
    cart_id: r.cart_id,
    status: ['pending', 'paid', 'shipped', 'cancelled'].includes(r.status) ? (r.status as Order['status']) : 'pending',
    subtotal_cents: r.subtotal_cents,
    tax_cents: r.tax_cents,
    total_cents: r.total_cents,
    currency: 'USD',
    created_at: r.created_at,
    updated_at: r.updated_at,
  };
}

function mapItemRow(r: OrderItemRow): OrderItem {
  return {
    id: r.id,
    order_id: r.order_id,
    product_id: r.product_id,
    quantity: r.quantity,
    unit_price_cents: r.unit_price_cents,
    created_at: r.created_at,
    updated_at: r.updated_at,
  };
}
  • OrderRow and OrderItemRow describe the exact columns we read from PostgreSQL. This repo works directly with SQL, so having explicit row shapes prevents accidental field mismatches and makes mapping predictable.
  • mapOrderRow() is where the DB-facing status: string becomes a safe domain value. If the status in the database isn’t one of our supported values (pending, paid, shipped, cancelled), we default to 'pending' so downstream code doesn’t crash on unknown strings.
  • mapItemRow() does the same for items, turning raw DB rows into domain OrderItem objects with consistent naming and structure.
  • You’ll notice computeTaxCents is imported here, but checkout currently sets tax to 0 later. That’s intentional in the current code: tax logic is a placeholder to be refined, so the import hints at planned evolution without changing current behavior.

Listing Orders and Fetching an Order with Its Items

Checkout Errors: Clear Failure Reasons from the Repository

Checkout can fail for multiple business reasons (cart missing, not open, empty, etc.). In this repo, those conditions are represented with a dedicated error type so the service layer can translate them into clean API errors.

// src/lib/repositories/ordersRepo.ts
interface CartSnapshotRow {
  id: string;
  status: string;
}

interface CartItemSnapshotRow {
  product_id: string;
  quantity: number;
  unit_price_cents: number;
}

interface ProductInventoryRow {
  id: string;
  currency: string;
  inventory_count: number;
}

export type CheckoutErrorCode = 'CART_NOT_FOUND' | 'CART_NOT_OPEN' | 'EMPTY_CART' | 'INSUFFICIENT_INVENTORY';

export class CheckoutError extends Error {
  constructor(public code: CheckoutErrorCode, public details?: unknown) {
    super(code);
  }
}
  • The snapshot row interfaces are “transaction-scoped shapes” for checkout queries. They’re smaller than full domain types because checkout only needs specific columns to make decisions.
  • CheckoutErrorCode lists the explicit, expected failure modes for checkout. This is important because it distinguishes “normal business failures” (like an empty cart) from “unexpected server failures.”
  • CheckoutError carries a machine-friendly code and optional details. That details field is especially useful for cases like inventory errors, where you may want to return structured info about which item failed.
  • This pattern keeps the repo focused on database correctness, while leaving the service layer responsible for turning errors into HTTP responses.

Checkout Core: Creating an Order from a Cart (Transactional + Safe)

Updating Order State in the Repository

Service Layer: Turning Repository Work into API-Friendly Results

Now let’s look at src/lib/services/ordersService.ts. This layer is responsible for two things:

  • Mapping repository errors (including CheckoutError and Postgres errors) into a consistent ServiceResult.
  • Enforcing business rules for state transitions (pay/cancel) before calling setOrderStatus.

Mapping checkout errors and exceptions

// src/lib/services/ordersService.ts
import { randomUUID } from 'node:crypto';
import { Order } from '@/lib/types/domain';
import { listOrders, getOrderById, createOrderFromCart, setOrderStatus, CheckoutError } from '@/lib/repositories/ordersRepo';
import { ServiceResult, ok, fail } from '@/lib/services/types';
import { isPostgresError, mapPostgresError } from '@/lib/db/errors';

function mapCheckoutError(e: CheckoutError): ServiceResult<never> {
  switch (e.code) {
    case 'CART_NOT_FOUND':
      return fail({ code: 'NOT_FOUND', message: 'Cart not found', httpStatus: 404 });
    case 'CART_NOT_OPEN':
      return fail({ code: 'CONFLICT', message: 'Cart is not open', httpStatus: 409 });
    case 'EMPTY_CART':
      return fail({ code: 'CONFLICT', message: 'Cart is empty', httpStatus: 409 });
    case 'INSUFFICIENT_INVENTORY':
      return fail({ code: 'CONFLICT', message: 'Insufficient inventory', details: e.details, httpStatus: 409 });
    default:
      return fail({ code: 'INTERNAL_ERROR', message: 'Checkout failed', httpStatus: 500 });
  }
}

function handleException(e: unknown): ServiceResult<never> {
  if (e instanceof CheckoutError) {
    return mapCheckoutError(e);
  }
  if (isPostgresError(e)) return fail(mapPostgresError(e));
  return fail({ code: 'INTERNAL_ERROR', message: (e as Error)?.message ?? 'Internal error', httpStatus: 500 });
}
  • mapCheckoutError() converts repo-level checkout failures into API-style errors with HTTP statuses. That’s why “empty cart” becomes a 409 Conflict instead of a 500—nothing crashed, the request just violates a business rule.
  • INSUFFICIENT_INVENTORY is mapped to a 409 with details, which is designed for richer client feedback (for example, telling the user which item caused the issue). Even though current checkout doesn’t throw this yet, the mapping ensures the API contract is ready.
  • handleException() centralizes error handling so every service function stays clean. This avoids repeating try/catch mapping logic and makes it much harder to accidentally return inconsistent error envelopes.
  • Postgres errors are detected via isPostgresError() and mapped using mapPostgresError(), which keeps database-specific failure modes out of route handlers.

Listing, fetching, and checking out:

// src/lib/services/ordersService.ts
export async function listOrdersService(params: { page?: number; pageSize?: number }): Promise<ServiceResult<Order[]>> {
  try {
    const page = params.page && params.page > 0 ? params.page : 1;
    const pageSize = params.pageSize && params.pageSize > 0 ? Math.min(params.pageSize, 100) : 20;
    const orders = await listOrders(page, pageSize);
    return ok(orders);
  } catch (e: unknown) {
    return handleException(e);
  }
}

export async function getOrderService(id: string): Promise<ServiceResult<Order>> {
  try {
    const order = await getOrderById(id);
    if (!order) return fail({ code: 'NOT_FOUND', message: 'Order not found', httpStatus: 404 });
    return ok(order);
  } catch (e: unknown) {
    return handleException(e);
  }
}

export async function checkoutService(cartId: string): Promise<ServiceResult<Order>> {
  try {
    const order = await createOrderFromCart({ orderId: randomUUID(), cartId });
    return ok(order);
  } catch (e: unknown) {
    return handleException(e);
  }
}
  • listOrdersService() normalizes pagination inputs. It defaults to page = 1 and pageSize = 20, and caps pageSize at 100 to prevent accidental “return everything” calls from overwhelming the database.
  • getOrderService() treats “not found” as a normal result (null) from the repo and turns it into a clean 404. This keeps repository code simpler and gives the API a consistent response pattern.
  • checkoutService() is intentionally thin: it generates a new orderId and delegates the heavy lifting to createOrderFromCart(). That design keeps transactions and locking logic in the repository where it belongs.

Service Layer: Enforcing State Transitions (Pay and Cancel)

Orders move through statuses, but not every transition is allowed. In this project, checkout creates a pending order, and then we can pay or cancel it depending on its current state.

// src/lib/services/ordersService.ts
export async function payOrderService(id: string): Promise<ServiceResult<Order>> {
  try {
    const order = await getOrderById(id);
    if (!order) return fail({ code: 'NOT_FOUND', message: 'Order not found', httpStatus: 404 });
    if (order.status !== 'pending') {
      return fail({ code: 'CONFLICT', message: `Cannot pay order in status ${order.status}`, httpStatus: 409 });
    }
    const updated = await setOrderStatus(id, 'paid');
    if (!updated) return fail({ code: 'NOT_FOUND', message: 'Order not found', httpStatus: 404 });
    return ok(updated);
  } catch (e: unknown) {
    return handleException(e);
  }
}

export async function cancelOrderService(id: string): Promise<ServiceResult<Order>> {
  try {
    const order = await getOrderById(id);
    if (!order) return fail({ code: 'NOT_FOUND', message: 'Order not found', httpStatus: 404 });
    if (order.status === 'shipped' || order.status === 'cancelled') {
      return fail({ code: 'CONFLICT', message: `Cannot cancel order in status ${order.status}`, httpStatus: 409 });
    }
    const updated = await setOrderStatus(id, 'cancelled');
    if (!updated) return fail({ code: 'NOT_FOUND', message: 'Order not found', httpStatus: 404 });
    return ok(updated);
  } catch (e: unknown) {
    return handleException(e);
  }
}
  • payOrderService() enforces a strict rule: only pending orders can be paid. If an order is already paid, shipped, or cancelled, paying would either be nonsensical or dangerous (double charge), so we return a 409 Conflict.
  • The service re-fetches the order first to decide whether the transition is legal. This keeps the rule in one place and avoids “blind updates” that might overwrite a newer status.
  • cancelOrderService() allows cancelling most statuses except shipped and cancelled. That means pending can be cancelled, and (as currently written) paid can also be cancelled—which may or may not match how a real store works, but it is the exact behavior enforced by these files.
  • Both services still handle the “order disappeared between reads” case by checking the result of setOrderStatus. That’s a small defensive step that keeps responses accurate even under concurrency.

Route Layer: Turning Cart Checkout into an HTTP Endpoint

Finally, the Next.js route handler exposes checkout at POST /api/carts/:id/checkout. This is implemented in src/app/api/carts/[id]/checkout/route.ts.

// src/app/api/carts/[id]/checkout/route.ts
import { NextRequest } from 'next/server';
import { success, error } from '@/lib/http/response';
import { checkoutService } from '@/lib/services/ordersService';
import { isUUID } from '@/lib/http/validation';

type RouteContext = { params: Promise<{ id: string }> };

export async function POST(_req: NextRequest, context: RouteContext) {
  try {
    const { id } = await context.params;

    if (!isUUID(id)) return error('VALIDATION_ERROR', 'Invalid id', undefined, 400);

    const result = await checkoutService(id);
    if (!result.ok) return error(result.error.code, result.error.message, result.error.details, result.error.httpStatus);

    return success(result.value, 201);
  } catch (e: unknown) {
    return error('INTERNAL_ERROR', (e as Error).message, undefined, 500);
  }
}
  • The route uses the modern Next.js pattern for dynamic route params: const { id } = await context.params;. That’s why RouteContext types params as a Promise.
  • isUUID(id) performs a fast, early guard so invalid IDs never reach the database layer. This improves error clarity and reduces unnecessary load.
  • The route delegates business logic to checkoutService, then converts the ServiceResult into a standardized HTTP response using success(...) and error(...).
  • A successful checkout returns 201 Created, which matches the fact that we created a new resource (an order). This also makes it easy for clients to distinguish “created” from a normal “fetched” response.

A quick note on /api/carts/:id GET

The cart read route also uses the same param pattern and validation, and it clarifies an important rule in the comment: POST is reserved for checkout.

// src/app/api/carts/[id]/route.ts
import { NextRequest } from 'next/server';
import { success, error } from '@/lib/http/response';
import { getCartService } from '@/lib/services/cartsService';
import { isUUID } from '@/lib/http/validation';

type RouteContext = { params: Promise<{ id: string }> };

export async function GET(_req: NextRequest, context: RouteContext) {
  try {
    const { id } = await context.params;

    if (!isUUID(id)) return error('VALIDATION_ERROR', 'Invalid id', undefined, 400);

    const cart = await getCartService(id);
    if (!cart.ok) return error(cart.error.code, cart.error.message, cart.error.details, cart.error.httpStatus);

    return success(cart.value);
  } catch (e: unknown) {
    return error('INTERNAL_ERROR', (e as Error).message, undefined, 500);
  }
}

// POST is reserved for /api/carts/:id/checkout
  • The cart route mirrors the same shape as checkout: validate ID, call a service, map ServiceResult to a response. That consistency is intentional—it makes the API predictable for learners and for clients.
  • The comment is a guardrail for future changes: rather than overloading /api/carts/:id with unrelated actions, checkout is its own explicit sub-route.

Recap

In this lesson you learned how checkout works in this codebase (not a generic e-commerce example):

  • The repository createOrderFromCart() performs checkout inside a transaction and uses FOR UPDATE locks to prevent race conditions.
  • Snapshotting is implemented by copying cart_items into order_items, preserving unit_price_cents and quantities as they existed at checkout time.
  • The service layer translates CheckoutError and Postgres errors into consistent ServiceResult objects and enforces state transition rules for paying and cancelling.
  • The Next.js route POST /api/carts/:id/checkout validates params, calls the service, and returns a standardized success/error envelope with the correct status codes.

From here, the practices should feel much more grounded: when you debug checkout behavior, you’ll know exactly which layer to inspect—route, service, or repository—and why that layer owns that responsibility.

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