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.
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.
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
queryhelper is imported from the database client. This utility executes parameterized SQL queries and returns typed rows from PostgreSQL. - The
TaxRatetype represents the domain object used throughout the application. Returning consistent domain types keeps the service layer and routes predictable. TaxRateRowdefines 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.
The first repository function retrieves all configured tax rates.
- This query retrieves every row from the
tax_ratestable and orders them alphabetically bycountry_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
TaxRateobjects after mapping each row withmapTaxRateRow. - Returning domain objects rather than raw database rows ensures consistency across the service and route layers.
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
nullinto a cleanNOT_FOUNDerror response.
Next we implement a function that either creates or updates a tax rate.
- This function uses PostgreSQL’s
INSERT ... ON CONFLICTfeature, 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_atwhen a conflict occurs, which keeps timestamps accurate. RETURNINGallows the database to send back the saved row immediately, which the repository then maps into aTaxRate.
This pattern ensures the API supports both creating and updating tax rates with a single endpoint.
The final repository function removes a tax rate by country code.
- The SQL
DELETEstatement removes the row matching the provided country code. - The
RETURNINGclause tells PostgreSQL to return the deleted row if one existed. - If a row was removed, the function returns
true. If nothing matched, it returnsfalse. - Returning a boolean allows the service layer to convert the result into a clean 404 error when needed.
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_REensures that only two uppercase letters are accepted as valid country codes.- This follows international standards like
US,CA, orFR. MAX_RATE_BPSlimits 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.
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
handleExceptionhelper 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 aNOT_FOUNDerror. - 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.
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
actionfunction exists to handle non-GET requests. - Since this endpoint only supports GET, the route immediately returns a 405 Method Not Allowed error.
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
countryURL parameter usingvalidateCountryCode. - If validation fails, the route returns a 400 validation error.
- The loader then calls
getTaxRateServiceto retrieve the configured tax rate. - If the rate does not exist, the service returns a
NOT_FOUNDerror which becomes a 404 response.
Next we implement the action for PUT and DELETE operations.
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.
