Tax Rates Configuration

Building the Tax Rates API

Welcome back! 👋 In this lesson, we begin building the Tax API for our e-commerce backend. Taxes are a critical part of any online store because they affect the final price customers pay. A reliable backend must apply the correct rate for a customer's location and ensure those rates are stored and managed safely.

In this lesson, we’ll implement the backend infrastructure that manages country-based tax rates. We’ll build a repository layer for database access, a service layer that validates and protects business logic, and API routes that expose the functionality to clients. By the end of the lesson, the system will support listing tax rates, retrieving a single rate, creating or updating a rate, and deleting one.

Previously in this learning path, we polished the Orders API: Checkout Workflow and Order State Transitions. That work handled the lifecycle of orders—from pending to paid or cancelled. Now we’re expanding the system to support tax configuration, which will later be used during checkout to calculate totals and snapshot tax details onto orders.

Why E-commerce Systems Store Taxes Carefully

Before diving into the code, it’s important to understand why taxes require careful backend design.

Taxes vary by country or region, and the rate can change over time. For example, one country might charge 7.25%, another 20% VAT, and governments occasionally adjust those numbers.

When a customer completes checkout, the system must record exactly which tax rate was used. Even if the rate changes later, historical orders must remain correct. This concept is called a snapshot—a copy of the tax configuration stored alongside the order.

To support this system cleanly, our backend must provide:

  • A reliable source of truth for tax rates
  • Validation to prevent invalid country codes or percentages
  • A consistent API for listing, retrieving, updating, and deleting tax configurations

The code in this lesson implements those capabilities.

Implementing the Repository Layer

The repository layer lives in src/lib/repositories/taxRatesRepo.ts. Its job is simple but extremely important: interact with PostgreSQL safely and return domain data.

Repositories should not contain business logic or HTTP concerns. Instead, they focus entirely on executing SQL queries and mapping rows to domain objects.

Below is the first part of the repository file.

import { query } from "@/lib/db/client";
import { TaxRate } from "@/lib/types/domain";

interface TaxRateRow {
  country_code: string;
  rate_bps: number;
  created_at: string;
  updated_at: string;
}
  • The query helper is imported from the database client. This utility executes parameterized SQL queries and returns typed rows from PostgreSQL.
  • The TaxRate type represents the domain object used throughout the application. Returning consistent domain types keeps the service layer and routes predictable.
  • TaxRateRow defines the exact shape of the database row. This helps TypeScript understand what the SQL query returns.
  • Separating database row types from domain types ensures flexibility. If the database schema changes later, we only need to adjust the mapping layer.

Next, we convert raw database rows into domain objects.

function mapTaxRateRow(row: TaxRateRow): TaxRate {
  return {
    country_code: row.country_code,
    rate_bps: row.rate_bps,
    created_at: row.created_at,
    updated_at: row.updated_at,
  };
}
  • This function transforms a raw database row into a domain object that the rest of the application can use safely.
  • Keeping this mapping centralized prevents duplicate transformation logic scattered across services or routes.
  • If new columns are added later, this mapping function becomes the single place to update how rows are converted.

Listing All Tax Rates

The first repository function retrieves all configured tax rates.

export async function listTaxRates(): Promise<TaxRate[]> {
  const rows = await query<TaxRateRow>(
    `SELECT country_code, rate_bps, created_at, updated_at
     FROM tax_rates
     ORDER BY country_code ASC`,
  );
  return rows.map(mapTaxRateRow);
}
  • This query retrieves every row from the tax_rates table and orders them alphabetically by country_code.
  • Ordering results is important because it makes responses deterministic. Without ordering, PostgreSQL might return rows in different sequences depending on internal query planning.
  • The function returns an array of TaxRate objects after mapping each row with mapTaxRateRow.
  • Returning domain objects rather than raw database rows ensures consistency across the service and route layers.

Fetching a Single Tax Rate

The repository also provides a function to retrieve the tax rate for a specific country.

export async function getTaxRate(countryCode: string): Promise<TaxRate | null> {
  const rows = await query<TaxRateRow>(
    `SELECT country_code, rate_bps, created_at, updated_at
     FROM tax_rates
     WHERE country_code = $1`,
    [countryCode],
  );
  return rows[0] ? mapTaxRateRow(rows[0]) : null;
}
  • The query searches for a tax rate matching the provided countryCode.
  • Notice the use of parameterized SQL ($1). This protects the application from SQL injection and ensures safe database access.
  • Instead of throwing an error when no row exists, the function returns null.
  • This design is intentional: the service layer will convert null into a clean NOT_FOUND error response.

Creating or Updating Tax Rates

