Build a DEX Swap Routing API
Fetch, compare, and persist swap quotes from multiple Ethereum DEX sources.
Introduction
30 Second Summary
When you compare prices across multiple stores before buying something, you're doing exactly what DEX aggregators do for crypto trades. They check every available exchange to find the best deal.
In this project, you will build a backend API that fetches swap quotes from multiple Ethereum decentralized exchanges concurrently, compares prices, selects the optimal route, and persists the entire decision trail to a database.
What You'll Build
A DEX swap routing API that fetches quotes from multiple liquidity sources in parallel, selects the best route based on output amount minus gas costs, and stores all quote data in PostgreSQL.
By the end of this project, you'll have:
- A Fastify server with typed route handlers and JSON Schema validation that processes swap requests.
- A quote aggregation service that fetches prices from 0x API and SushiSwap concurrently using Promise.allSettled.
- A PostgreSQL database schema for quotes, routes, and execution states with proper indexes and structured logging using Pino child loggers.
- Secret Mission: Add an in-memory response cache with TTL for sub-millisecond repeated quote responses.
Are there any prerequisites?
This project assumes familiarity with Node.js/TypeScript, REST APIs, and basic blockchain token concepts like ERC-20 addresses and wei amounts. You'll also need Docker running via WSL for the PostgreSQL container.
Before We Start
DEX aggregators like 1inch and Paraswap fetch swap prices from multiple liquidity sources, compare them, and route trades to get users the best deal. Building one yourself is one of the strongest ways to demonstrate backend systems architecture skills for DeFi engineering roles.
Before diving into the routing logic, you need a working development environment and a scaffolded project. In this step, you'll verify your tools, register for an API key, and initialize a TypeScript project with all its dependencies.
In this step, get ready to:
- Confirm Node.js and Docker (via WSL) are installed.
- Register for a 0x API key.
- Initialize a TypeScript Fastify project with all dependencies and configuration.
Confirm your tools are installed
This project requires Node.js for running your TypeScript server and Docker (inside WSL) for running a PostgreSQL database container.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or Windows Terminal (Windows) and press Enter to open it.
- Check your Node.js version by running:
node --version
✔️ I see a version number
You're good to go. Any version 18 or above works for this project.
ⓧ Command not found
Node.js is not installed yet.
- Download and install Node.js from https://nodejs.org (choose the LTS version).
- After installing, close and reopen your terminal.
- Run node --version again to confirm it works.
ⓧ Something else
If you see a permission error or an unexpected message, try closing your terminal completely and reopening it.
help me troubleshoot my Node.js installation
- Now check Docker is available inside WSL by running:
wsl docker --version
✔️ I see a Docker version
Docker is ready inside WSL.
ⓧ Command not found
Docker is not available in your WSL environment.
- Install Docker Desktop from https://docs.docker.com/desktop/install/windows-install/.
- In Docker Desktop settings, enable Use the WSL 2 based engine and enable integration with your WSL distro under Resources > WSL Integration.
- Restart your terminal and run wsl docker --version again.
Register for a 0x API key
Your routing API fetches live swap prices from the 0x Swap API. This requires a free API key.
- Open https://dashboard.0x.org in your browser.
- Click Create an account and fill in your name, email, and password.
- After verifying your email, you'll be prompted to create a team. Give it any name.
- Click Create an app.
- Enter an app name (e.g. swap-routing) and select Swap API as the product.
- Click Continue.
- Copy your new API key and keep it somewhere safe. You'll paste it into your environment file shortly.
What is the 0x Swap API?
0x aggregates liquidity from 373+ DEX sources across multiple blockchains. Its /price endpoint returns indicative swap quotes without committing to a trade.
The free tier gives you approximately 5 requests per second and 1 million API calls monthly. That is more than enough for development.
Initialize the project
Now you'll scaffold a Fastify project with TypeScript, install all dependencies, and create the configuration files your API needs.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir dex-swap-routing-api
cd dex-swap-routing-api
- Confirm the folder exists by running:
ls
You should see dex-swap-routing-api listed in the output (if you haven't moved into it yet) or an empty directory (if you have).
- Initialize a package.json file by running:
npm init -y
What does npm init -y do?
This creates a default package.json with pre-filled values. The -y flag skips the interactive prompts and accepts all defaults.
- Install the production dependencies by running:
npm install fastify pg dotenv
- Install the development dependencies by running:
npm install -D @types/node @types/pg pino-pretty tsx typescript
What are these packages?
- fastify is a high-performance Node.js web framework with built-in JSON Schema validation and Pino logging.
- pg is the PostgreSQL client for Node.js with connection pooling support.
- dotenv loads environment variables from a .env file into process.env.
- tsx runs TypeScript files directly without a separate compile step.
- pino-pretty formats JSON log output into readable, colorized lines during development.
Seeing install errors?
Make sure you are inside the dex-swap-routing-api folder (run pwd to check). If you see permission errors, try prefixing with sudo on macOS/Linux or running your terminal as Administrator on Windows.
help me fix npm install errors in my project
- Now update your package.json to use ES modules and add the project scripts. Open package.json in your editor and replace its contents with:
{
"name": "dex-swap-routing-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "node dist/server.js",
"migrate": "tsx src/db/migrate.ts",
"build": "tsc"
},
"dependencies": {
"dotenv": "^16.4.7",
"fastify": "^5.8.5",
"pg": "^8.20.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/pg": "^8.11.0",
"pino-pretty": "^13.0.0",
"tsx": "^4.21.0",
"typescript": "^5.7.0"
}
}
What does type: module do?
Setting "type": "module" tells Node.js to treat .js files as ES modules. This lets you use import/export syntax instead of require().
- Save package.json.
- Create the TypeScript configuration file by creating a new file called tsconfig.json in the dex-swap-routing-api folder and paste in:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
What do these compiler options do?
- target: "ES2022" compiles to modern JavaScript that supports top-level await.
- module: "ESNext" outputs ES module syntax to match your package.json type setting.
- moduleResolution: "bundler" allows importing without specifying file extensions in your source code.
- strict: true enables all strict type-checking options for safer code.
- Save tsconfig.json.
- Create the environment configuration file by creating a new file called .env in the dex-swap-routing-api folder and paste in:
PORT=3000
HOST=0.0.0.0
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=swap_routing
ZERO_X_API_KEY=your-0x-api-key-here
NODE_ENV=development
LOG_LEVEL=info
- Replace your-0x-api-key-here with the API key you copied from the 0x dashboard.
- Save .env.
Never commit your .env file
The .env file contains your API key. If you push this project to GitHub, add .env to your .gitignore file first.
- Create the folder structure for your source code by running:
mkdir -p src/config src/db src/services src/routes
- Confirm the folders were created by running:
ls src
You should see config, db, routes, and services listed.
Why this folder structure?
Each folder isolates a single concern. config holds environment loading. db holds database connections and migrations. services holds business logic. routes holds HTTP endpoint definitions.
This modular layout makes it easy to test, extend, and navigate your codebase as it grows.
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files (package.json, tsconfig.json, .env).
ⓧ I'd like to double check the full code
Here are the three configuration files in full.
{
"name": "dex-swap-routing-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "node dist/server.js",
"migrate": "tsx src/db/migrate.ts",
"build": "tsc"
},
"dependencies": {
"dotenv": "^16.4.7",
"fastify": "^5.8.5",
"pg": "^8.20.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/pg": "^8.11.0",
"pino-pretty": "^13.0.0",
"tsx": "^4.21.0",
"typescript": "^5.7.0"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
PORT=3000
HOST=0.0.0.0
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=swap_routing
ZERO_X_API_KEY=your-0x-api-key-here
NODE_ENV=development
LOG_LEVEL=info
Your project skeleton is ready. Next up, you'll design the database schema and spin up a PostgreSQL container to store your swap quotes and routing decisions.
Set Up the Project and PostgreSQL
Your project scaffolding is in place with all dependencies installed and your folder structure ready.
Before you can build any services, you need two foundational pieces: a centralized configuration module that reads your .env values into typed objects, and a running PostgreSQL database to persist swap quotes and routes.
In this step, get ready to:
- Create the environment configuration module that centralizes all settings.
- Start a PostgreSQL container in Docker via WSL.
- Verify database connectivity from your local machine.
Create the environment configuration module
Your .env file holds raw key-value pairs, but the rest of your application needs typed, structured access to those values.
A config module reads the environment variables once at startup and exports a single object the rest of your code can import.
- Create a new file called env.ts inside the src/config folder and paste the following code:
import dotenv from 'dotenv';
dotenv.config();
export const config = {
// Server settings
port: parseInt(process.env.PORT || '3000', 10),
host: process.env.HOST || '0.0.0.0',
// PostgreSQL connection details
database: {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_NAME || 'swap_routing',
},
// 0x Swap API credentials and base URL
zeroX: {
apiKey: process.env.ZERO_X_API_KEY || '',
baseUrl: 'https://api.0x.org',
},
// SushiSwap Quote API base URL
sushi: {
baseUrl: 'https://api.sushi.com',
},
};
What does this code do?
dotenv.config() loads the key-value pairs from your .env file into process.env so the rest of the module can read them.
The config object groups related settings (database, zeroX, sushi) so any service can import one object instead of reading raw environment variables everywhere.
The fallback values (the || defaults) ensure the app still starts even if a variable is missing.
- Save src/config/env.ts.
Seeing a red underline on the dotenv import?
Make sure you ran npm install in the previous step. The dotenv package must be in your node_modules folder for TypeScript to find it.
Also confirm your tsconfig.json has "esModuleInterop": true. Without it, default imports like import dotenv from 'dotenv' won't resolve correctly.
Help me fix my dotenv import error in src/config/env.ts
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/config/env.ts file for comparison:
import dotenv from 'dotenv';
dotenv.config();
export const config = {
port: parseInt(process.env.PORT || '3000', 10),
host: process.env.HOST || '0.0.0.0',
database: {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_NAME || 'swap_routing',
},
zeroX: {
apiKey: process.env.ZERO_X_API_KEY || '',
baseUrl: 'https://api.0x.org',
},
sushi: {
baseUrl: 'https://api.sushi.com',
},
};
Start the PostgreSQL Docker container
Your routing service needs a database to persist every quote it fetches and every route it selects.
You will run PostgreSQL inside a Docker container through WSL. The container uses the same credentials you defined in your .env file.
- Open your WSL terminal.
- Start the PostgreSQL container by running this command:
docker run --name swap-routing-db -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=swap_routing -p 5432:5432 -d postgres:16
What does this command do?
- --name swap-routing-db gives the container a memorable name you can reference later.
- -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=swap_routing sets the database password and creates a database called swap_routing on first start.
- -p 5432:5432 forwards the container's PostgreSQL port to your host machine so your Node.js app can connect via localhost:5432.
- -d postgres:16 runs the official PostgreSQL 16 image in detached (background) mode.
- Confirm the container is running by running:
docker ps
You should see a row with swap-routing-db in the NAMES column and Up in the STATUS column.
Container not showing as running?
If you see the name swap-routing-db is already in use, it means the container was created previously. Remove it first with docker rm swap-routing-db, then re-run the docker run command.
If you get a "port already in use" error, another process is using port 5432. Stop it or change the host port mapping (e.g. -p 5433:5432) and update DB_PORT in your .env to match.
Help me troubleshoot my Docker PostgreSQL container not starting in WSL
Verify database connectivity
A running container does not guarantee your app can connect. You need to confirm the database accepts connections with the credentials from your .env file.
- Connect to the PostgreSQL container by running this command in your WSL terminal:
docker exec -it swap-routing-db psql -U postgres -d swap_routing -c 'SELECT 1;'
You should see a small table with a single row containing the value 1. This confirms PostgreSQL is accepting connections and the swap_routing database exists.
Seeing "database swap_routing does not exist"?
This means the POSTGRES_DB environment variable was not set when the container first started. Remove the container with docker rm -f swap-routing-db and re-run the docker run command from above. PostgreSQL only creates the database on the very first initialization.
Help me fix my PostgreSQL database not being created in the Docker container
Your database is live and your config module is ready to feed connection details into every service you build. Next up, you will design the database schema that stores quotes, routes, and execution states.
Design and Create the Database Schema
Your project is scaffolded and PostgreSQL is running in Docker. Now you need a place to store data as it flows through your routing engine.
Every swap quote your API fetches, every route decision it makes, and every execution state it tracks needs a persistent home. In this step, you'll design the database schema that captures this entire decision trail.
In this step, get ready to:
- Create a database connection pool module.
- Write a SQL migration with tables for quotes, routes, and execution states.
- Run the migration and verify the tables exist.
Create the database connection pool
Opening a new database connection for every query is slow and wasteful. A connection pool keeps a set of reusable connections open so your application can grab one instantly whenever it needs to talk to PostgreSQL.
- Create a new file at src/db/pool.ts and add the following code:
import pg from 'pg';
import { config } from '../config/env.js';
// Destructure Pool from the pg module (ESM compatibility)
const { Pool } = pg;
// Create a shared connection pool using settings from .env
export const pool = new Pool({
host: config.database.host,
port: config.database.port,
user: config.database.user,
password: config.database.password,
database: config.database.database,
max: 10, // Maximum concurrent connections
idleTimeoutMillis: 30000, // Close idle connections after 30s
connectionTimeoutMillis: 5000, // Fail if connection takes longer than 5s
});
What does this code do?
- import pg from 'pg' imports the node-postgres library. The destructure pattern is needed because pg uses a default export in ESM.
- max: 10 limits the pool to 10 simultaneous connections. This prevents your app from overwhelming the database.
- idleTimeoutMillis and connectionTimeoutMillis set guardrails so stale or hung connections get cleaned up automatically.
- Save src/db/pool.ts.
You won't see a visible change yet. The next chunk uses this pool to run database migrations.
Seeing a red underline on the import?
- Make sure you ran npm install in Step 1. The pg and @types/pg packages must be in node_modules.
- Confirm the path ../config/env.js is correct. TypeScript uses .js extensions in ESM even though the source file is .ts.
Help me fix import errors in my pool.ts file.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
import pg from 'pg';
import { config } from '../config/env.js';
const { Pool } = pg;
export const pool = new Pool({
host: config.database.host,
port: config.database.port,
user: config.database.user,
password: config.database.password,
database: config.database.database,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
Write the SQL migration
Your routing engine needs three tables to capture the full lifecycle of a swap request. The quotes table stores every price response from each DEX source. The routes table records which quote was selected and why. The execution_states table tracks the status of each routed swap.
- Create a new file at src/db/migrate.ts and add the import and SQL migration string:
import { pool } from './pool.js';
// SQL schema defining the three core tables and their indexes
const migration = `
CREATE TABLE IF NOT EXISTS quotes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
request_id UUID NOT NULL,
source VARCHAR(50) NOT NULL,
token_in VARCHAR(42) NOT NULL,
token_out VARCHAR(42) NOT NULL,
amount_in VARCHAR(78) NOT NULL,
amount_out VARCHAR(78) NOT NULL,
gas_estimate VARCHAR(78),
chain_id INTEGER NOT NULL DEFAULT 1,
fetched_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS routes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
request_id UUID NOT NULL,
selected_quote_id UUID REFERENCES quotes(id),
token_in VARCHAR(42) NOT NULL,
token_out VARCHAR(42) NOT NULL,
amount_in VARCHAR(78) NOT NULL,
best_amount_out VARCHAR(78) NOT NULL,
sources_queried INTEGER NOT NULL,
sources_responded INTEGER NOT NULL,
selection_reason VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS execution_states (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
route_id UUID REFERENCES routes(id),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
error_message TEXT,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_quotes_request_id ON quotes(request_id);
CREATE INDEX IF NOT EXISTS idx_routes_request_id ON routes(request_id);
CREATE INDEX IF NOT EXISTS idx_execution_states_route_id ON execution_states(route_id);
`;
What do these tables store?
- quotes stores every raw price response. Token addresses use VARCHAR(42) because Ethereum addresses are 42 characters (0x + 40 hex chars). Amounts use VARCHAR(78) because uint256 values in wei can be up to 78 digits.
- routes records the routing decision. It links back to the winning quote via selected_quote_id and stores how many sources responded versus how many were queried.
- execution_states tracks whether a route is pending, quoted, or failed. This is where you'd add transaction hashes if you extended the project to on-chain execution.
- The indexes on request_id and route_id speed up lookups when tracing a request through the system.
You won't see a change yet. The next piece adds the function that actually runs this SQL.
- Below the migration string in src/db/migrate.ts, add the function that executes the migration and the self-invoking call:
// Execute the full migration SQL in a single transaction
export async function runMigration(): Promise<void> {
const client = await pool.connect();
try {
await client.query(migration);
console.log('Migration completed successfully');
} finally {
client.release();
}
}
// Run migration immediately when this file is executed via tsx
runMigration()
.then(() => process.exit(0))
.catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});
What does this function do?
- pool.connect() grabs a single connection from the pool. The finally block always releases it back, even if the query fails.
- The self-invoking runMigration() at the bottom means this file runs the migration as soon as tsx executes it. Success exits with code 0. Failure prints the error and exits with code 1.
- Save src/db/migrate.ts.
Why raw SQL instead of an ORM?
ORMs like Prisma or TypeORM abstract away the SQL and connection management that DEX engineering roles expect you to understand. Using raw pg with a Pool teaches you connection pooling, manual transactions, and direct SQL. These are the skills interviewers test for.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
import { pool } from './pool.js';
const migration = `
CREATE TABLE IF NOT EXISTS quotes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
request_id UUID NOT NULL,
source VARCHAR(50) NOT NULL,
token_in VARCHAR(42) NOT NULL,
token_out VARCHAR(42) NOT NULL,
amount_in VARCHAR(78) NOT NULL,
amount_out VARCHAR(78) NOT NULL,
gas_estimate VARCHAR(78),
chain_id INTEGER NOT NULL DEFAULT 1,
fetched_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS routes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
request_id UUID NOT NULL,
selected_quote_id UUID REFERENCES quotes(id),
token_in VARCHAR(42) NOT NULL,
token_out VARCHAR(42) NOT NULL,
amount_in VARCHAR(78) NOT NULL,
best_amount_out VARCHAR(78) NOT NULL,
sources_queried INTEGER NOT NULL,
sources_responded INTEGER NOT NULL,
selection_reason VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS execution_states (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
route_id UUID REFERENCES routes(id),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
error_message TEXT,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_quotes_request_id ON quotes(request_id);
CREATE INDEX IF NOT EXISTS idx_routes_request_id ON routes(request_id);
CREATE INDEX IF NOT EXISTS idx_execution_states_route_id ON execution_states(route_id);
`;
export async function runMigration(): Promise<void> {
const client = await pool.connect();
try {
await client.query(migration);
console.log('Migration completed successfully');
} finally {
client.release();
}
}
runMigration()
.then(() => process.exit(0))
.catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});
Run the migration and verify tables exist
Your package.json already has a migrate script that runs tsx src/db/migrate.ts. This executes your migration file directly without a compile step.
- Make sure your PostgreSQL container is running. If you stopped it, start it again in your WSL terminal by running:
docker start swap-routing-db
- Run the migration by executing this command in your project directory:
npm run migrate
You should see Migration completed successfully printed to your terminal.
- Verify the tables were created by listing them from inside the container:
docker exec swap-routing-db psql -U postgres -d swap_routing -c "\dt"
You should see a table listing showing quotes, routes, and execution_states in the output.
Seeing 'Migration failed' or 'ECONNREFUSED'?
- ECONNREFUSED means PostgreSQL is not reachable on port 5432. Run docker ps in WSL to confirm the swap-routing-db container is running.
- If you see a password authentication error, check that DB_PASSWORD=postgres in your .env matches the container's POSTGRES_PASSWORD.
- If you see database "swap_routing" does not exist, the container may have been recreated without the database. Remove it with docker rm swap-routing-db and re-run the docker run command from Step 1.
Help me debug my database migration failure.
Your database is ready with a proper schema for tracking every swap quote and routing decision. Next up, you'll build the services that fetch live quotes from DEX sources and fill these tables with real data.
Build the Quote Fetching Services
Your database is ready to store quotes and routes. Now you need the services that actually go out and fetch swap prices from external DEX liquidity sources.
A real DEX aggregator queries multiple sources in parallel and compares outputs to find the best deal. In this step, you'll build two quote fetching services that each call a different DEX API and return results in a shared format. This shared format is what makes it possible to compare apples to apples across sources.
In this step, get ready to:
- Define a unified QuoteRequest and QuoteResult interface that both services share.
- Build a 0x API quote fetching service.
- Build a SushiSwap quote fetching service.
Define the shared types interface
Both quote services need to return data in the same shape. Defining a shared interface upfront means any new source you add later just needs to conform to the same contract.
- Create a new file called src/services/types.ts and paste in the following code:
export interface QuoteRequest {
tokenIn: string;
tokenOut: string;
amount: string;
chainId: number;
}
export interface QuoteResult {
source: string;
tokenIn: string;
tokenOut: string;
amountIn: string;
amountOut: string;
gasEstimate: string | null;
fetchedAt: Date;
}
export interface RouteResult {
requestId: string;
bestQuote: QuoteResult;
alternatives: QuoteResult[];
sourcesQueried: number;
sourcesResponded: number;
selectionReason: string;
}
What does this code do?
QuoteRequest defines what a caller asks for: which tokens to swap, how much, and on which chain.
QuoteResult is the normalized output every source must return. Regardless of how 0x or SushiSwap structures their response, your code maps it into this single shape.
RouteResult wraps the final routing decision: the best quote, any alternatives, and metadata about the selection process. You will use this in Step 4.
- Save src/services/types.ts.
You won't see a change yet. The next two chunks import these types and put them to work.
Create the 0x quote service
The 0x API aggregates over 370 liquidity sources under a single endpoint. The /swap/allowance-holder/price endpoint returns indicative pricing without committing to a trade. This makes it perfect for a read-only quote comparison flow.
- Create a new file called src/services/zeroXService.ts and paste in the following code:
import { config } from '../config/env.js';
import { QuoteRequest, QuoteResult } from './types.js';
export async function fetchZeroXQuote(request: QuoteRequest): Promise<QuoteResult> {
// Build query params from the swap request
const params = new URLSearchParams({
sellToken: request.tokenIn,
buyToken: request.tokenOut,
sellAmount: request.amount,
chainId: request.chainId.toString(),
taker: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
});
// Call the 0x price endpoint with required auth headers
const response = await fetch(
`${config.zeroX.baseUrl}/swap/allowance-holder/price?${params.toString()}`,
{
headers: {
'0x-api-key': config.zeroX.apiKey,
'0x-version': 'v2',
},
}
);
// Throw a descriptive error if the API rejects the request
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`0x API error (${response.status}): ${errorBody}`);
}
const data = await response.json();
// Normalize the 0x response into our shared QuoteResult shape
return {
source: '0x',
tokenIn: request.tokenIn,
tokenOut: request.tokenOut,
amountIn: request.amount,
amountOut: data.buyAmount,
gasEstimate: data.gas || null,
fetchedAt: new Date(),
};
}
What does this code do?
- URLSearchParams builds the query string from the incoming request fields. The 0x v2 API requires sellToken, buyToken, sellAmount, chainId, and taker.
- The taker address is a well-known public address (vitalik.eth). The /price endpoint requires a taker but does not execute a trade, so any valid Ethereum address works here.
- Two required headers authenticate the request: 0x-api-key (your key from the dashboard) and 0x-version: v2 (the v1 API was sunset in April 2025).
- The return block maps 0x-specific field names (like buyAmount) into your normalized QuoteResult shape.
- Save src/services/zeroXService.ts.
You won't see output yet. The routing service you build in Step 4 calls this function.
Seeing a red squiggly on the import?
If your editor underlines ./types.js in red, make sure you saved src/services/types.ts first. TypeScript resolves .js extensions to the corresponding .ts file at compile time when using ESM.
Why is my TypeScript import showing an error?
Create the SushiSwap quote service
SushiSwap exposes a simple REST quote endpoint that requires no authentication. This makes it a great second source for comparison.
- Create a new file called src/services/sushiService.ts and paste in the following code:
import { config } from '../config/env.js';
import { QuoteRequest, QuoteResult } from './types.js';
export async function fetchSushiQuote(request: QuoteRequest): Promise<QuoteResult> {
// Build the URL with chain ID in the path and swap params as query strings
const url = new URL(`${config.sushi.baseUrl}/quote/v7/${request.chainId}`);
url.searchParams.set('tokenIn', request.tokenIn);
url.searchParams.set('tokenOut', request.tokenOut);
url.searchParams.set('amount', request.amount);
url.searchParams.set('maxSlippage', '0.005');
// No auth headers needed for SushiSwap's public quote endpoint
const response = await fetch(url.toString());
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`SushiSwap API error (${response.status}): ${errorBody}`);
}
const data = await response.json();
// Normalize the response, handling different field name variants
return {
source: 'sushiswap',
tokenIn: request.tokenIn,
tokenOut: request.tokenOut,
amountIn: request.amount,
amountOut: data.amountOut || data.outAmount || '0',
gasEstimate: data.gasSpent || data.gas || null,
fetchedAt: new Date(),
};
}
What does this code do?
- The URL structure is different from 0x. SushiSwap puts the chain ID in the path (/quote/v7/1 for Ethereum mainnet) and uses query parameters for the token pair.
- maxSlippage is set to 0.005 (0.5%). This tells SushiSwap the acceptable price deviation.
- The normalization handles field name variants defensively. SushiSwap's response may use amountOut or outAmount depending on the version. This fallback pattern prevents breakage.
- Save src/services/sushiService.ts.
- Verify that TypeScript compiles all three new files by running:
npx tsc --noEmit
You should see no output and no errors. A clean return means all your types and imports resolve correctly.
Seeing TypeScript errors?
If you see errors about missing modules, confirm that src/services/types.ts exists and is saved. Both service files import from it.
If you see errors about fetch not being defined, check that your tsconfig.json has "target": "ES2022". Node.js 18+ includes fetch globally and @types/node v22 provides its type definitions.
Help me fix my TypeScript compilation errors in my DEX routing project.
Why two sources with different API designs?
In production, DEX aggregators query dozens of sources. Each source has its own response schema, authentication style, and error format. By normalizing everything into a single QuoteResult type, your routing engine can compare sources without knowing anything about their internals.
Adding a third source later means writing one new fetch function that returns QuoteResult. No changes to routing logic required.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files in src/services/.
ⓧ I'd like to double check the full code
Here is the complete code for each file.
export interface QuoteRequest {
tokenIn: string;
tokenOut: string;
amount: string;
chainId: number;
}
export interface QuoteResult {
source: string;
tokenIn: string;
tokenOut: string;
amountIn: string;
amountOut: string;
gasEstimate: string | null;
fetchedAt: Date;
}
export interface RouteResult {
requestId: string;
bestQuote: QuoteResult;
alternatives: QuoteResult[];
sourcesQueried: number;
sourcesResponded: number;
selectionReason: string;
}
import { config } from '../config/env.js';
import { QuoteRequest, QuoteResult } from './types.js';
export async function fetchZeroXQuote(request: QuoteRequest): Promise<QuoteResult> {
const params = new URLSearchParams({
sellToken: request.tokenIn,
buyToken: request.tokenOut,
sellAmount: request.amount,
chainId: request.chainId.toString(),
taker: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
});
const response = await fetch(
`${config.zeroX.baseUrl}/swap/allowance-holder/price?${params.toString()}`,
{
headers: {
'0x-api-key': config.zeroX.apiKey,
'0x-version': 'v2',
},
}
);
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`0x API error (${response.status}): ${errorBody}`);
}
const data = await response.json();
return {
source: '0x',
tokenIn: request.tokenIn,
tokenOut: request.tokenOut,
amountIn: request.amount,
amountOut: data.buyAmount,
gasEstimate: data.gas || null,
fetchedAt: new Date(),
};
}
import { config } from '../config/env.js';
import { QuoteRequest, QuoteResult } from './types.js';
export async function fetchSushiQuote(request: QuoteRequest): Promise<QuoteResult> {
const url = new URL(`${config.sushi.baseUrl}/quote/v7/${request.chainId}`);
url.searchParams.set('tokenIn', request.tokenIn);
url.searchParams.set('tokenOut', request.tokenOut);
url.searchParams.set('amount', request.amount);
url.searchParams.set('maxSlippage', '0.005');
const response = await fetch(url.toString());
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`SushiSwap API error (${response.status}): ${errorBody}`);
}
const data = await response.json();
return {
source: 'sushiswap',
tokenIn: request.tokenIn,
tokenOut: request.tokenOut,
amountIn: request.amount,
amountOut: data.amountOut || data.outAmount || '0',
gasEstimate: data.gasSpent || data.gas || null,
fetchedAt: new Date(),
};
}
You now have two independent services that can fetch swap quotes from different liquidity sources. Next up, you'll build the routing engine that calls both services in parallel and picks the best deal.
Build the Route Selection Engine
Your quote fetching services can talk to 0x and SushiSwap individually. Now you need something that orchestrates them together, compares results, and decides which quote wins.
This is the routing service. It fires off both quote requests simultaneously, waits for them to settle, picks the best output amount, and persists the full decision trail to PostgreSQL so you have a complete audit log of every routing decision.
In this step, get ready to:
- Fetch quotes from all sources concurrently using Promise.allSettled.
- Implement best-route selection by comparing output amounts.
- Persist quotes and the selected route to PostgreSQL inside a transaction.
Fetch quotes from all sources concurrently
A real DEX aggregator cannot afford to call liquidity sources one at a time. If each call takes 500ms, calling two sources sequentially wastes half a second. Promise.allSettled fires all requests in parallel and returns the outcome of each, whether it succeeded or failed.
- Create a new file at src/services/routingService.ts and add the imports and concurrent fetch logic:
import { randomUUID } from 'crypto';
import { pool } from '../db/pool.js';
import { fetchZeroXQuote } from './zeroXService.js';
import { fetchSushiQuote } from './sushiService.js';
import { QuoteRequest, QuoteResult, RouteResult } from './types.js';
import { Logger } from 'pino';
export async function findBestRoute(
request: QuoteRequest,
logger: Logger
): Promise<RouteResult> {
const requestId = randomUUID();
const sourcesQueried = 2;
logger.info({ requestId, request }, 'Fetching quotes from all sources');
const startTime = Date.now();
// Fire both quote fetches in parallel
const results = await Promise.allSettled([
fetchZeroXQuote(request),
fetchSushiQuote(request),
]);
const elapsed = Date.now() - startTime;
logger.info({ requestId, elapsed }, 'All quote fetches completed');
// Separate successes from failures
const successfulQuotes: QuoteResult[] = [];
results.forEach((result, index) => {
const sourceName = index === 0 ? '0x' : 'sushiswap';
if (result.status === 'fulfilled') {
successfulQuotes.push(result.value);
logger.info(
{ requestId, source: sourceName, amountOut: result.value.amountOut },
'Quote received'
);
} else {
logger.warn(
{ requestId, source: sourceName, error: result.reason.message },
'Quote fetch failed'
);
}
});
if (successfulQuotes.length === 0) {
throw new Error('All quote sources failed');
}
What does this code do?
The function generates a unique requestId to trace this routing decision through logs and the database.
Promise.allSettled is the key pattern here. Unlike Promise.all which rejects if any promise fails, allSettled always resolves with an array of outcomes. This means one failing source does not kill the entire request.
The forEach loop categorizes each result as fulfilled or rejected, logging both for observability.
Why Promise.allSettled instead of Promise.all?
In production DEX aggregators, external API failures are common. A source might be rate-limited, down for maintenance, or slow to respond. You still want to return the best available quote from whichever sources did respond.
Promise.allSettled gives you graceful degradation. If 0x fails but SushiSwap responds, the user still gets a quote instead of an error.
Implement best-route selection logic
With the successful quotes collected, you need to pick the winner. The selection strategy compares raw output amounts using BigInt arithmetic to handle the large integer values that Ethereum token amounts produce.
- In src/services/routingService.ts, add the selection logic directly below the if (successfulQuotes.length === 0) check, still inside the findBestRoute function:
// Select the quote with the highest output amount
const bestQuote = successfulQuotes.reduce((best, current) => {
const bestOut = BigInt(best.amountOut);
const currentOut = BigInt(current.amountOut);
return currentOut > bestOut ? current : best;
});
const alternatives = successfulQuotes.filter((q) => q !== bestQuote);
const selectionReason = `Highest output amount from ${bestQuote.source}`;
logger.info(
{ requestId, bestSource: bestQuote.source, bestAmountOut: bestQuote.amountOut, selectionReason },
'Route selected'
);
// Persist the full decision trail to the database
await persistRoute(requestId, request, bestQuote, successfulQuotes, selectionReason);
return {
requestId,
bestQuote,
alternatives,
sourcesQueried,
sourcesResponded: successfulQuotes.length,
selectionReason,
};
}
What does this code do?
- The reduce compares each quote's amountOut as a BigInt. Token amounts in Ethereum are stored as large integers (e.g., 1 USDC = 1000000) which overflow regular JavaScript numbers.
- alternatives contains any quotes that were not selected. The API response will include these so the caller can see what other options existed.
- selectionReason records why this quote won. This is stored in the database as an audit trail for every routing decision.
Persist the quotes and selected route to PostgreSQL
Every routing decision needs a paper trail. The persistRoute function wraps all database writes in a single transaction so that either everything is saved or nothing is.
- Below the closing brace of findBestRoute, add the persistRoute function:
async function persistRoute(
requestId: string,
request: QuoteRequest,
bestQuote: QuoteResult,
allQuotes: QuoteResult[],
selectionReason: string
): Promise<void> {
const client = await pool.connect();
try {
await client.query('BEGIN');
let selectedQuoteId: string | null = null;
// Insert each quote and track the best one's ID
for (const quote of allQuotes) {
const result = await client.query(
`INSERT INTO quotes (request_id, source, token_in, token_out, amount_in, amount_out, gas_estimate, chain_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id`,
[
requestId,
quote.source,
quote.tokenIn,
quote.tokenOut,
quote.amountIn,
quote.amountOut,
quote.gasEstimate,
request.chainId,
]
);
if (quote === bestQuote) {
selectedQuoteId = result.rows[0].id;
}
}
// Insert the route record linking to the selected quote
const routeResult = await client.query(
`INSERT INTO routes (request_id, selected_quote_id, token_in, token_out, amount_in, best_amount_out, sources_queried, sources_responded, selection_reason)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id`,
[
requestId,
selectedQuoteId,
request.tokenIn,
request.tokenOut,
request.amount,
bestQuote.amountOut,
allQuotes.length,
allQuotes.length,
selectionReason,
]
);
// Create an initial execution state for this route
await client.query(
`INSERT INTO execution_states (route_id, status)
VALUES ($1, $2)`,
[routeResult.rows[0].id, 'quoted']
);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
What does this code do?
- BEGIN/COMMIT/ROLLBACK wraps the inserts in a database transaction. If any insert fails, all changes are rolled back so you never end up with orphaned rows.
- The loop inserts every quote into the quotes table and captures the RETURNING id of the selected quote. This ID becomes the foreign key in the routes table.
- The execution_states row starts with status quoted. In a full system, this would progress through states like pending, submitted, and confirmed as the swap executes on-chain.
- Save src/services/routingService.ts.
- Check for TypeScript errors by running:
npx tsc --noEmit
You should see no output if the types are correct. Any errors mean a typo in the file.
Seeing TypeScript errors?
- If you see Cannot find module 'pino', the Logger type comes from pino which is a dependency of fastify. Make sure you ran npm install in the previous step.
- If you see errors about BigInt, confirm your tsconfig.json has "target": "ES2022" or later.
- Double-check that all import paths end with .js. TypeScript ESM requires the .js extension even though the source files are .ts.
help me debug TypeScript errors in my routing service
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/services/routingService.ts before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/services/routingService.ts file:
import { randomUUID } from 'crypto';
import { pool } from '../db/pool.js';
import { fetchZeroXQuote } from './zeroXService.js';
import { fetchSushiQuote } from './sushiService.js';
import { QuoteRequest, QuoteResult, RouteResult } from './types.js';
import { Logger } from 'pino';
export async function findBestRoute(
request: QuoteRequest,
logger: Logger
): Promise<RouteResult> {
const requestId = randomUUID();
const sourcesQueried = 2;
logger.info({ requestId, request }, 'Fetching quotes from all sources');
const startTime = Date.now();
const results = await Promise.allSettled([
fetchZeroXQuote(request),
fetchSushiQuote(request),
]);
const elapsed = Date.now() - startTime;
logger.info({ requestId, elapsed }, 'All quote fetches completed');
const successfulQuotes: QuoteResult[] = [];
results.forEach((result, index) => {
const sourceName = index === 0 ? '0x' : 'sushiswap';
if (result.status === 'fulfilled') {
successfulQuotes.push(result.value);
logger.info(
{ requestId, source: sourceName, amountOut: result.value.amountOut },
'Quote received'
);
} else {
logger.warn(
{ requestId, source: sourceName, error: result.reason.message },
'Quote fetch failed'
);
}
});
if (successfulQuotes.length === 0) {
throw new Error('All quote sources failed');
}
const bestQuote = successfulQuotes.reduce((best, current) => {
const bestOut = BigInt(best.amountOut);
const currentOut = BigInt(current.amountOut);
return currentOut > bestOut ? current : best;
});
const alternatives = successfulQuotes.filter((q) => q !== bestQuote);
const selectionReason = `Highest output amount from ${bestQuote.source}`;
logger.info(
{ requestId, bestSource: bestQuote.source, bestAmountOut: bestQuote.amountOut, selectionReason },
'Route selected'
);
await persistRoute(requestId, request, bestQuote, successfulQuotes, selectionReason);
return {
requestId,
bestQuote,
alternatives,
sourcesQueried,
sourcesResponded: successfulQuotes.length,
selectionReason,
};
}
async function persistRoute(
requestId: string,
request: QuoteRequest,
bestQuote: QuoteResult,
allQuotes: QuoteResult[],
selectionReason: string
): Promise<void> {
const client = await pool.connect();
try {
await client.query('BEGIN');
let selectedQuoteId: string | null = null;
for (const quote of allQuotes) {
const result = await client.query(
`INSERT INTO quotes (request_id, source, token_in, token_out, amount_in, amount_out, gas_estimate, chain_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id`,
[
requestId,
quote.source,
quote.tokenIn,
quote.tokenOut,
quote.amountIn,
quote.amountOut,
quote.gasEstimate,
request.chainId,
]
);
if (quote === bestQuote) {
selectedQuoteId = result.rows[0].id;
}
}
const routeResult = await client.query(
`INSERT INTO routes (request_id, selected_quote_id, token_in, token_out, amount_in, best_amount_out, sources_queried, sources_responded, selection_reason)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id`,
[
requestId,
selectedQuoteId,
request.tokenIn,
request.tokenOut,
request.amount,
bestQuote.amountOut,
allQuotes.length,
allQuotes.length,
selectionReason,
]
);
await client.query(
`INSERT INTO execution_states (route_id, status)
VALUES ($1, $2)`,
[routeResult.rows[0].id, 'quoted']
);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
Your routing engine is ready. It fetches quotes concurrently, picks the best price, and saves the full decision to the database. Next up, you will wire this engine to a proper API endpoint with request validation and structured logging.
Wire Up the API Route and Server
You have a routing service that fetches quotes and persists decisions to PostgreSQL.
The missing piece is the HTTP layer that accepts incoming swap requests, validates them, and passes them to your routing engine. In this step, you will create a Fastify route with JSON Schema validation and a server entry point with structured Pino logging.
In this step, get ready to:
- Create a POST /quote route with JSON Schema request validation.
- Build the server entry point with Pino-powered structured logging.
- Start the dev server and verify database connectivity via a health endpoint.
Create the POST /quote route with JSON Schema validation
Fastify has built-in JSON Schema validation. You define the shape of valid requests as a schema object, and Fastify automatically rejects anything that does not match before your handler even runs.
- Create a new file at src/routes/quoteRoute.ts.
- Add the imports and validation schemas by pasting the following code:
import { FastifyInstance } from 'fastify';
import { findBestRoute } from '../services/routingService.js';
import { QuoteRequest } from '../services/types.js';
// Validates incoming request body shape
const quoteBodySchema = {
type: 'object',
required: ['tokenIn', 'tokenOut', 'amount', 'chainId'],
properties: {
tokenIn: { type: 'string', pattern: '^0x[a-fA-F0-9]{40}$' },
tokenOut: { type: 'string', pattern: '^0x[a-fA-F0-9]{40}$' },
amount: { type: 'string', pattern: '^[0-9]+$' },
chainId: { type: 'number', enum: [1] },
},
};
// Defines the shape of successful responses
const quoteResponseSchema = {
type: 'object',
properties: {
requestId: { type: 'string' },
bestQuote: {
type: 'object',
properties: {
source: { type: 'string' },
tokenIn: { type: 'string' },
tokenOut: { type: 'string' },
amountIn: { type: 'string' },
amountOut: { type: 'string' },
gasEstimate: { type: ['string', 'null'] },
},
},
alternatives: {
type: 'array',
items: {
type: 'object',
properties: {
source: { type: 'string' },
amountOut: { type: 'string' },
gasEstimate: { type: ['string', 'null'] },
},
},
},
sourcesQueried: { type: 'number' },
sourcesResponded: { type: 'number' },
selectionReason: { type: 'string' },
},
};
What do these schemas do?
The quoteBodySchema enforces that tokenIn and tokenOut are valid Ethereum addresses (0x-prefixed, 40 hex characters). The amount field must be a numeric string (wei values are too large for JSON numbers). The chainId is locked to 1 (Ethereum mainnet).
The quoteResponseSchema tells Fastify what shape the response will have. Fastify uses this for serialization optimization and automatic documentation.
- Below the schemas in the same file, add the route handler function:
export async function quoteRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post<{ Body: QuoteRequest }>(
'/quote',
{
schema: {
body: quoteBodySchema,
response: { 200: quoteResponseSchema },
},
},
async (request, reply) => {
// Create a child logger scoped to this route
const logger = request.log.child({ route: '/quote' });
logger.info({ body: request.body }, 'Quote request received');
const startTime = Date.now();
// Call the routing service to fetch and compare quotes
const result = await findBestRoute(request.body, logger);
const elapsed = Date.now() - startTime;
logger.info({ elapsed, requestId: result.requestId }, 'Quote request completed');
return result;
}
);
}
What does this handler do?
The handler creates a child logger from request.log. Every log line from this request automatically includes { route: '/quote' } so you can filter logs per endpoint.
It passes both the request body and the logger to findBestRoute. The routing service uses that logger for its own tracing, creating a continuous log trail from HTTP request to database write.
- Save src/routes/quoteRoute.ts.
Seeing a TypeScript error on request.log?
Make sure the import at the top uses FastifyInstance from 'fastify'. Fastify v5 includes Pino types automatically, so request.log and .child() are fully typed.
help me fix a TypeScript error in my quoteRoute.ts file
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/routes/quoteRoute.ts before continuing.
ⓧ I'd like to double check the full code
import { FastifyInstance } from 'fastify';
import { findBestRoute } from '../services/routingService.js';
import { QuoteRequest } from '../services/types.js';
const quoteBodySchema = {
type: 'object',
required: ['tokenIn', 'tokenOut', 'amount', 'chainId'],
properties: {
tokenIn: { type: 'string', pattern: '^0x[a-fA-F0-9]{40}$' },
tokenOut: { type: 'string', pattern: '^0x[a-fA-F0-9]{40}$' },
amount: { type: 'string', pattern: '^[0-9]+$' },
chainId: { type: 'number', enum: [1] },
},
};
const quoteResponseSchema = {
type: 'object',
properties: {
requestId: { type: 'string' },
bestQuote: {
type: 'object',
properties: {
source: { type: 'string' },
tokenIn: { type: 'string' },
tokenOut: { type: 'string' },
amountIn: { type: 'string' },
amountOut: { type: 'string' },
gasEstimate: { type: ['string', 'null'] },
},
},
alternatives: {
type: 'array',
items: {
type: 'object',
properties: {
source: { type: 'string' },
amountOut: { type: 'string' },
gasEstimate: { type: ['string', 'null'] },
},
},
},
sourcesQueried: { type: 'number' },
sourcesResponded: { type: 'number' },
selectionReason: { type: 'string' },
},
};
export async function quoteRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post<{ Body: QuoteRequest }>(
'/quote',
{
schema: {
body: quoteBodySchema,
response: { 200: quoteResponseSchema },
},
},
async (request, reply) => {
const logger = request.log.child({ route: '/quote' });
logger.info({ body: request.body }, 'Quote request received');
const startTime = Date.now();
const result = await findBestRoute(request.body, logger);
const elapsed = Date.now() - startTime;
logger.info({ elapsed, requestId: result.requestId }, 'Quote request completed');
return result;
}
);
}
Create the server entry point with structured logging
Fastify uses Pino as its built-in logger. You configure it once at server creation, and every request automatically gets structured JSON log output with timing, status codes, and route metadata.
- Create a new file at src/server.ts and paste the following code:
import Fastify from 'fastify';
import { config } from './config/env.js';
import { quoteRoutes } from './routes/quoteRoute.js';
import { pool } from './db/pool.js';
// Create the Fastify instance with Pino structured logging
const fastify = Fastify({
logger: {
level: process.env.LOG_LEVEL || 'info',
transport:
process.env.NODE_ENV === 'development'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
},
});
// Register the quote route plugin
fastify.register(quoteRoutes);
// Health check endpoint to verify database connectivity
fastify.get('/health', async () => {
const dbCheck = await pool.query('SELECT 1');
return { status: 'ok', database: dbCheck.rowCount === 1 ? 'connected' : 'error' };
});
// Start the server
const start = async () => {
try {
await fastify.listen({ port: config.port, host: config.host });
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
What does this server setup do?
- The logger.transport block uses pino-pretty in development mode for human-readable colored output. In production, it outputs raw JSON for log aggregation tools.
- The /health endpoint runs a trivial SELECT 1 query to confirm the database connection pool is alive.
- The fastify.register(quoteRoutes) call loads your route as a plugin. Fastify's plugin system encapsulates routes and decorators cleanly.
Why pino-pretty only in development?
Pretty-printing adds CPU overhead to format and colorize each log line. In production, raw JSON logs are faster to write and easier for tools like Datadog or ELK to ingest. The conditional transport gives you the best of both worlds.
- Save src/server.ts.
Seeing 'Cannot find module' errors?
Check that every import path ends with .js (not .ts). TypeScript ESM resolution requires the .js extension even though the source files are .ts.
help me fix module resolution errors in my server.ts
✔️ Awesome, I've got everything!
Great. Double check you saved src/server.ts before continuing.
ⓧ I'd like to double check the full code
import Fastify from 'fastify';
import { config } from './config/env.js';
import { quoteRoutes } from './routes/quoteRoute.js';
import { pool } from './db/pool.js';
const fastify = Fastify({
logger: {
level: process.env.LOG_LEVEL || 'info',
transport:
process.env.NODE_ENV === 'development'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
},
});
fastify.register(quoteRoutes);
fastify.get('/health', async () => {
const dbCheck = await pool.query('SELECT 1');
return { status: 'ok', database: dbCheck.rowCount === 1 ? 'connected' : 'error' };
});
const start = async () => {
try {
await fastify.listen({ port: config.port, host: config.host });
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
Start the dev server and verify the health endpoint
Your package.json already has a dev script that uses tsx watch to run and auto-reload your server on file changes.
- Make sure your PostgreSQL container is running in WSL. If it stopped, restart it by running this in your WSL terminal:
docker start swap-routing-db
- Start your dev server by running:
npm run dev
You should see colorized Pino output showing the server started on port 3000.
- Open a new terminal tab and test the health endpoint by running:
curl http://localhost:3000/health
You should see a JSON response: {"status":"ok","database":"connected"}.
Seeing database: 'error' in the health response?
This means the PostgreSQL container is not reachable. Confirm it is running with docker ps in WSL. You should see swap-routing-db listed with port 5432 mapped.
Also check that the DB_HOST, DB_PORT, and DB_PASSWORD values in your .env match what you used when creating the container.
help me debug my database connection
Your API is live and connected to the database. Next up, you will send a real swap quote request and watch the entire pipeline fetch, compare, and persist quotes from multiple DEX sources.
Secret mission
Add Response Caching for Repeated Quotes
Your API works, but every request hits external DEX APIs even when the same swap was just quoted seconds ago. Real aggregators cache recent quotes to serve repeated requests instantly. Can you build an in-memory cache with TTL that cuts response times to sub-millisecond?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing cloud costs. The only resource using system memory is your PostgreSQL Docker container.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- PostgreSQL Docker container (swap-routing-db).
- Node.js project folder (dex-swap-routing-api).
- 0x API account and API key.
- Environment file (.env) with database credentials.
Keep everything running
No action needed. Choose this if you are still actively building or want to continue developing the API.
- Your PostgreSQL container stays running and accessible on port 5432.
- Your project files remain on your Desktop.
- Your 0x API key stays active for future requests.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your project files and container data stay intact so you can resume later.
- Stop the Fastify dev server by pressing Ctrl+C in the terminal where it is running.
- Stop the PostgreSQL container by running this command in your WSL terminal:
docker stop swap-routing-db
- To restart later, run docker start swap-routing-db in WSL and then start the dev server again.
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes the database container and its data.
- Stop and remove the PostgreSQL container by running these commands in your WSL terminal:
docker stop swap-routing-db
docker rm swap-routing-db
- Delete the project folder from your Desktop.
- Optionally, revoke your 0x API key at dashboard.0x.org if you no longer need it.
That's a Wrap!
That's a Wrap!
Nice work! You just built the backend engine that powers DEX aggregators like 1inch and Paraswap.
You've learned how to:
- Build a high-performance Fastify API with TypeScript and JSON Schema validation that fetches swap quotes from multiple Ethereum DEX sources concurrently using Promise.allSettled.
- Implement a route selection engine that compares output amounts across liquidity sources and persists the full decision trail to PostgreSQL with proper transaction handling.
- Add production-grade structured logging with Pino child loggers to trace quote fetch timings, source responses, and selection reasons across the full request lifecycle.
- Secret Mission: Added an in-memory cache layer with TTL for sub-millisecond repeated quote responses.
Ready to quiz yourself?