Componentes con SOLID

Por: Artiko
angularbuenas-practicassolidcomponentessignalschange-detectionarquitectura

Componentes con SOLID

Un componente Angular es, técnicamente, una clase con un template. Esa doble naturaleza lo convierte en el lugar donde más rápido se pudre un proyecto: es tentador meter en el mismo archivo el fetch de datos, la lógica de negocio, el estado de la UI, el formato de presentación y la navegación. El componente crece, cada cambio toca cinco cosas a la vez, y probarlo requiere levantar medio framework.

Este capítulo aplica SOLID a componentes con la API moderna de Angular 22. No es teoría: cada principio se traduce en una decisión concreta sobre dónde vive el estado, qué recibe un componente por sus input() y cómo se comunica hacia afuera. El objetivo es que un componente sea fácil de leer, barato de probar y estable frente al cambio.

SRP: un componente, una responsabilidad de UI

El Principio de Responsabilidad Única dice que una clase debe tener una sola razón para cambiar. En un componente esa razón es una responsabilidad de UI: presentar un fragmento de interfaz y reaccionar a la interacción del usuario. No es “gestionar usuarios”, no es “hablar con la API”, no es “orquestar el flujo de checkout”.

Cómo detectar un componente que hace de más

Las señales de que un componente violó SRP son observables a simple vista:

Mirá este componente que hace de todo:

// ANTIPATRÓN: componente-dios que mezcla 4 responsabilidades
@Component({
  selector: 'app-user-panel',
  template: `
    @if (cargando) {
      <p>Cargando…</p>
    } @else {
      <ul>
        @for (u of usuariosFiltrados; track u.id) {
          <li (click)="verDetalle(u.id)">
            {{ u.nombre }} — {{ u.saldo | currency }}
          </li>
        }
      </ul>
    }
  `,
})
export class UserPanel implements OnInit {
  private http = inject(HttpClient);
  private router = inject(Router);

  usuarios: Usuario[] = [];
  usuariosFiltrados: Usuario[] = [];
  cargando = false;
  filtro = '';

  ngOnInit() {
    this.cargando = true;
    // 1) Acceso a datos (HTTP) dentro del componente
    this.http.get<UsuarioDto[]>('/api/users').subscribe((dtos) => {
      // 2) Mapeo DTO → modelo (lógica de dominio)
      this.usuarios = dtos.map((d) => ({
        id: d.id,
        nombre: `${d.first_name} ${d.last_name}`,
        // 3) Regla de negocio: saldo con recargo
        saldo: d.balance * (d.is_premium ? 1 : 1.05),
      }));
      this.aplicarFiltro();
      this.cargando = false;
    });
  }

  aplicarFiltro() {
    // 4) Presentación/filtrado
    this.usuariosFiltrados = this.usuarios.filter((u) =>
      u.nombre.toLowerCase().includes(this.filtro.toLowerCase()),
    );
  }

  // 5) Navegación
  verDetalle(id: string) {
    this.router.navigate(['/users', id]);
  }
}

Tiene cinco razones para cambiar. Además arrastra dos problemas de rendimiento: sin OnPush, cada evento del framework revisa este componente; y aplicarFiltro() recorre toda la lista en cada pulsación de tecla —O(n) por tecla, O(n·m) para m teclas— reconstruyendo un array nuevo que fuerza re-render del @for.

Separar responsabilidades

El refactor extrae cada razón de cambio a su lugar. El acceso a datos y la regla de negocio van a un servicio; el estado reactivo vive en el smart component; la presentación pura queda en un dumb component.

// Acceso a datos + mapeo aislados en un servicio de dominio
@Injectable({ providedIn: 'root' })
export class UsuariosService {
  private http = inject(HttpClient);

  // httpResource: estado declarativo (value/isLoading/error) como signals
  private readonly recurso = httpResource<UsuarioDto[]>(() => '/api/users');

  readonly usuarios = computed(() =>
    (this.recurso.value() ?? []).map(mapearUsuario),
  );
  readonly cargando = this.recurso.isLoading;
}