Deleting a Tax Rate

The final repository function removes a tax rate by country code.

export async function deleteTaxRate(countryCode: string): Promise<boolean> {
  const rows = await query<{ country_code: string }>(
    `DELETE FROM tax_rates WHERE country_code = $1 RETURNING country_code`,
    [countryCode],
  );
  return Boolean(rows[0]);
}
  • The SQL DELETE statement removes the row matching the provided country code.
  • The RETURNING clause tells PostgreSQL to return the deleted row if one existed.
  • If a row was removed, the function returns true. If nothing matched, it returns false.
  • Returning a boolean allows the service layer to convert the result into a clean 404 error when needed.

Validating Tax Rate Inputs

Next we move to the service layer in src/lib/services/taxRatesService.ts.

The service layer sits between repositories and API routes. It performs validation, handles errors, and ensures consistent service responses.

First we define validation rules.

const COUNTRY_CODE_RE = /^[A-Z]{2}$/;
const MAX_RATE_BPS = 5000;
  • COUNTRY_CODE_RE ensures that only two uppercase letters are accepted as valid country codes.
  • This follows international standards like US, CA, or FR.
  • MAX_RATE_BPS limits the maximum tax rate to 5000 basis points, which equals 50%.

Now we validate country codes.

export function validateCountryCode(
  input: unknown,
): { ok: true; value: string } | { ok: false; message: string } {
  if (typeof input !== "string")
    return { ok: false, message: "country_code must be a string" };
  const normalized = input.trim().toUpperCase();
  if (!COUNTRY_CODE_RE.test(normalized)) {
    return { ok: false, message: "country_code must match /^[A-Z]{2}$/" };
  }
  return { ok: true, value: normalized };
}
  • The function first checks that the input is a string.
  • It then trims whitespace and converts the value to uppercase, ensuring consistent storage.
  • The regex validation enforces exactly two uppercase letters.
  • Instead of throwing errors, the function returns structured validation results.

This pattern allows routes to respond with helpful 400 validation errors.

Next we validate the tax rate.

export function validateRateBps(
  input: unknown,
): { ok: true; value: number } | { ok: false; message: string } {
  if (!isInt(input, { min: 0, max: MAX_RATE_BPS })) {
    return {
      ok: false,
      message: `rate_bps must be an integer between 0 and ${MAX_RATE_BPS}`,
    };
  }
  return { ok: true, value: input };
}
  • The function checks that rate_bps is an integer within the allowed range.
  • Floating-point values, strings, or out-of-range numbers are rejected.
  • Returning a clear validation message helps API consumers fix their requests quickly.

Implementing Tax Rate Services

The service layer also wraps repository functions with structured responses.

Here is the list service.

export async function listTaxRatesService(): Promise<ServiceResult<TaxRate[]>> {
  try {
    const rates = await listTaxRates();
    return ok(rates);
  } catch (e: unknown) {
    return handleException(e);
  }
}
  • The service calls the repository function listTaxRates.
  • If successful, it returns an ok(...) result containing the data.
  • If an exception occurs, the shared handleException helper converts it into a consistent error format.
  • This keeps route logic simple because services already return structured responses.

Next we fetch a single tax rate.

export async function getTaxRateService(
  countryCode: string,
): Promise<ServiceResult<TaxRate>> {
  try {
    const rate = await getTaxRate(countryCode);
    if (!rate)
      return fail({
        code: "NOT_FOUND",
        message: "Tax rate not found",
        httpStatus: 404,
      });
    return ok(rate);
  } catch (e: unknown) {
    return handleException(e);
  }
}
  • The service calls the repository’s getTaxRate.
  • If the repository returns null, the service converts that into a NOT_FOUND error.
  • Returning structured errors ensures the API responds with the correct HTTP status.

The service also supports creating or updating tax rates.

export async function upsertTaxRateService(
  countryCode: string,
  rateBps: number,
): Promise<ServiceResult<TaxRate>> {
  try {
    const rate = await upsertTaxRate(countryCode, rateBps);
    return ok(rate);
  } catch (e: unknown) {
    return handleException(e);
  }
}
  • This function calls the repository’s upsert logic.
  • If successful, the saved tax rate is returned in an ok(...) result.
  • Any unexpected errors are mapped through handleException.

Finally, the delete service removes a tax rate.

export async function deleteTaxRateService(
  countryCode: string,
): Promise<ServiceResult<{ deleted: true }>> {
  try {
    const deleted = await deleteTaxRate(countryCode);
    if (!deleted)
      return fail({
        code: "NOT_FOUND",
        message: "Tax rate not found",
        httpStatus: 404,
      });
    return ok({ deleted: true });
  } catch (e: unknown) {
    return handleException(e);
  }
}
  • The service calls the repository’s delete function.
  • If no row was removed, the service returns a NOT_FOUND error.
  • Otherwise it returns { deleted: true } so clients can confirm the operation succeeded.

