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.

  • 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.

  • 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.

  • 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.

  • 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

Next we implement a function that either creates or updates a tax rate.

  • This function uses PostgreSQL’s INSERT ... ON CONFLICT feature, commonly called an upsert.
  • If the country code does not exist, PostgreSQL inserts a new row.
  • If the country already exists, PostgreSQL updates the existing row instead of failing.
  • The query also updates updated_at when a conflict occurs, which keeps timestamps accurate.
  • RETURNING allows the database to send back the saved row immediately, which the repository then maps into a TaxRate.

This pattern ensures the API supports both creating and updating tax rates with a single endpoint.

Deleting a Tax Rate

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

  • 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.

  • 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.

  • 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.

Implementing Tax Rate Services

The service layer also wraps repository functions with structured responses.

Here is the list service.

  • 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.

  • 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.

  • This function calls the repository’s upsert logic.
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.

  • 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.

  • 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.

  • 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.

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