// Regla de negocio como función pura y testeable en aislamiento
export function mapearUsuario(d: UsuarioDto): Usuario {
  return {
    id: d.id,
    nombre: `${d.first_name} ${d.last_name}`,
    saldo: d.balance * (d.is_premium ? 1 : 1.05),
  };
}
// Smart component: orquesta estado y efectos, no presenta
@Component({
  selector: 'app-user-panel',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <app-user-list
      [usuarios]="usuariosFiltrados()"
      [cargando]="service.cargando()"
      (seleccionar)="verDetalle($event)"
    />
    <input [value]="filtro()" (input)="filtro.set($any($event.target).value)" />
  `,
})
export class UserPanel {
  protected readonly service = inject(UsuariosService);
  private readonly router = inject(Router);

  protected readonly filtro = signal('');

  // Derivación memoizada: sólo recalcula si cambian usuarios o filtro
  protected readonly usuariosFiltrados = computed(() => {
    const q = this.filtro().toLowerCase();
    return this.service.usuarios().filter((u) =>
      u.nombre.toLowerCase().includes(q),
    );
  });

  protected verDetalle(id: string) {
    this.router.navigate(['/users', id]);
  }
}

El filtrado sigue siendo O(n) por evaluación, pero computed lo memoiza: sólo se recalcula cuando cambia filtro o usuarios, no en cada ciclo de detección de cambios. Y como veremos, la lista puede presentarse con un dumb component sin lógica alguna.

Smart / Dumb: dónde vive el estado

El patrón smart/dumb (también container/presentational) separa los componentes en dos categorías con contratos distintos:

Smart (container)Dumb (presentational)
ResponsabilidadEstado, efectos, orquestaciónPresentar datos, emitir intención
Conoce serviciosSí (los inyecta)No
Fuente de datossignal/computed/resourceinput()
Comunicación salienteNavega, llama serviciosoutput()
Change detectionOnPushOnPush
TesteabilidadMedia (mock de servicios)Alta (sólo inputs/outputs)
ReutilizaciónBaja (atado al feature)Alta (agnóstico del origen)

La regla es simple: el estado y los efectos viven arriba (smart), la presentación vive abajo (dumb). Un dumb component es una función de sus inputs: dado el mismo input, produce el mismo DOM, sin efectos secundarios. Recibe datos por input() y comunica la intención del usuario por output(), sin decidir qué pasa después.

// Dumb component: presentación pura, sin servicios, sin estado propio
@Component({
  selector: 'app-user-list',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (cargando()) {
      <p>Cargando…</p>
    } @else {
      <ul>
        @for (u of usuarios(); track u.id) {
          <li (click)="seleccionar.emit(u.id)">
            {{ u.nombre }} — {{ u.saldo | currency }}
          </li>
        } @empty {
          <li>No hay usuarios.</li>
        }
      </ul>
    }
  `,
})
export class UserList {
  readonly usuarios = input.required<Usuario[]>();
  readonly cargando = input(false);
  readonly seleccionar = output<string>();
}

Este componente no sabe de dónde vienen los usuarios ni qué pasa al hacer click. Lo podés usar en cualquier pantalla, montarlo en Storybook, o probarlo pasándole un array y verificando el output —sin HttpClient, sin Router, sin TestBed pesado. Esa es la ganancia: testeabilidad y reutilización crecen cuando el componente no conoce el mundo exterior.

API moderna de componentes

Angular 22 reemplaza los decoradores @Input()/@Output() por funciones que devuelven signals. No es sólo sintaxis: cambia el modelo mental y habilita OnPush sin fricción.

input(), input.required() y transform

input() declara una propiedad de entrada como InputSignal<T>. Se lee llamándola como función dentro de contextos reactivos (template, computed, effect):

export class Slider {
  // Opcional con valor por defecto → InputSignal<number>
  readonly value = input(0);

  // Requerido: error en build si el padre no lo pasa
  readonly max = input.required<number>();

  // Con alias y transform (booleanAttribute/numberAttribute son built-in)
  readonly disabled = input(false, { transform: booleanAttribute });
  readonly label = input('', { alias: 'sliderLabel' });
}

Ventajas sobre @Input():

Computed inputs: derivar en vez de duplicar

Un antipatrón clásico es sincronizar estado derivado a mano en ngOnChanges. Con signals, lo derivado se declara con computed y se mantiene solo:

// ANTIPATRÓN: estado derivado sincronizado manualmente
export class Precio implements OnChanges {
  @Input() base = 0;
  @Input() iva = 0.21;
  total = 0; // fuente de verdad duplicada, se desincroniza fácil

  ngOnChanges() {
    this.total = this.base * (1 + this.iva); // hay que acordarse de recalcular
  }
}
// MEJOR: derivación declarativa, imposible de desincronizar
export class Precio {
  readonly base = input(0);
  readonly iva = input(0.21);
  // Se recalcula solo cuando cambia base o iva; memoizado
  readonly total = computed(() => this.base() * (1 + this.iva()));
}

computed es memoizado: si sus dependencias no cambiaron, devuelve el valor cacheado sin reejecutar la función. Elimina una fuente de verdad duplicada y una razón de bug.

output(): eventos hacia afuera

