Catálogo de patrones y antipatrones

Por: Artiko
angularbuenas-practicaspatronesantipatronescode-reviewsignalsrendimiento

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:

  1. Catálogo de patrones: lo que querés ver en el código, con el problema que resuelve cada uno.
  2. Catálogo de antipatrones: en formato síntoma → por qué duele → refactor, con enlace al capítulo donde se trata a fondo.
  3. 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ónQué esProblema que resuelveCuándo aplicarlo
Smart / dumb componentsContenedores 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 UICasi siempre: la mayoría de tus componentes deberían ser dumb
Service con signals como storeUn servicio providedIn: 'root' (o de feature) que expone signal/computed de solo lectura y muta vía métodosEstado compartido sin acoplar a una librería pesadaEstado de dominio compartido entre rutas o componentes
Presentacional puro con OnPushComponente sin dependencias, salida determinista de sus entradas, ChangeDetectionStrategy.OnPushChange detection recorre todo el árbol en cada tickTodo componente presentacional (idealmente, todos)
inject() + InjectionToken para IoCInversión de control: el componente depende de una abstracción, no de una implementación concretaAcoplamiento fuerte a clases concretas; imposible sustituir en testsCuando querés poder cambiar o mockear una dependencia
Interceptores funcionalesHttpInterceptorFn registrados con withInterceptors()Lógica transversal (auth, logging, retry) duplicada en cada llamadaAuth headers, manejo global de errores, retry, telemetría
@defer para carga diferidaBloque que carga su contenido (y su JS) según un trigger (on viewport, on interaction, on idle)Bundle inicial inflado; LCP e INP degradadosContenido below the fold, widgets pesados, gráficos, mapas
httpResource / rxResource para datosPrimitiva reactiva declarativa que ata una URL/param reactivo a estado (value, status, error)El trío manual loading/error/data con subscribe propenso a fugasLectura de datos derivada de estado reactivo (parámetros, filtros)
takeUntilDestroyed()Operador que completa el stream al destruirse el componente/contexto de inyecciónMemory leaks por suscripciones que sobreviven al componenteCualquier subscribe manual que no gestione RxJS declarativo
FacadeServicio delgado que expone una API de dominio y esconde la orquestación internaEl componente conoce demasiados colaboradoresSolo 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ónSíntomaPor qué dueleRefactorCapítulo
1Componente-diosUn .ts de 800 líneas con 12 responsabilidadesImposible de testear y reusar; toca todo en cada cambioExtraer dumb components y mover lógica a serviciosComponentes con SOLID
2Lógica de negocio en el componente/templateReglas de dominio dentro del .ts de la vista o en el HTMLLa regla no se puede reusar ni testear sin renderizarMover a un servicio de dominio o computedComponentes con SOLID
3Funciones en bindings{{ calcular() }} o [x]="fn()" en el templateSe ejecuta en cada change detection; O(n) llamadas por tickReemplazar por computed() o pipe puroChange Detection
4Subscribe anidadosubscribe dentro de otro subscribeCallback hell, sin cancelación, condiciones de carreraAplanar con switchMap/concatMap/mergeMapRxJS con criterio
5effect() para derivar estadoUn effect que escribe en otro signalDoble change detection, riesgo de bucles, estado no declarativoUsar computed() o linkedSignal()Signals y estado
6Mutación sin cambio de referencia con OnPushthis.items.push(x) con OnPushLa vista no se actualiza: OnPush compara por referenciaReemplazar la referencia (signal.update, spread)Change Detection
7Barrel filesindex.ts que reexporta todo un directorioBundles inflados, imports circulares, tree-shaking rotoImportar desde la ruta concreta del símboloOrganización del proyecto
8Autorización solo en clienteOcultar un botón con @if y creer que eso “protege”El endpoint sigue abierto; cualquiera lo llama directoAutorizar en el backend; el cliente solo es UXSeguridad
9any en formulariosFormGroup<any> o form.value.foo sin tipoSe pierde el chequeo de tipos; errores en runtimeReactive forms tipados o Signal FormsFormularios
10Librerías pesadas innecesariasMoment.js, Lodash entero, una librería de UI por un botón+100 KB al bundle; LCP/INP peoresAPI nativa (Intl, structuredClone), import puntualLibrerías del ecosistema
11Sin OnPush / con Zone.jsComponentes en estrategia Default y app con Zone.jsChange detection recorre todo el árbol en cada eventoOnPush 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 alarmaUmbral orientativoQué suele indicar
Archivo de componente gigante.ts > 300-400 líneasComponente-dios; múltiples responsabilidades sin extraer
Muchos input()Más de ~8-10 inputsEl componente hace demasiado; falta descomponer en subcomponentes
Muchos servicios inyectadosMás de ~5 inject() en un componenteDemasiados colaboradores; candidato a facade o a mover lógica
Plantilla con lógicaTernarios anidados, @if con expresiones complejas, métodos llamadosLógica que debería ser computed() o pipe
subscribe manual sin takeUntilDestroyedCualquieraMemory leak potencial
effect() que escribe signalsCualquieraEstado derivado imperativo; debería ser computed/linkedSignal
any / as frecuentesVarios en un archivoSe está esquivando el compilador; bugs de tipo latentes
Métodos en bindingsCualquiera en hot pathRecalcula en cada CD
import * from en un index.tsCualquieraBarrel; revisá tree-shaking y ciclos
Componentes sin OnPushEn una app grandeCD 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?”.

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.

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 →.