Voltar para o blog
Using the Builder Pattern to create API calls with a RequestBuilder in TypeScript
A pragmatic approach to standardize HTTP calls in modern apps (React/Next.js/BFF), separating request construction (RequestBuilder) from execution (Axios, retry, token refresh).
Este artigo está disponível só em inglês.
In modern applications — especially React / Next.js frontends and BFF (Backend for Frontend) architectures — the HTTP layer tends to grow with cross-cutting requirements: dynamic headers, authentication, token refresh, retries, timeouts, pagination, optional filters, idempotency, and observability.
The problem is not making a request. The problem is keeping code readable and evolvable when “a simple GET call” turns into a set of decisions.
In this article, I present a pragmatic approach using the Builder Pattern to build HTTP requests via a RequestBuilder (TypeScript), keeping a clear separation between:
- Builder (request construction)
- HTTP executor (Axios, interceptors, retry, refresh)
The real problem
The most common anti-pattern I see is centralizing everything into a single function with a long list of optional parameters.
Anti-pattern
await httpRequest('users', 'GET', {
query: { page: 1, search: undefined },
headers: { 'x-source': 'web' },
timeoutMs: 8000,
retries: 3,
retryDelayMs: 200,
refreshTokenOn401: true,
baseURLOverride: process.env.EXTERNAL_API,
})It works, but it quickly degrades in three ways:
- Unstable signatures: every new feature becomes “just one more option”.
- Hard to read: you need the whole contract in your head to use 10% of it.
- Cascade effect: HTTP infrastructure changes leak into services and UI code.
Builder Pattern (goal)
The Builder is a creational pattern that helps when you need to build a complex object step by step, with many option combinations and well-defined defaults.
Applied to HTTP requests, the complex object is a RequestDescriptor: URL, method, headers, query params, body, timeout, and execution metadata (retry, auth strategy, etc.).
When it makes sense
This is not “pattern for pattern’s sake”. It pays off when you have combinatorial complexity, not just volume.
- Many optional knobs (headers, params, timeout, baseURL override)
- Cross-cutting concerns (auth, retry, refresh token, tracing)
- Need for standardization (every service builds URLs/params the same way)
- BFF projects or multiple integrations (internal + external APIs)
Solution architecture (Builder vs executor)
The key architectural point is not coupling the Builder to Axios. The Builder expresses intent; the HTTP layer executes.
- RequestBuilder: produces a typed `RequestDescriptor`.
- HttpClient: applies interceptors (token), retry/refresh policies and executes `axios.request`.
RequestBuilder structure
The base is a Builder with internal mutable state (expected for builders) and a fluent interface.
RequestBuilder.ts
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
export type QueryValue = string | number | boolean | null | undefined
export type QueryParams = Record<string, QueryValue>
export type HeadersMap = Record<string, string>
export interface RequestDescriptor<TResponse = unknown> {
method: HttpMethod
url: string
headers: HeadersMap
query: QueryParams
body?: unknown
timeoutMs?: number
// Execution metadata (not Axios options directly)
retries?: number
requireAuth?: boolean
}
export class RequestBuilder<TResponse = unknown> {
private descriptor: RequestDescriptor<TResponse>
constructor() {
this.descriptor = {
method: 'GET',
url: '',
headers: {},
query: {},
retries: 0,
requireAuth: true,
}
}
setUrl(url: string) {
this.descriptor.url = url
return this
}
setMethod(method: HttpMethod) {
this.descriptor.method = method
return this
}
addHeader(key: string, value: string) {
this.descriptor.headers[key] = value
return this
}
setBody(body: unknown) {
this.descriptor.body = body
return this
}
setTimeout(timeoutMs: number) {
this.descriptor.timeoutMs = timeoutMs
return this
}
setRetries(retries: number) {
this.descriptor.retries = retries
return this
}
requireAuth(requireAuth: boolean) {
this.descriptor.requireAuth = requireAuth
return this
}
addQueryParam(key: string, value: QueryValue) {
this.descriptor.query[key] = value
return this
}
addQueryParams(params: QueryParams) {
Object.entries(params).forEach(([k, v]) => {
this.addQueryParam(k, v)
})
return this
}
build(): RequestDescriptor<TResponse> {
if (!this.descriptor.url) {
throw new Error('RequestBuilder: url is required')
}
return {
...this.descriptor,
headers: { ...this.descriptor.headers },
query: { ...this.descriptor.query },
}
}
}Notice `build()` returns an immutable object (copies of headers/query). This avoids side effects if the same builder is accidentally reused.
Query params (the sharp edge)
In real projects, query params are a constant source of bugs: `undefined` turning into a string, optional filters generating inconsistent URLs, and duplicated rules.
The rule I follow: the builder accepts `undefined`, but URL serialization must exclude `undefined` and `null`.
buildUrlWithQuery.ts
import { QueryParams } from './RequestBuilder'
export function buildUrlWithQuery(url: string, query: QueryParams): string {
const entries = Object.entries(query).filter(([, v]) => v !== undefined && v !== null)
if (entries.length === 0) return url
const params = new URLSearchParams()
for (const [k, v] of entries) {
params.append(k, String(v))
}
const separator = url.includes('?') ? '&' : '?'
return url + separator + params.toString()
}Centralized HTTP executor
This is where Axios, interceptors and resilience policies live. The Builder knows nothing about them.
httpClient.ts (Axios + refresh + retry)
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from 'axios'
import { RequestDescriptor } from './RequestBuilder'
import { buildUrlWithQuery } from './buildUrlWithQuery'
type TokenProvider = () => Promise<string | null>
type RefreshToken = () => Promise<void>
function isUnauthorized(error: unknown): boolean {
return axios.isAxiosError(error) && error.response?.status === 401
}
async function withRetry<T>(
fn: () => Promise<T>,
retries: number,
delayMs: number,
): Promise<T> {
let attempt = 0
// simple retry, usually enough for most BFF/frontends
while (true) {
try {
return await fn()
} catch (err) {
attempt += 1
if (attempt > retries) throw err
await new Promise((r) => setTimeout(r, delayMs))
}
}
}
export class HttpClient {
private axios: AxiosInstance
private getToken: TokenProvider
private refreshToken: RefreshToken
constructor(params: {
baseURL: string
getToken: TokenProvider
refreshToken: RefreshToken
}) {
this.getToken = params.getToken
this.refreshToken = params.refreshToken
this.axios = axios.create({
baseURL: params.baseURL,
})
// Auth interceptor: inject token only when the request requires it
this.axios.interceptors.request.use(async (config) => {
const requireAuth = (config.headers as any)?.['x-require-auth'] === 'true'
if (!requireAuth) return config
const token = await this.getToken()
if (token) {
config.headers = {
...(config.headers || {}),
Authorization: 'Bearer ' + token,
}
}
return config
})
}
async request<TResponse>(descriptor: RequestDescriptor<TResponse>): Promise<TResponse> {
const url = buildUrlWithQuery(descriptor.url, descriptor.query)
const config: AxiosRequestConfig = {
url,
method: descriptor.method,
headers: {
...descriptor.headers,
// internal flag, consumed by the interceptor
'x-require-auth': descriptor.requireAuth ? 'true' : 'false',
},
data: descriptor.body,
timeout: descriptor.timeoutMs,
}
const exec = async () => {
try {
const res = await this.axios.request<TResponse>(config)
return res.data
} catch (err) {
if (isUnauthorized(err) && descriptor.requireAuth) {
await this.refreshToken()
const res = await this.axios.request<TResponse>(config)
return res.data
}
throw err
}
}
return withRetry(exec, descriptor.retries ?? 0, 200)
}
}This design makes the contract explicit:
- the Builder defines what will be called
- the HttpClient decides how to execute with auth/resilience
Practical example (service getAll)
In services, the Builder improves readability because the code reads as intent, not as a bag of options.
usersService.ts
type User = { id: string; name: string }
type GetAllUsersQuery = {
page?: number
search?: string
active?: boolean
}
export class UsersService {
constructor(private http: HttpClient) {}
async getAll(query: GetAllUsersQuery = {}): Promise<User[]> {
const descriptor = new RequestBuilder<User[]>()
.setUrl('/users')
.setMethod('GET')
.addQueryParams({
page: query.page,
search: query.search,
active: query.active,
})
.setTimeout(8000)
.setRetries(2)
.build()
return this.http.request(descriptor)
}
}The result is a service with low “accidental complexity”: it knows the endpoint domain, not the infrastructure.
Why this improves maintainability
- Localized evolution: adding `cache`, `idempotencyKey`, `traceId` becomes a new builder method.
- Standardization: services build requests in a uniform way.
- Testability: you can test the `RequestDescriptor` (build) separately from the executor.
- Clear separation: Axios does not leak into the domain layer.
Advantages (no marketing)
- Reduces huge signatures and scattered conditionals
- Enables cross-cutting features (retry/refresh) without duplication
- Creates an internal “request language” the team understands
- Better observability: the executor can log `method/url` consistently
Drawbacks and watch-outs
- Overengineering: in small apps it can be cost without payoff.
- Typing: excessive genericity becomes noise without discipline.
- Mutable state: builders should be treated as single-use objects.
- Axios integration: be deliberate about what is a “request option” vs a “client policy”.
When to use vs avoid
Use it when the product requires consistency and resilience across multiple integrations (especially with BFF and Next.js).
Avoid it when:
- You have few calls and low parameter variability
- The team is not ready to maintain a centralized HTTP infrastructure
- The problem is better solved with a well-defined client + small functions (no builder)
Conclusion
The Builder Pattern applied to requests is not about “making it look pretty”; it is about reducing accidental complexity and preventing execution details (Axios, retry, refresh) from contaminating the domain.
In projects that must scale safely — especially React/Next.js with a BFF layer — a well-designed `RequestBuilder` becomes an architectural stability point: the team evolves policies in the executor while keeping services predictable.
Useful references: Axios | Builder Pattern
If you found this guide helpful, feel free to share it and if you have any questions or comments just contact me. Your journey to better API calls starts here! 🚀