output() devuelve un OutputEmitterRef<T>. Emitís con .emit(); en el template el padre escucha con (nombre)="handler($event)":

export class ExpandablePanel {
  readonly cerrado = output<void>();
  readonly cambio = output<number>({ alias: 'valueChanged' });

  cerrar() {
    this.cerrado.emit();
  }
}

A diferencia de @Output() = new EventEmitter(), output() no expone la maquinaria de RxJS al consumidor y deja claro que es un canal de salida, no un observable para suscribirse a discreción.

model(): two-way binding explícito

model() crea un input con su output de cambio asociado, habilitando la sintaxis [(value)]. Angular genera automáticamente el evento valueChange:

export class Slider {
  readonly value = model(0); // habilita [(value)]="volumen" en el padre

  incrementar() {
    this.value.update((v) => v + 10); // notifica al padre
  }
}

Usá model() sólo cuando el componente realmente posee y muta un valor compartido con el padre (un input de formulario, un slider). Para todo lo demás, input() + output() deja el flujo de datos más explícito.

OnPush por defecto, y por qué

Poné ChangeDetectionStrategy.OnPush en todos los componentes. Con la estrategia por defecto (Default), Angular revisa cada componente en cada ciclo de detección de cambios, disparado por cualquier evento del navegador, timer o promesa —re-evalúa todo el árbol aunque nada relevante haya cambiado.

Con OnPush, un componente sólo se re-revisa cuando:

  1. Cambia la referencia de un input().
  2. Se dispara un output()/evento originado en su template.
  3. Un signal leído en su template cambia.
  4. Se marca explícitamente con markForCheck() (raro con signals).

El resultado es que el trabajo de detección de cambios pasa de O(número de componentes del árbol) por evento a O(componentes efectivamente afectados). Con signals + OnPush, Angular puede además revisar sólo el sub-árbol que depende del signal que cambió. Es la base del rendimiento de runtime y prepara el terreno para zoneless (capítulo 6).

@Component({
  selector: 'app-badge',
  changeDetection: ChangeDetectionStrategy.OnPush, // no es opcional en código de calidad
  template: `<span class="badge">{{ texto() }}</span>`,
})
export class Badge {
  readonly texto = input.required<string>();
}

Nuevo control flow en templates

El control flow de bloque (@if/@for/@switch) reemplaza a *ngIf/*ngFor/ngSwitch. Es más rápido, no necesita importar directivas y trae track obligatorio.

@if / @else

@if (usuario(); as u) {
  <app-perfil [usuario]="u" />
} @else if (cargando()) {
  <app-spinner />
} @else {
  <app-vacio />
}

La forma ; as u captura el valor —útil para no re-evaluar el signal ni lidiar con null dentro del bloque.

@for con track y @empty

track es obligatorio y es la palanca de rendimiento del bloque. Le dice a Angular cómo relacionar cada item con su nodo del DOM:

@for (u of usuarios(); track u.id) {
  <li>{{ u.nombre }}</li>
} @empty {
  <li>Sin resultados.</li>
}

Un track mal elegido es una de las causas más comunes de listas lentas: reconstruye el DOM entero en cada actualización en vez de parchear lo que cambió.

@switch

@switch (rol()) {
  @case ('admin') { <app-admin /> }
  @case ('editor') { <app-editor /> }
  @default { <app-viewer /> }
}

Usa igualdad estricta (===), sin fallthrough. Comparado con [ngSwitch], no requiere importar directivas y el compilador lo optimiza mejor.

OCP y DIP en componentes

Los dos principios que más se ignoran en componentes son Abierto/Cerrado (extender sin modificar) e Inversión de Dependencias (depender de abstracciones, no de concreciones). Angular ofrece tres herramientas para cumplirlos: content projection, directivas composables e inyección de estrategias.

Content projection (ng-content): OCP en la estructura

Un componente que hardcodea su contenido interno está cerrado a la extensión: para agregar una variante, hay que modificarlo. ng-content invierte eso: el componente define la estructura (el “hueco”) y el consumidor proyecta el contenido.

// ANTIPATRÓN: card cerrada, cada variante requiere un @if más
@Component({
  selector: 'app-card',
  template: `
    <div class="card">
      @if (tipo() === 'usuario') { <app-user-header [u]="data()" /> }
      @if (tipo() === 'producto') { <app-product-header [p]="data()" /> }
      <!-- cada nuevo tipo obliga a modificar esta clase -->
    </div>
  `,
})
export class Card { /* ... */ }
// MEJOR: card abierta a extensión, cerrada a modificación
@Component({
  selector: 'app-card',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="card">
      <header class="card__header">
        <ng-content select="[card-header]" />
      </header>
      <div class="card__body">
        <ng-content />
      </div>
    </div>
  `,
})
export class Card {}
// El consumidor extiende sin tocar Card
@Component({
  template: `
    <app-card>
      <app-user-header card-header [u]="usuario()" />
      <p>Contenido arbitrario aquí.</p>
    </app-card>
  `,
})
export class Pantalla {}

Card no conoce a UserHeader ni a ProductHeader. Agregar una variante nueva no toca Card: es OCP puro. Los select="[slot]" permiten múltiples huecos con nombre (multi-slot projection).

Directivas composables: comportamiento sin herencia

En vez de heredar de una clase base para compartir comportamiento (acoplamiento rígido, un solo padre), componés directivas standalone sobre el host. Cada directiva agrega una capacidad ortogonal:

@Directive({ selector: '[appAutofocus]' })
export class Autofocus {
  private el = inject(ElementRef<HTMLElement>);
  constructor() {
    afterNextRender(() => this.el.nativeElement.focus());
  }
}

// Se compone en el host sin herencia
@Component({
  selector: 'app-search',
  imports: [Autofocus],
  hostDirectives: [Autofocus], // el comportamiento se agrega, no se hereda
  template: `<input appAutofocus />`,
})
export class Search {}

hostDirectives permite adjuntar directivas al host de un componente y hasta exponer sus inputs/outputs, componiendo comportamiento en vez de heredarlo.

Inyección de estrategias: DIP

Cuando un componente necesita variar un comportamiento (formatear, ordenar, validar), en vez de un switch interno depende de una abstracción inyectada. El componente queda cerrado; la estrategia concreta se provee desde afuera.

// Abstracción (puerto)
export interface FormateadorPrecio {
  formatear(valor: number): string;
}
export const FORMATEADOR_PRECIO = new InjectionToken<FormateadorPrecio>(
  'FormateadorPrecio',
);

// El componente depende de la abstracción, no de una implementación
@Component({
  selector: 'app-precio',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<span>{{ formateado() }}</span>`,
})
export class Precio {
  private fmt = inject(FORMATEADOR_PRECIO);
  readonly valor = input.required<number>();
  readonly formateado = computed(() => this.fmt.formatear(this.valor()));
}
// La implementación concreta se decide en el árbol de inyección
providers: [
  { provide: FORMATEADOR_PRECIO, useClass: FormateadorPesos },
];