Listing Tax Rates Through the API

Next we expose this functionality through Remix routes.

The first route is app/routes/api.tax.rates.ts, which handles GET /api/tax/rates.

export async function loader(_: LoaderFunctionArgs) {
  try {
    const rates = await listTaxRatesService();
    if (!rates.ok)
      return error(
        rates.error.code,
        rates.error.message,
        rates.error.details,
        rates.error.httpStatus,
      );
    return success(rates.value);
  } catch (e: unknown) {
    return error(
      "INTERNAL_ERROR",
      e instanceof Error ? e.message : "Internal error",
      undefined,
      500,
    );
  }
}
  • Remix uses a loader to handle GET requests.
  • The loader calls listTaxRatesService() to retrieve all tax rates.
  • If the service returns a failure, the route converts it into a structured error response.
  • On success, the route returns success(rates.value) which produces the standard { data, meta } envelope.

The same route rejects unsupported methods.

export async function action(_: ActionFunctionArgs) {
  return error("VALIDATION_ERROR", "Method not allowed", undefined, 405);
}
  • The action function exists to handle non-GET requests.
  • Since this endpoint only supports GET, the route immediately returns a 405 Method Not Allowed error.

Managing Tax Rates by Country

The second route file is app/routes/api.tax.rates.$country.ts, which manages individual country tax rates.

First we implement the loader for GET /api/tax/rates/:country.

export async function loader({ params }: LoaderFunctionArgs) {
  try {
    const validCountry = validateCountryCode(params.country);
    if (!validCountry.ok)
      return error("VALIDATION_ERROR", validCountry.message, undefined, 400);

    const result = await getTaxRateService(validCountry.value);
    if (!result.ok)
      return error(
        result.error.code,
        result.error.message,
        result.error.details,
        result.error.httpStatus,
      );
    return success(result.value);
  } catch (e: unknown) {
    return error(
      "INTERNAL_ERROR",
      e instanceof Error ? e.message : "Internal error",
      undefined,
      500,
    );
  }
}
  • The loader first validates the country URL parameter using validateCountryCode.
  • If validation fails, the route returns a 400 validation error.
  • The loader then calls getTaxRateService to retrieve the configured tax rate.
  • If the rate does not exist, the service returns a NOT_FOUND error which becomes a 404 response.

Next we implement the action for PUT and DELETE operations.

if (request.method === "PUT") {
  const bodyResult = await parseJson(request);
  if (!bodyResult.ok)
    return error(bodyResult.code, bodyResult.message, undefined, 400);

  const payload = bodyResult.value as { rate_bps?: unknown };
  const validRate = validateRateBps(payload.rate_bps);
  if (!validRate.ok)
    return error("VALIDATION_ERROR", validRate.message, undefined, 400);

  const result = await upsertTaxRateService(
    validCountry.value,
    validRate.value,
  );
  if (!result.ok)
    return error(
      result.error.code,
      result.error.message,
      result.error.details,
      result.error.httpStatus,
    );
  return success(result.value);
}
  • The route checks whether the request method is PUT.
  • The request body is parsed using parseJson, which safely handles invalid JSON payloads.
  • The payload’s rate_bps field is validated with validateRateBps.
  • If validation passes, the route calls upsertTaxRateService to create or update the tax rate.
  • A successful result returns the saved tax rate in the standard response envelope.

Finally, we support deletion.

if (request.method === "DELETE") {
  const result = await deleteTaxRateService(validCountry.value);
  if (!result.ok)
    return error(
      result.error.code,
      result.error.message,
      result.error.details,
      result.error.httpStatus,
    );
  return success(result.value);
}
  • The route calls deleteTaxRateService to remove the country’s tax rate.
  • If the country did not exist, the service returns a NOT_FOUND error.
  • On success, the API returns { deleted: true } to confirm the operation.

Lesson Recap

In this lesson, we built the Tax Rates API, a critical component of our e-commerce backend.

We implemented:

  • A repository layer that interacts safely with PostgreSQL using parameterized SQL.
  • A service layer responsible for validation, error handling, and consistent service results.
  • API routes in Remix that expose tax configuration through loaders and actions.
  • Validation rules that ensure country codes and tax rates follow strict constraints.

This infrastructure provides the system’s source of truth for tax rates. In upcoming lessons, these tax configurations will be used during checkout so the system can calculate totals and snapshot tax data onto orders.

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