Catálogo de patrones y antipatrones
Catálogo de patrones y antipatrones
Los trece capítulos anteriores desarrollaron cada tema en profundidad. Este es distinto: es el mapa consolidado. No enseña conceptos nuevos, los ordena en un formato que podés tener abierto mientras revisás un pull request. Si tuvieras que dejar una sola pestaña abierta durante un code review de Angular 22, que sea esta.
El capítulo tiene tres partes:
- Catálogo de patrones: lo que querés ver en el código, con el problema que resuelve cada uno.
- Catálogo de antipatrones: en formato síntoma → por qué duele → refactor, con enlace al capítulo donde se trata a fondo.
- Señales de alarma (code smells): métricas y olores que delatan deuda técnica antes de que te explote.
La idea es que uses esto como una lista de verificación. Ante cada archivo que revisás, preguntate: ¿esto está en la columna de patrones o en la de antipatrones?
Parte 1: catálogo de patrones recomendados
Estos son los patrones que, aplicados con criterio, mantienen un proyecto Angular 22 rápido, testeable y con poca superficie de acoplamiento. La última columna dice cuándo aplicarlo, porque ningún patrón es gratis: cada uno resuelve un problema concreto y agregarlo sin ese problema es sobreingeniería.
| Patrón | Qué es | Problema que resuelve | Cuándo aplicarlo |
|---|---|---|---|
| Smart / dumb components | Contenedores que orquestan datos y estado vs. presentacionales que solo reciben input() y emiten output() | Acopla la vista a los servicios; dificulta reusar y testear la UI | Casi siempre: la mayoría de tus componentes deberían ser dumb |
| Service con signals como store | Un servicio providedIn: 'root' (o de feature) que expone signal/computed de solo lectura y muta vía métodos | Estado compartido sin acoplar a una librería pesada | Estado de dominio compartido entre rutas o componentes |
Presentacional puro con OnPush | Componente sin dependencias, salida determinista de sus entradas, ChangeDetectionStrategy.OnPush | Change detection recorre todo el árbol en cada tick | Todo componente presentacional (idealmente, todos) |
inject() + InjectionToken para IoC | Inversión de control: el componente depende de una abstracción, no de una implementación concreta | Acoplamiento fuerte a clases concretas; imposible sustituir en tests | Cuando querés poder cambiar o mockear una dependencia |
| Interceptores funcionales | HttpInterceptorFn registrados con withInterceptors() | Lógica transversal (auth, logging, retry) duplicada en cada llamada | Auth headers, manejo global de errores, retry, telemetría |
@defer para carga diferida | Bloque que carga su contenido (y su JS) según un trigger (on viewport, on interaction, on idle) | Bundle inicial inflado; LCP e INP degradados | Contenido below the fold, widgets pesados, gráficos, mapas |
httpResource / rxResource para datos | Primitiva reactiva declarativa que ata una URL/param reactivo a estado (value, status, error) | El trío manual loading/error/data con subscribe propenso a fugas | Lectura de datos derivada de estado reactivo (parámetros, filtros) |
takeUntilDestroyed() | Operador que completa el stream al destruirse el componente/contexto de inyección | Memory leaks por suscripciones que sobreviven al componente | Cualquier subscribe manual que no gestione RxJS declarativo |
| Facade | Servicio delgado que expone una API de dominio y esconde la orquestación interna | El componente conoce demasiados colaboradores | Solo cuando reduce acoplamiento real; no por defecto |
Cómo se combinan
Estos patrones no son islas: se refuerzan entre sí. El smart component inyecta un service con signals vía inject(), pasa datos a un dumb component con OnPush, y difiere lo pesado con @defer. Los datos entran por httpResource y las llamadas cruzan interceptores funcionales.
flowchart TD
Route[Ruta / Smart component] -->|inject| Store[Service con signals]
Route -->|input/output| Dumb[Dumb component OnPush]
Store -->|httpResource| HTTP[HttpClient]
HTTP -->|withInterceptors| Int[Interceptores funcionales]
Route -->|@defer on viewport| Heavy[Widget pesado lazy]
Store -.->|computed de solo lectura| Dumb
Un detalle sobre el facade: es el único de la lista que conviene mirar con desconfianza. Aporta cuando esconde una orquestación genuinamente compleja (varios servicios, coordinación de estado). Si tu “facade” solo reexporta métodos de un único servicio, es una capa de indirección sin valor: más superficie, más archivos, mismo acoplamiento. Lo desarrollamos en Inyección de dependencias.
Parte 2: catálogo de antipatrones
Cada fila sigue el formato síntoma → por qué duele → refactor. El síntoma es lo que ves en el código; el “por qué duele” es el costo técnico concreto (rendimiento, acoplamiento, testeabilidad); el refactor es la dirección de la solución. El enlace lleva al capítulo donde el tema se trata a fondo.
Tabla resumen
| # | Antipatrón | Síntoma | Por qué duele | Refactor | Capítulo |
|---|---|---|---|---|---|
| 1 | Componente-dios | Un .ts de 800 líneas con 12 responsabilidades | Imposible de testear y reusar; toca todo en cada cambio | Extraer dumb components y mover lógica a servicios | Componentes con SOLID |
| 2 | Lógica de negocio en el componente/template | Reglas de dominio dentro del .ts de la vista o en el HTML | La regla no se puede reusar ni testear sin renderizar | Mover a un servicio de dominio o computed | Componentes con SOLID |
| 3 | Funciones en bindings | {{ calcular() }} o [x]="fn()" en el template | Se ejecuta en cada change detection; O(n) llamadas por tick | Reemplazar por computed() o pipe puro | Change Detection |
| 4 | Subscribe anidado | subscribe dentro de otro subscribe | Callback hell, sin cancelación, condiciones de carrera | Aplanar con switchMap/concatMap/mergeMap | RxJS con criterio |
| 5 | effect() para derivar estado | Un effect que escribe en otro signal | Doble change detection, riesgo de bucles, estado no declarativo | Usar computed() o linkedSignal() | Signals y estado |
| 6 | Mutación sin cambio de referencia con OnPush | this.items.push(x) con OnPush | La vista no se actualiza: OnPush compara por referencia | Reemplazar la referencia (signal.update, spread) | Change Detection |
| 7 | Barrel files | index.ts que reexporta todo un directorio | Bundles inflados, imports circulares, tree-shaking roto | Importar desde la ruta concreta del símbolo | Organización del proyecto |
| 8 | Autorización solo en cliente | Ocultar un botón con @if y creer que eso “protege” | El endpoint sigue abierto; cualquiera lo llama directo | Autorizar en el backend; el cliente solo es UX | Seguridad |
| 9 | any en formularios | FormGroup<any> o form.value.foo sin tipo | Se pierde el chequeo de tipos; errores en runtime | Reactive forms tipados o Signal Forms | Formularios |
| 10 | Librerías pesadas innecesarias | Moment.js, Lodash entero, una librería de UI por un botón | +100 KB al bundle; LCP/INP peores | API nativa (Intl, structuredClone), import puntual | Librerías del ecosistema |
| 11 | Sin OnPush / con Zone.js | Componentes en estrategia Default y app con Zone.js | Change detection recorre todo el árbol en cada evento | OnPush en todo + provideZonelessChangeDetection() | Change Detection |
A continuación, los que más se repiten en revisión, con código del “antes” y el “después”.
Antipatrón 1: componente-dios
Síntoma: un componente que trae datos por HTTP, mantiene estado de UI, valida formularios, formatea fechas y maneja navegación, todo en el mismo archivo.
Por qué duele: viola SRP. Cualquier cambio obliga a entender las 800 líneas; testear una regla implica instanciar todo el componente con sus dependencias; nada se puede reusar.
// Antes (antipatrón): todo en un componente
@Component({ /* ... */ })
export class DashboardComponent {
private http = inject(HttpClient);
usuarios: Usuario[] = [];
filtro = '';
ordenAsc = true;
ngOnInit() {
this.http.get<Usuario[]>('/api/usuarios').subscribe(u => this.usuarios = u);
}
usuariosFiltrados() {
return this.usuarios
.filter(u => u.nombre.includes(this.filtro))
.sort((a, b) => this.ordenAsc ? a.edad - b.edad : b.edad - a.edad);
}
// ...200 líneas más de validación, formateo, navegación
}
// Después: el smart component orquesta; el store tiene el estado; el dumb pinta
@Injectable({ providedIn: 'root' })
export class UsuariosStore {
private http = inject(HttpClient);
private filtro = signal('');
private ordenAsc = signal(true);
readonly usuarios = httpResource<Usuario[]>(() => '/api/usuarios');
readonly visibles = computed(() =>
(this.usuarios.value() ?? [])
.filter(u => u.nombre.includes(this.filtro()))
.sort((a, b) => this.ordenAsc() ? a.edad - b.edad : b.edad - a.edad));
setFiltro(v: string) { this.filtro.set(v); }
toggleOrden() { this.ordenAsc.update(x => !x); }
}
El componente pasa a ser un smart delgado que inyecta el store y delega la vista a un dumb con OnPush. Detalle a fondo en Componentes con SOLID.
Antipatrón 3: funciones en bindings
Síntoma: llamar métodos desde la plantilla, como {{ total() }} o @for (item of ordenar()).
Por qué duele: el binding se reevalúa en cada ciclo de detección de cambios, no cuando cambian sus datos. Si el método filtra u ordena, tenés O(n) o O(n log n) ejecutándose decenas de veces por segundo ante cualquier evento del árbol.
// Antes (antipatrón): función invocada en el template
@Component({
template: `@for (u of usuariosOrdenados(); track u.id) { <app-fila [u]="u"/> }`,
})
export class Lista {
usuarios = input<Usuario[]>([]);
usuariosOrdenados() { // se ejecuta en cada CD
return [...this.usuarios()].sort((a, b) => a.edad - b.edad);
}
}
// Después: computed memoizado, recalcula solo si cambia el input
@Component({
template: `@for (u of usuariosOrdenados(); track u.id) { <app-fila [u]="u"/> }`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Lista {
usuarios = input<Usuario[]>([]);
readonly usuariosOrdenados = computed(() =>
[...this.usuarios()].sort((a, b) => a.edad - b.edad)); // memoizado
}
El computed solo recalcula cuando cambia su dependencia (usuarios()), no en cada tick. Más en Change Detection.
Antipatrón 4: subscribe anidado
Síntoma: encadenar peticiones dependientes con subscribe dentro de subscribe.
Por qué duele: no hay cancelación (si el usuario cambia de ruta, la petición interna sigue), aparecen condiciones de carrera y el flujo se vuelve imposible de leer.
// Antes (antipatrón): callback hell y sin cancelación
this.ruta.params.subscribe(p => {
this.http.get<Usuario>(`/api/usuarios/${p['id']}`).subscribe(u => {
this.http.get<Pedido[]>(`/api/pedidos?u=${u.id}`).subscribe(pedidos => {
this.pedidos = pedidos;
});
});
});
// Después: composición declarativa con switchMap y toSignal
private ruta = inject(ActivatedRoute);
private http = inject(HttpClient);
readonly pedidos = toSignal(
this.ruta.params.pipe(
switchMap(p => this.http.get<Usuario>(`/api/usuarios/${p['id']}`)),
switchMap(u => this.http.get<Pedido[]>(`/api/pedidos?u=${u.id}`)),
),
{ initialValue: [] as Pedido[] },
);
switchMap cancela la petición anterior cuando llega un nuevo parámetro, eliminando la condición de carrera. Desarrollado en RxJS con criterio.
Antipatrón 5: effect() para derivar estado
Síntoma: un effect que lee unos signals y escribe en otro.
Por qué duele: derivar con effect te saca del modelo declarativo. Provoca un segundo ciclo de detección (el effect corre después de que el estado cambió), abre la puerta a bucles de escritura y hace que el estado derivado pueda quedar temporalmente inconsistente. effect es para efectos secundarios (logging, DOM imperativo, localStorage), no para calcular estado.
// Antes (antipatrón): effect que deriva estado
readonly precio = signal(100);
readonly cantidad = signal(2);
readonly total = signal(0);
constructor() {
effect(() => this.total.set(this.precio() * this.cantidad())); // deriva con effect
}
// Después: computed, declarativo y sin ciclo extra
readonly precio = signal(100);
readonly cantidad = signal(2);
readonly total = computed(() => this.precio() * this.cantidad());
Si necesitás un valor derivado que también sea escribible (por ejemplo, una selección que se resetea al cambiar la lista), ese es el caso de linkedSignal(), no de effect. Todo el detalle en Signals y estado reactivo.
Antipatrón 6: mutación sin cambio de referencia con OnPush
Síntoma: mutar un array u objeto en su lugar (push, splice, asignar una propiedad) y esperar que la vista se actualice bajo OnPush o signals.
Por qué duele: OnPush y la igualdad por defecto de signals comparan por referencia. Si la referencia no cambia, Angular asume que nada cambió y no re-renderiza. El bug es silencioso: los datos están bien en memoria pero la UI muestra lo viejo.
// Antes (antipatrón): mutación en su lugar, la vista no se entera
readonly items = signal<Item[]>([]);
agregar(item: Item) {
this.items().push(item); // misma referencia -> signal no notifica
}
// Después: nueva referencia con update
readonly items = signal<Item[]>([]);
agregar(item: Item) {
this.items.update(prev => [...prev, item]); // nueva referencia -> notifica
}
La regla es simple: con OnPush y signals, tratá el estado como inmutable. Detalle en Change Detection.
Antipatrón 7: barrel files
Síntoma: un index.ts por carpeta que reexporta todo (export * from './...') para poder importar “cómodo”.
Por qué duele: rompe el tree-shaking (el bundler no puede descartar lo que no usás si todo se reexporta junto), infla el bundle, y es una fuente clásica de dependencias circulares que Angular resuelve con undefined en tiempo de carga. Nx puede incluso reportar tiempos de compilación peores.
// Antes (antipatrón): barrel que arrastra todo el directorio
// shared/index.ts
export * from './boton.component';
export * from './modal.component';
export * from './tabla.component'; // importás Boton y te viene Modal y Tabla
import { BotonComponent } from '@app/shared';
// Después: import directo al símbolo concreto
import { BotonComponent } from '@app/shared/boton.component';
Tratado junto con los límites de dependencia en Organización del proyecto.
Antipatrón 8: autorización solo en cliente
Síntoma: esconder acciones con @if (esAdmin()) y considerar que eso es “seguridad”.
Por qué duele: todo lo que corre en el navegador es inspeccionable y modificable. El @if es experiencia de usuario, no un control de acceso. El endpoint sigue expuesto: cualquiera con las DevTools abiertas llama a DELETE /api/usuarios/1 directamente.
// Antes (antipatrón): la única barrera es el template
@if (usuario.rol === 'admin') {
<button (click)="borrar()">Borrar</button>
}
// Después: el cliente oculta por UX, pero el backend AUTORIZA
// Cliente: sigue ocultando el botón (buena UX)
@if (usuario.rol === 'admin') { <button (click)="borrar()">Borrar</button> }
// Backend (fuente de verdad): rechaza si el token no es admin
// @UseGuards(RolesGuard) @Roles('admin')
// DELETE /api/usuarios/:id -> 403 si no corresponde
La autorización vive en el servidor; el cliente nunca es la fuente de verdad. Ampliado en Seguridad.
Antipatrón 9: any en formularios
Síntoma: FormGroup sin tipar o acceder a form.value.campo sin que TypeScript sepa qué hay.
Por qué duele: perdés todo el chequeo de tipos justo en la frontera donde entran datos del usuario. Un renombre de campo no rompe la compilación, rompe en runtime.
// Antes (antipatrón): formulario sin tipos
form = new FormGroup({
email: new FormControl(''),
edad: new FormControl(''),
});
guardar() {
const edad: number = this.form.value.edad; // en realidad es string | null
}
// Después: reactive forms tipados (o Signal Forms)
form = new FormGroup({
email: new FormControl('', { nonNullable: true }),
edad: new FormControl(0, { nonNullable: true }),
});
guardar() {
const edad = this.form.getRawValue().edad; // number, garantizado
}
Comparativa entre reactive forms tipados y Signal Forms en Formularios.
Antipatrón 11: sin OnPush / con Zone.js
Síntoma: componentes en ChangeDetectionStrategy.Default y la aplicación arrancada con Zone.js.
Por qué duele: con Zone.js y estrategia Default, cualquier evento asíncrono (un setTimeout, un scroll, una respuesta HTTP) dispara detección de cambios que recorre todo el árbol de componentes. En un árbol grande, eso es trabajo O(n) por evento aunque solo haya cambiado un nodo.
// Antes (antipatrón): Default + Zone.js -> CD global en cada evento
bootstrapApplication(AppComponent); // Zone.js por defecto
@Component({ /* changeDetection: Default (implícito) */ })
export class Widget {}
// Después: zoneless + OnPush -> CD dirigida por signals
bootstrapApplication(AppComponent, {
providers: [provideZonelessChangeDetection()],
});
@Component({ changeDetection: ChangeDetectionStrategy.OnPush })
export class Widget {}
Sin Zone.js, la detección la disparan los signals que efectivamente cambiaron, no cualquier evento. Es el capítulo entero de Change Detection y rendimiento de runtime.
Parte 3: señales de alarma (code smells)
Los antipatrones de arriba son cosas concretas que ves. Las señales de alarma son métricas y olores que no son un error por sí solos, pero que en conjunto delatan que un archivo está acumulando deuda. Usalas como umbrales de revisión: cuando algo cruza el umbral, mirá más de cerca.
| Señal de alarma | Umbral orientativo | Qué suele indicar |
|---|---|---|
| Archivo de componente gigante | .ts > 300-400 líneas | Componente-dios; múltiples responsabilidades sin extraer |
Muchos input() | Más de ~8-10 inputs | El componente hace demasiado; falta descomponer en subcomponentes |
| Muchos servicios inyectados | Más de ~5 inject() en un componente | Demasiados colaboradores; candidato a facade o a mover lógica |
| Plantilla con lógica | Ternarios anidados, @if con expresiones complejas, métodos llamados | Lógica que debería ser computed() o pipe |
subscribe manual sin takeUntilDestroyed | Cualquiera | Memory leak potencial |
effect() que escribe signals | Cualquiera | Estado derivado imperativo; debería ser computed/linkedSignal |
any / as frecuentes | Varios en un archivo | Se está esquivando el compilador; bugs de tipo latentes |
| Métodos en bindings | Cualquiera en hot path | Recalcula en cada CD |
import * from en un index.ts | Cualquiera | Barrel; revisá tree-shaking y ciclos |
Componentes sin OnPush | En una app grande | CD global innecesaria |
Ninguno de estos números es una ley. Un componente de formulario complejo puede tener legítimamente 10 controles; lo que importa es que el umbral te haga detenerte y justificar. La pregunta no es “¿superó las 400 líneas?”, es “¿por qué las superó y sigue teniendo una sola responsabilidad?”.
Mapa mental del catálogo
mindmap
root((Code review<br/>Angular 22))
Patrones
Smart / dumb
Service con signals
inject + IoC
Interceptores funcionales
@defer
httpResource
takeUntilDestroyed
Facade con criterio
Antipatrones
Componente-dios
Lógica en template
Funciones en bindings
Subscribe anidado
effect para derivar
Mutación con OnPush
Barrel files
Auth solo cliente
any en formularios
Librerías pesadas
Sin OnPush ni zoneless
Señales de alarma
Archivos gigantes
Muchos inputs
Muchos servicios
Plantillas con lógica
Flujo de decisión en la revisión
Cuando abrís un archivo en un pull request, este es el orden de preguntas que más rápido detecta deuda:
flowchart TD
A[Abro el archivo] --> B{¿Componente > 300 líneas<br/>o muchos inputs/servicios?}
B -->|Sí| C[Sospechá componente-dios:<br/>extraer dumb + store]
B -->|No| D{¿Hay lógica en el template<br/>o funciones en bindings?}
D -->|Sí| E[Mover a computed o pipe puro]
D -->|No| F{¿Hay subscribe manual?}
F -->|Sí| G{¿takeUntilDestroyed<br/>o toSignal?}
G -->|No| H[Riesgo de leak: refactor]
G -->|Sí| I{¿effect escribe estado?}
F -->|No| I
I -->|Sí| J[Cambiar a computed / linkedSignal]
I -->|No| K{¿OnPush + zoneless<br/>+ inmutabilidad?}
K -->|No| L[Habilitar OnPush y revisar mutaciones]
K -->|Sí| M[Archivo sano ✔]
Checklist
Usá esta lista al revisar cualquier pull request de Angular 22. Si un ítem no se cumple, es un comentario de revisión, no necesariamente un bloqueo: pedí la justificación.
- Los componentes son standalone y usan el nuevo control flow (
@if/@for/@defer), sinNgModuleni*ngIf/*ngFor. - Los componentes presentacionales están en
ChangeDetectionStrategy.OnPushy la app corre conprovideZonelessChangeDetection(). - Hay separación smart / dumb: la lógica vive en servicios/stores, no en componentes de vista.
- El estado compartido usa signals en un servicio; los derivados son
computed, noeffect. -
effect()se usa solo para efectos secundarios (DOM imperativo, logging,localStorage), nunca para derivar estado. - No hay funciones ni lógica de negocio en bindings; se reemplazaron por
computedo pipes puros. - Todo
subscribemanual tienetakeUntilDestroyed()o se reemplazó portoSignal/httpResource. - No hay subscribe anidados; las peticiones dependientes se componen con
switchMap/concatMap. - Con
OnPush/signals, el estado se trata como inmutable (nueva referencia en cada cambio). - Las dependencias entran por
inject()y, donde importa sustituir, víaInjectionToken(IoC). - La lógica HTTP transversal está en interceptores funcionales, no duplicada.
- El contenido pesado o below the fold usa
@defer; los datos reactivos usanhttpResource/rxResource. - No hay barrel files; los imports apuntan al símbolo concreto.
- La autorización vive en el backend; el cliente solo oculta por UX.
- Los formularios están tipados (reactive forms tipados o Signal Forms), sin
any. - No se agregaron librerías pesadas para lo que resuelve la plataforma (
Intl,structuredClone,@defer). - Ningún archivo cruza las señales de alarma sin una justificación explícita.
Este capítulo cierra el catálogo. El siguiente da el paso final: convertir todo este conocimiento en un protocolo ejecutable, para que un agente de IA audite un proyecto Angular completo aplicando esta misma lista, con comandos, heurísticas de detección y formato de informe. Seguí con Auditoría con agente IA →.