Precio no conoce FormateadorPesos. Cambiar a FormateadorDolares o inyectar un mock en un test no toca el componente. Esto es DIP, y el capítulo 9 lo desarrolla en profundidad: Inyección de dependencias →.

Catálogo de antipatrones

Flujo de datos unidireccional

El modelo mental que ata todo: los datos bajan por input(), los eventos suben por output(). El estado tiene un único dueño (el smart component o un servicio), y nadie modifica lo que le llega por input.

flowchart TD
  subgraph Smart["Smart Component (estado + efectos)"]
    S[signal / computed / resource]
    SVC[Servicio de dominio]
  end
  subgraph Dumb["Dumb Components (presentación pura)"]
    D1[UserList]
    D2[UserRow]
  end
  SVC -->|inject| S
  S -->|input| D1
  D1 -->|input| D2
  D2 -->|output: seleccionar| D1
  D1 -->|output: seleccionar| S
  S -->|actualiza estado / navega| SVC

  classDef smart fill:#1e3a5f,stroke:#4a90d9,color:#fff
  classDef dumb fill:#2d4a2b,stroke:#6ab04c,color:#fff
  class S,SVC smart
  class D1,D2 dumb

Los datos fluyen en una sola dirección (arriba → abajo por inputs); las intenciones del usuario viajan en la opuesta (abajo → arriba por outputs) hasta llegar al dueño del estado, que es el único que muta. Ningún dumb component modifica su input ni conoce el servicio. Este ciclo cerrado es lo que hace que el estado sea razonable: para entender por qué la UI muestra algo, sólo mirás quién es el dueño del signal, no una maraña de mutaciones dispersas.

sequenceDiagram
  participant U as Usuario
  participant D as UserList (dumb)
  participant S as UserPanel (smart)
  participant Svc as UsuariosService
  U->>D: click en un item
  D->>S: seleccionar.emit(id)
  S->>Svc: (o) router.navigate / update signal
  Svc-->>S: nuevo estado (signal)
  S-->>D: nuevo input (referencia nueva)
  D-->>U: re-render mínimo (OnPush + track)

Checklist

Con los componentes ya diseñados según SRP y con el estado empujado hacia arriba, la pregunta siguiente es cómo modelar ese estado sin que se vuelva un nudo. Eso es signals: Signals y estado reactivo →.