Un constructor de consultas inmutable basado en cierres para Angular HttpClient
Sustituya el código genérico repetido de HttpParams por un pequeño constructor de solicitudes basado en cierres, conectado a través de un InjectionToken, que permanezca inmutable, tipado y fácil de probar.
Fuentes: Stop Hand-Wiring HttpParams: Build a Fluent, Type-Safe Request Builder in Angular
Las grandes bases de código Angular tienden a acumular esas mismas pocas líneas en docenas de servicios: se crea un HttpParams, se establece el filtro, el orden de clasificación y la página, y finalmente se realiza la solicitud. Cada copia es ligeramente diferente, y esas pequeñas diferencias generan nombres incoherentes para los parámetros de consulta y convenciones de ordenación entre los equipos.
Esta guía muestra cómo reemplazar esa repetición con un constructor de solicitudes eficiente construido únicamente a partir de cierres. No necesita clases, decoradores ni herencia; se expone una única función de fábrica a través de un InjectionToken y se resuelve con inject(). Verá cómo funciona, por qué es inmutable por diseño, cómo probarlo sin TestBed, y cuándo no vale la pena añadirlo.
El código genérico que se va a reemplazar
Aquí hay un endpoint de lista típico escrito a mano.
getUsers(status: string, sortField: string, page: number, size: number) {
let params = new HttpParams()
.set('status', status)
.set('sort', sortField)
.set('direction', 'asc')
.set('page', page)
.set('size', size);
return this.http.get<User[]>('/api/users', { params });
}
No hay nada incorrecto en ello, pero no escala bien. Un servicio denomina el tamaño de la página pageSize, otro utiliza size; un equipo ordena con sort más direction, otro codifica ambos en un único parámetro. Cada nuevo endpoint añade otro bloque de llamadas a HttpParams antes de la parte que realmente importa. El objetivo es un lugar de llamadas que se lea como una descripción de la consulta:
loadUsers() {
return this.request<User[]>('/api/users')
.where('status', 'active')
.sortBy('name')
.paginate(0, 20)
.execute();
}
Cada llamada en esa cadena devuelve un valor nuevo e independiente, y la función final execute() genera el resultado observable. El resto de esta guía explica los componentes que lo hacen posible.
Una fábrica que se devuelve a sí misma
Todo el constructor consta de unas veinte líneas.
import { Observable } from 'rxjs';
import { HttpClient, HttpParams } from '@angular/common/http';
type SortDirection = 'asc' | 'desc';
interface RequestBuilder<T> {
where(key: string, value: string | number | boolean): RequestBuilder<T>;
sortBy(field: string, direction?: SortDirection): RequestBuilder<T>;
paginate(page: number, size: number): RequestBuilder<T>;
execute(): Observable<T>;
}
export function createRequest(http: HttpClient) {
const build = <T>(url: string, params = new HttpParams()): RequestBuilder<T> => ({
where: (key, value) => build<T>(url, params.set(key, String(value))),
sortBy: (field, direction = 'asc') =>
build<T>(url, params.set('sort', field).set('direction', direction)),
paginate: (page, size) =>
build<T>(url, params.set('page', page).set('size', size)),
execute: () => http.get<T>(url, { params }),
});
return build;
}
El truco clave: al llamar a build se obtiene un objeto, y cada método de ese objeto llama a build una vez más. Cada paso en la cadena crea un constructor nuevo que utiliza los HttpParams actualizados. El constructor anterior nunca se modifica.
Esto funciona de manera tan eficiente porque HttpParams ya es inmutable: params.set() deja intacta la instancia original y devuelve una nueva con la entrada adicional. El constructor simplemente eleva ese atributo un nivel, de modo que los objetos constructor hereden la misma garantía. where convierte los valores en cadenas de texto, mientras que sortBy escribe las claves sort y direction (con valor por defecto ascendente) y paginate escribe page y size. Solo execute() interactúa con la red, al llamar a http.get con los parámetros acumulados.
También vale la pena observar lo que falta. No hay this, ni campo privado, ni class. Todo el estado se encuentra en los argumentos de la función capturados por cierres, lo que impide que algo cambie accidentalmente y no deja nada que rastrear fuera de la llamada actual. Si los cierres que capturan argumentos le resultan desconocidos, la explicación de cómo la cadena de ámbitos de JavaScript resuelve las variables aborda el mecanismo subyacente.
Exponer el constructor mediante inyección de dependencias
Una función que recibe HttpClient es útil, pero los componentes deberían poder inyectarlo como cualquier otra dependencia. Un InjectionToken basado en fábricas está diseñado precisamente para esto.
import { InjectionToken, inject } from '@angular/core';
export const REQUEST_BUILDER = new InjectionToken<ReturnType<typeof createRequest>>(
'REQUEST_BUILDER',
{
providedIn: 'root',
factory: () => createRequest(inject(HttpClient)),
}
);
Hay dos detalles que merecen atención.
Deje que el compilador derive el tipo
ReturnType<typeof createRequest> significa que el tipo del constructor nunca se escribe a mano. Cuando createRequest adquiere un método, el tipo del token se establece automáticamente, y todos los consumidores perciben ese cambio.
Resolver HttpClient cuando existe un contexto de inyección
inject(HttpClient) se ejecuta dentro de la fábrica del token, y dicha fábrica siempre se ejecuta en un contexto de inyección. Ese es el lugar adecuado para ello. Un error frecuente es llamar a inject() desde un método ordinario o una función de callback que se ejecuta fuera de cualquier contexto de inyección; en ese caso Angular lanza el error NG0203 en tiempo de ejecución. Resolver HttpClient una sola vez, al crear el token, y almacenarlo en la cierre de funciones permite que el constructor resultante se utilice posteriormente en cualquier lugar: dentro de efectos, operadores RxJS o funciones de utilidad simples.
Usarlo desde un componente
Un componente inyecta el token en un campo y lo llama como si fuera una función.
@Component({
selector: 'app-users',
template: `...`,
})
export class UsersComponent {
private readonly request = inject(REQUEST_BUILDER);
loadUsers() {
return this.request<User[]>('/api/users')
.where('status', 'active')
.sortBy('name')
.paginate(0, 20)
.execute();
}
}
El argumento genérico proporcionado al inicio, User[], se transmite a lo largo de toda la cadena, por lo que execute() devuelve Observable<User[]> sin necesidad de ningún casting.
Patrones que violan la inmutabilidad
Búsqueda reactiva con señales
Dado que un constructor es un valor barato e inmutable, está perfectamente bien crear uno nuevo cada vez que cambian las entradas reactivas. Aquí, un recurso controlado por señales reconstruye la consulta a partir del texto de búsqueda y página actuales.
export class ProductSearchComponent {
private readonly request = inject(REQUEST_BUILDER);
readonly query = signal('');
readonly page = signal(0);
readonly products = rxResource({
params: () => ({ q: this.query(), page: this.page() }),
stream: ({ params }) =>
this.request<Product[]>('/api/products')
.where('q', params.q)
.where('inStock', true)
.sortBy('price', 'desc')
.paginate(params.page, 24)
.execute(),
});
}
Cada vez que query o page cambian, la función params genera un nuevo objeto, y la función stream crea una nueva solicitud a partir de él. No existe un constructor compartido que se pueda reiniciar entre ejecuciones. Tenga en cuenta que rxResource y los nombres de sus opciones han cambiado en las versiones recientes de Angular (las versiones anteriores utilizaban nombres de propiedad diferentes), por lo que debe consultar la API de la versión que está utilizando.
Cadenado condicional
Dado que cada paso devuelve un valor en lugar de modificar uno, se pueden aplicar filtros opcionales reasignando una variable local.
loadOrders(filter: OrderFilter) {
let builder = this.request<Order[]>('/api/orders')
.paginate(filter.page, filter.size);
if (filter.status) {
builder = builder.where('status', filter.status);
}
if (filter.sortField) {
builder = builder.sortBy(filter.sortField, filter.direction);
}
return builder.execute();
}
Solo se vuelve a asignar la variable builder; los propios objetos constructor no cambian nunca. Cada rama simplemente sustituye por un nuevo constructor ampliado.
Pruebas sin TestBed
createRequest es una función simple de HttpClient, por lo que una prueba unitaria no necesita en absoluto un módulo de pruebas de Angular. Basta con un pequeño stub que registre los parámetros que recibe.
it('accumulates params immutably', () => {
const captured: HttpParams[] = [];
const httpStub = {
get: (_url: string, opts: { params: HttpParams }) => {
captured.push(opts.params);
return of([]);
},
} as unknown as HttpClient;
const request = createRequest(httpStub);
const base = request<unknown[]>('/api/items').where('a', 1);
base.where('b', 2).execute();
base.where('c', 3).execute();
expect(captured[0].get('b')).toBe('2');
expect(captured[0].get('c')).toBeNull(); // branches don't leak into each other
expect(captured[1].get('c')).toBe('3');
});
Las afirmaciones finales muestran las ventajas de la inmutabilidad. Las cadenas que se desvían a partir de un mismo constructor base permanecen completamente aisladas: el parámetro b de la primera cadena nunca aparece en la segunda, y viceversa. Con un constructor mutable, la segunda llamada heredaría silenciosamente el filtro de la primera.
Por qué este diseño funciona
- Inmutable por diseño. No existe ningún estado interno que pueda corromperse. Reutilizar una solicitud parcialmente construida, por ejemplo una consulta base para usuarios activos y otra para usuarios archivados, es seguro por defecto. Un constructor basado en clases mutable lo hace mal sin ninguna advertencia.
sort, direction, page y size están definidas en un solo lugar. Cuando el backend renombra una de ellas, basta con modificar una sola línea en lugar de tener que buscar entre cuarenta servicios diferentes. El constructor se convierte así en el contrato entre la interfaz de usuario y las convenciones de consulta de la API.execute(). Los constructores basados en clases suelen obligar a pasar los genéricos nuevamente por cada capa de herencia.HttpClient dentro de un contexto de inyección garantizado, evitando toda la serie de errores relacionados con “inject() llamado fuera de un contexto de inyección” que aparecen cuando los constructores se crean de forma perezosa en callbacks.providedIn: 'root' se descarta del paquete cuando nadie lo utiliza. El núcleo es una función sencilla, por lo que las pruebas no necesitan ni TestBed.configureTestingModule ni HttpTestingController, a menos que se desee específicamente una cobertura a nivel de integración.execute() termina llamando a HttpClient.get, por lo que los encabezados de autenticación, la lógica de reintentos y el mapeo de errores en su cadena de interceptores se aplican sin cambios. El constructor funciona sobre HttpClient; no lo reemplaza.Compromisos a considerar
- Las claves son simples cadenas de texto.
where('status', 'active')no se valida contra la API. Un error de escritura en la clave se compila y solo falla en tiempo de ejecución. Restringir las claves con un tipo genérico, comowhere<K extends keyof UserQuery>, soluciona este problema, pero a costa de tipos más complejos; decida si su equipo lo necesita. - Sobrescritura por llamadas repetidas.
HttpParams.setreemplaza un valor existente, por lo que al llamar asortBy()dos veces solo se mantiene la segunda llamada. Por lo general, eso es lo que se desea para el ordenamiento, pero sorprende a quienes esperan parámetros con múltiples valores. Para claves repetidas como?tag=a&tag=b, añada un métodowhereAllque utiliceparams.append. - Intencionalmente solo GET. El constructor compone cadenas de consulta. Los cuerpos de solicitud, los encabezados y las opciones de tipo de respuesta están fuera del alcance. Puede añadir métodos como
withHeadersopost(body), pero cada adición tiende a reimplantar las opciones que ya tiene el objetoHttpClient. Sepa cuándo detenerse. - Un nuevo objeto por paso. Cada llamada en cadena asigna un constructor y un
HttpParams. Se trata de objetos pequeños y de corta duración que los motores de JavaScript recogen sin coste elevado, por lo que en la práctica el costo es insignificante, pero merece ser tenido en cuenta. - Otra abstracción más que aprender. Los nuevos miembros del equipo deben descubrir que existe
REQUEST_BUILDERy cuál es su función. En una aplicación con dos puntos finales, llamar directamente aHttpClientes más sencillo. Este patrón resulta útil en bases de código con muchos puntos finales de tipo lista y tabla que comparten reglas de paginación y ordenamiento, lo cual describe una gran parte de las aplicaciones empresariales desarrolladas con Angular.
Maneras de extenderlo
Una vez establecida la versión básica, son naturales algunas extensiones.
Estructuras de consulta tipadas por punto final
Un segundo parámetro genérico puede describir qué claves y valores acepta un endpoint, convirtiendo una clave escrita incorrectamente en un error en tiempo de compilación.
interface UserQuery {
status: 'active' | 'archived';
role: string;
}
const users = this.request<User[], UserQuery>('/api/users')
.where('status', 'active'); // 'staus' would now be a compile error
Este fragmento muestra el lugar donde se debe realizar la llamada. Para soportarlo, createRequest y la interfaz RequestBuilder necesitan un segundo parámetro de tipo, y where debe especificarse en términos de keyof, que representa ese tipo de clave y sus tipos de valor.
URL base específicas del entorno
También se debe resolver un token BASE_API_URL en la fábrica de tokens, y añadirlo al principio de cada ruta que recibe el generador. Los componentes siguen pasando rutas relativas y nunca manejan la configuración del entorno.
Adaptadores de respuesta
Una etapa map puede reconfigurar la respuesta del servidor dentro del generador, lo que significa que los componentes reciben modelos listos para ser mostrados en lugar de estructuras API en bruto.
Conclusión
El generador de solicitudes basado en cierres ofrece mucho con muy poco código. Unas veinte líneas generan una capa inmutable, tipada e injetable que estandariza la forma en que toda la aplicación se comunica con los endpoints paginados. Solo depende de lo que Angular ya proporciona: la inmutabilidad de HttpParams, los tokens de inyección basados en fábricas y inject(), sin necesidad de mecanismos adicionales en tiempo de ejecución.
Como ocurre con cualquier abstracción, existe un punto de equilibrio. Para un pequeño número de endpoints, las llamadas simples a HttpClient son más claras. Más allá de eso, el generador convierte las convenciones de cadenas de consulta, que la gente debe recordar, en un contrato que el compilador verifica por usted.
Lecturas relacionadas
- Construyendo un módulo de React basado en características: Posts CRUD con TanStack Query — Reestructurar una aplicación React por características en lugar de por tipo de archivo, conectar la funcionalidad completa de posts CRUD con TanStack Query y axios, y observar hasta dónde llega la escalabilidad de este patrón.
- Diseñando para solicitudes fallidas en Angular: Estados, interceptores y reintentos — Aprender a clasificar los fallos HTTP en Angular, organizar adecuadamente el estado de carga, centralizar el manejo en interceptores, reintentar de forma segura y mostrar mensajes con los que los usuarios puedan actuar.