Guía práctica: cómo migré un proyecto de Angular 19/20 a Angular 21.2 sin morir en el intento

Migrar un proyecto Angular de v19/20 a v21 puede sonar intimidante. En la práctica, si seguís el proceso correcto, es incremental: cada paso es reversible y el proyecto sigue funcionando en todo momento. Esta es la guía que apliqué en proyectos reales.

Antes de empezar: el inventario

# Ver versión actual y dependencias desactualizadas
ng version
ng update

# Verificar compatibilidad antes de actualizar
npx npm-check-updates -u --target minor

Paso 1: Actualizar el core

# Siempre de a una versión mayor a la vez
# Si estás en v19, primero actualizar a v20, luego a v21

# De v20 a v21:
ng update @angular/core@21 @angular/cli@21

# El comando aplica schematics automáticos:
# - Actualiza imports deprecated
# - Adapta APIs que cambiaron
# - Avisa sobre cambios manuales necesarios

# Verificar que compila
ng build --configuration production

Paso 2: Adoptar Zoneless

// app.config.ts
import { provideZonelessChangeDetection } from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZonelessChangeDetection(),
    provideRouter(routes)
  ]
};

// angular.json: "polyfills": []  // eliminar zone.js
// npm uninstall zone.js

Paso 3: Migrar de Karma a Vitest

ng generate @angular/build:vitest
ng test  # verificar que los tests pasan
npm uninstall karma karma-chrome-launcher karma-coverage karma-jasmine karma-jasmine-html-reporter

Paso 4: Standalone components

# Angular 21 asume standalone por defecto
# Migración automática:
ng generate @angular/core:standalone --mode=convert-to-standalone
ng generate @angular/core:standalone --mode=prune-ng-modules
ng generate @angular/core:standalone --mode=standalone-bootstrap

Checklist completo

PasoAcciónObligatorio
1ng update @angular/core@21 @angular/cli@21✅ Sí
2Verificar que la app compila y los tests pasan✅ Sí
3Activar provideZonelessChangeDetection()Recomendado
4Eliminar Zone.js de polyfillsCon paso 3
5Migrar a VitestRecomendado
6Nuevos formularios con Signal FormsGradual
7Componentes de nav con funciones standalone del RouterGradual
8Configurar ng mcp para integración con IAOpcional

Errores comunes y cómo resolverlos

// ERROR: El componente no se actualiza después de activar Zoneless
// → Convertir estado a Signals o llamar markForCheck()

// ERROR: Tests fallan con "No current Angular test" después de Vitest
// → Verificar imports de @angular/core/testing

// ERROR: "Cannot find module zone.js"
// → Buscar y eliminar import 'zone.js' en el proyecto:
// grep -r "import 'zone.js'" src/

// ERROR: ExpressionChangedAfterItHasBeenCheckedError en Zoneless
// → Usar signal.update() o signal.set() en lugar de mutación directa

Mi experiencia: cuánto tardó la migración

En un proyecto de tamaño medio (45 componentes, 12 servicios, 80 tests): el ng update tardó 10 minutos. Revisar avisos y corregir deprecaciones: media jornada. Migración a Zoneless con todos los tests adaptados a Vitest: un día. Adopción gradual de Signal Forms en los formularios principales: dos semanas, a medida que tocábamos cada módulo.

No es un proceso de un fin de semana, pero tampoco es una reescritura. Es una evolución incremental que podés hacer en paralelo con el trabajo normal del equipo.


Artículo anterior: Router Signals en Angular 21: navegación standalone sin cargar todo el Router | Fin de la Serie Angular 20 → 21.2

¿Tenés preguntas sobre la migración en tu proyecto específico? Dejá tu caso en los comentarios. 👇

Router Signals en Angular 21: navegación standalone sin cargar todo el Router

Siempre que necesitaba saber si una ruta estaba activa en Angular, inyectaba el Router completo y me suscribía a los eventos. En componentes simples de navegación, era matar moscas a cañonazos. Angular 21 introduce funciones standalone que devuelven Signals — cargás exactamente lo que necesitás, sin más.

El problema con inyectar el Router entero

// ❌ Antes: inyectar todo el Router para saber si una ruta está activa
import { Router, NavigationEnd } from '@angular/router';
import { filter } from 'rxjs/operators';

@Component({ ... })
export class SidebarComponent implements OnInit, OnDestroy {
  isDashboard = false;
  private sub = Subscription.EMPTY;

  constructor(private router: Router) {}

  ngOnInit() {
    this.sub = this.router.events
      .pipe(filter(e => e instanceof NavigationEnd))
      .subscribe(() => {
        this.isDashboard = this.router.url === '/dashboard';
      });
  }

  ngOnDestroy() { this.sub.unsubscribe(); }
}

isActive(): la función standalone con Signal

// ✅ Angular 21: función standalone, devuelve Signal
import { Component } from '@angular/core';
import { isActive } from '@angular/router';
import { RouterLink } from '@angular/router';

@Component({
  selector: 'app-sidebar',
  standalone: true,
  imports: [RouterLink],
  template: `
    <nav>
      <a routerLink="/dashboard" [class.active]="isDashboard()">Dashboard</a>
      <a routerLink="/usuarios" [class.active]="isUsuarios()">Usuarios</a>
      <a routerLink="/reportes" [class.active]="isReportes()">Reportes</a>
    </nav>
  `
})
export class SidebarComponent {
  isDashboard = isActive('/dashboard', { exact: true });
  isUsuarios  = isActive('/usuarios');
  isReportes  = isActive('/reportes');
}

Otras funciones standalone del Router

import { isActive, currentRoute, routeParams, queryParams, routeData } from '@angular/router';

@Component({ ... })
export class DetalleComponent {
  // Parámetros de ruta como Signal — sin ActivatedRoute
  params     = routeParams();
  usuarioId  = computed(() => this.params()['id']);

  // Query params como Signal
  qParams = queryParams();
  pagina  = computed(() => Number(this.qParams()['page'] ?? 1));

  // Data estática de la ruta
  data   = routeData();
  titulo = computed(() => this.data()['titulo'] ?? 'Sin título');
}

Navigation API experimental (v21.0)

// app.config.ts — habilitar Navigation API experimental
import { provideExperimentalNavigationApi } from '@angular/common';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideExperimentalNavigationApi()
  ]
};

Location strategies: control sobre el trailing slash (v21.2)

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withRouterConfig({
      trailingSlash: 'never'  // 'always' | 'never' | 'preserve'
    }))
  ]
};

Por qué el tree-shaking importa

Con las funciones standalone, el bundler puede eliminar del bundle final todo el código del Router que no se usa. Si solo usás isActive y routeParams, el resto no viaja al usuario. En aplicaciones grandes esto puede reducir el bundle inicial de forma significativa — especialmente relevante en conexiones móviles lentas.


Artículo anterior: Vitest reemplaza a Karma y Angular habla con tu IA: el nuevo ecosistema de herramientas | Serie Angular 20 → 21.2 | Próximo: Guía práctica: cómo migré un proyecto de Angular 19/20 a Angular 21.2 sin morir en el intento →

Vitest reemplaza a Karma y Angular habla con tu IA: el nuevo ecosistema de herramientas

Karma murió. No es una exageración: Angular 21 lo retiró como runner por defecto y lo reemplazó con Vitest. Y junto con eso llegó algo que no esperaba: soporte nativo para que asistentes de IA entiendan la estructura de un proyecto Angular en tiempo real con el protocolo MCP.

Por qué Karma quedó obsoleto

Karma fue el runner de tests de Angular durante más de una década. Abre un browser real, corre los tests ahí y reporta los resultados. Funcional, pero lento, difícil de configurar en CI y sin soporte nativo para el modelo Zoneless. En el mundo actual de testing, donde queremos ejecución rápida y paralela, Karma no competía.

Vitest: el nuevo estándar

  • Ejecución en paralelo: múltiples workers, tests más rápidos
  • Sin browser real: usa jsdom o happy-dom — más rápido y estable en CI
  • Soporte nativo Zoneless: sin el overhead de Zone.js en los tests
  • Watch mode inteligente: solo re-ejecuta los tests afectados por cambios
  • API compatible con Jest: si venís de Jest, la curva es mínima
# Configurar Vitest en un proyecto Angular 21 existente
ng generate @angular/build:vitest

# O en angular.json
{
  "test": {
    "builder": "@angular/build:unit-test",
    "options": {
      "runner": "vitest"
    }
  }
}

Tests con Vitest + Signals

import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { describe, it, expect } from 'vitest';
import { ContadorComponent } from './contador.component';

describe('ContadorComponent', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [ContadorComponent],
      providers: [provideZonelessChangeDetection()]
    });
  });

  it('debería incrementar el contador', () => {
    const fixture = TestBed.createComponent(ContadorComponent);
    const comp = fixture.componentInstance;

    expect(comp.contador()).toBe(0);
    comp.incrementar();
    // Sin detectChanges() manual — Signals actualizan sincrónicamente
    expect(comp.contador()).toBe(1);
    expect(comp.doble()).toBe(2);
  });

  it('debería renderizar el valor actualizado', async () => {
    const fixture = TestBed.createComponent(ContadorComponent);
    fixture.detectChanges();

    const button = fixture.nativeElement.querySelector('button');
    button.click();

    await fixture.whenStable();
    expect(fixture.nativeElement.querySelector('p').textContent).toContain('1');
  });
});

ng mcp: Angular habla con tu IA

La feature que más me sorprendió de Angular 21.2: soporte nativo para el Model Context Protocol (MCP). Con un solo comando, Angular expone la estructura del proyecto a asistentes de IA locales para que entiendan el contexto en tiempo real.

# Levantar el servidor MCP de Angular
ng mcp

# Configuración en .cursor/mcp.json o claude_desktop_config.json:
{
  "mcpServers": {
    "angular": {
      "command": "ng",
      "args": ["mcp"],
      "cwd": "/path/to/mi-proyecto-angular"
    }
  }
}

Con el servidor MCP activo, el asistente de IA tiene acceso al árbol de componentes, rutas del router, servicios y sus dependencias, grafo de Signals y configuración del proyecto. En la práctica: cuando le pedís al asistente que genere un componente, lo hace correctamente dentro del contexto de tu proyecto — no código genérico que hay que adaptar.

Migrar de Karma a Vitest

# 1. Desinstalar Karma
npm uninstall karma karma-chrome-launcher karma-jasmine karma-jasmine-html-reporter

# 2. Ejecutar el schematic de migración
ng generate @angular/build:vitest

# 3. Verificar tests
ng test

# Los tests de Jasmine son mayormente compatibles
# Solo ajustar: jasmine.SpyObj → vi.Mocked cuando sea necesario

Artículo anterior: Novedades del template en Angular 21: regex, spread, instanceof y más | Serie Angular 20 → 21.2 | Próximo: Router Signals en Angular 21: navegación standalone sin cargar todo el Router →

Novedades del template en Angular 21: regex, spread, instanceof y más

Angular 21 trajo varias novedades al template que parecen pequeñas pero cambian el día a día. Regex literals directamente en el HTML, spread operator en arrays y objetos, instanceof en expresiones, exhaustive switch blocks. Menos lógica empujada al componente, más expresividad en el template.

Regex literals en el template (v21)

Antes, si quería filtrar con una expresión regular en el template, tenía que crear una propiedad en el componente. Ahora puedo usar el literal directamente.

// Antes: declarar el regex en el componente
// emailRegex = /@empresa\.com$/;

// Ahora: regex literal directo en el template
@if (userEmail() | match: /@empresa\.com$/) {
  <span class="badge">Email corporativo</span>
}

@if (/^\d{8}-\d$/.test(cuit())) {
  <span class="valid">CUIT válido</span>
}

Spread operator en templates (v21.1)

// Spread de arrays
@let todosLosItems = [...itemsActivos(), ...itemsArchivados()];

@for (item of todosLosItems; track item.id) {
  <li>{{ item.nombre }}</li>
}

// Spread de objetos en property binding
<mi-componente [config]="{ ...configBase(), ...configOverride() }" />

instanceof en expresiones del template (v21.2)

// Antes: casteo forzado en el componente
// get esError(): boolean { return this.resultado() instanceof Error; }

// Ahora: directo en el template
@if (resultado() instanceof Error) {
  <app-error-display [error]="resultado()" />
} @else if (resultado() instanceof Array) {
  <app-lista [items]="resultado()" />
} @else {
  <app-detalle [dato]="resultado()" />
}

Exhaustive switch blocks (v21.2)

type Estado = 'activo' | 'pausado' | 'eliminado';

@switch (usuario.estado()) {
  @case ('activo') {
    <span class="badge green">Activo</span>
  }
  @case ('pausado') {
    <span class="badge yellow">Pausado</span>
  }
  @case ('eliminado') {
    <span class="badge red">Eliminado</span>
  }
  // Sin @default: el compilador verifica que todos los casos estén cubiertos
}

Multiple switch case matching (v21.1)

// Múltiples valores en un @case
@switch (rol()) {
  @case ('admin', 'superadmin') {
    <app-panel-admin />
  }
  @case ('editor', 'revisor') {
    <app-panel-editor />
  }
  @default {
    <app-panel-usuario />
  }
}

@let: variables locales en el template

// Sin @let: el Signal se evalúa 3 veces
<p>Hola, {{ usuario().nombre }}</p>
<p>Email: {{ usuario().email }}</p>
<img [src]="usuario().avatar" />

// Con @let: evaluación única, más eficiente
@let u = usuario();
<p>Hola, {{ u.nombre }}</p>
<p>Email: {{ u.email }}</p>
<img [src]="u.avatar" />

ngTemplateOutlet con injector personalizado (v21.2)

<ng-container
  [ngTemplateOutlet]="miTemplate"
  [ngTemplateOutletContext]="{ $implicit: datos() }"
  [ngTemplateOutletInjector]="injectorPersonalizado">
</ng-container>

Artículo anterior: Signal Forms: cuando los formularios reactivos finalmente tienen sentido | Serie Angular 20 → 21.2 | Próximo: Vitest reemplaza a Karma y Angular habla con tu IA: el nuevo ecosistema de herramientas →

Signal Forms: cuando los formularios reactivos finalmente tienen sentido

Los formularios reactivos de Angular siempre me parecieron demasiado verbosos. Crear un FormGroup, definir los FormControl, suscribirse a valueChanges, acordarse de hacer unsubscribe… Signal Forms simplifica todo eso. El formulario es sincrónico, tipado y reactivo sin esfuerzo.

El problema con FormGroup y FormControl

// ❌ FormGroup clásico: verboso y con memory leaks potenciales
import { FormBuilder, Validators } from '@angular/forms';

@Component({ ... })
export class LoginClasico implements OnDestroy {
  private destroy$ = new Subject<void>();

  form = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(8)]]
  });

  constructor(private fb: FormBuilder) {
    this.form.valueChanges
      .pipe(takeUntil(this.destroy$))
      .subscribe(val => console.log(val));
  }

  ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); }
}

Signal Forms: la forma moderna

// ✅ Signal Forms: sincrónico, tipado, sin suscripciones
import { Component } from '@angular/core';
import { form, field } from '@angular/forms/signals';
import { Validators } from '@angular/forms';

@Component({
  selector: 'app-login',
  template: `
    <form (ngSubmit)="onSubmit()">
      <div>
        <label>Email</label>
        <input type="email" [formField]="loginForm.controls.email" />
        @if (loginForm.controls.email.invalid() && loginForm.controls.email.touched()) {
          <span class="error">Email inválido</span>
        }
      </div>
      <div>
        <label>Password</label>
        <input type="password" [formField]="loginForm.controls.password" />
      </div>
      <button type="submit" [disabled]="!loginForm.valid()">Ingresar</button>
    </form>
  `
})
export class LoginComponent {
  loginForm = form({
    email: field('', [Validators.required, Validators.email]),
    password: field('', [Validators.required, Validators.minLength(8)])
  });

  onSubmit() {
    if (this.loginForm.valid()) {
      const { email, password } = this.loginForm.value();
      console.log('Login con:', email);
    }
  }
}

Formularios dinámicos con Signal Forms

import { Component, computed } from '@angular/core';
import { form, field } from '@angular/forms/signals';
import { Validators } from '@angular/forms';

@Component({
  selector: 'app-registro',
  template: `
    <form>
      <input type="text" [formField]="registroForm.controls.nombre" placeholder="Nombre" />
      <input type="email" [formField]="registroForm.controls.email" placeholder="Email" />

      <label>
        <input type="checkbox" [formField]="registroForm.controls.esEmpresa" />
        Registro como empresa
      </label>

      @if (registroForm.controls.esEmpresa.value()) {
        <input type="text" [formField]="registroForm.controls.razonSocial" placeholder="Razón Social" />
        <input type="text" [formField]="registroForm.controls.cuit" placeholder="CUIT" />
      }

      <p>Completado: {{ camposCompletados() }}/{{ totalCampos() }}</p>
      <button [disabled]="!registroForm.valid()">Registrarse</button>
    </form>
  `
})
export class RegistroComponent {
  registroForm = form({
    nombre: field('', Validators.required),
    email: field('', [Validators.required, Validators.email]),
    esEmpresa: field(false),
    razonSocial: field(''),
    cuit: field('')
  });

  camposCompletados = computed(() =>
    Object.values(this.registroForm.controls)
      .filter(c => c.value() !== '' && c.value() !== false).length
  );

  totalCampos = computed(() =>
    this.registroForm.controls.esEmpresa.value() ? 5 : 3
  );
}

Migración gradual: FormGroup y Signal Forms coexisten

No hay que reescribir todo de una vez. Angular 21 permite tener ambos sistemas en paralelo. Mi estrategia: nuevos formularios con Signal Forms, los existentes se migran cuando hay un motivo concreto (bug, refactor, mejora de rendimiento).

Lo que más me gusta de Signal Forms

No tener que pensar en el ciclo de vida de las suscripciones. Con FormGroup siempre había ese riesgo latente de un memory leak por un valueChanges sin unsubscribe. Con Signal Forms, el formulario es un valor reactivo sincrónico: lo leo con loginForm.value(), verifico con loginForm.valid(), y Angular maneja el resto. Menos código, menos bugs.


Artículo anterior: Adiós Zone.js: cómo Angular 21 cambió la detección de cambios para siempre | Serie Angular 20 → 21.2 | Próximo: Novedades del template en Angular 21: regex, spread, instanceof y más →

Adiós Zone.js: cómo Angular 21 cambió la detección de cambios para siempre

Cuando leí «Angular sin Zone.js» por primera vez pensé que era un truco de optimización para casos extremos. Ahora es el estándar. Y una vez que lo entendés, no querés volver. La detección de cambios deja de ser magia negra para convertirse en algo predecible y controlable.

Qué hacía Zone.js y por qué era problemático

Zone.js es una librería que parcha todas las APIs asíncronas del browser: setTimeout, setInterval, Promise, addEventListener, XMLHttpRequest. Cuando cualquiera de esas operaciones completa, Zone.js notifica a Angular para que ejecute la detección de cambios en todo el árbol de componentes.

El problema: esa detección recorre todo el árbol aunque solo cambiara un campo en un componente profundo. En aplicaciones grandes, eso genera renders innecesarios que degradan el INP (Interaction to Next Paint), la métrica Core Web Vital que Google usa para el ranking.

// Con Zone.js: cualquier click, timeout o promesa dispara detección en TODO el árbol
// ❌ Angular evalúa todos los componentes aunque nada haya cambiado

// Con Signals: solo los componentes que dependen del Signal actualizado se re-renderizan
// ✅ Detección quirúrgica y predecible

Activar Zoneless en Angular 21

// app.config.ts
import {
  ApplicationConfig,
  provideZonelessChangeDetection
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZonelessChangeDetection(),
    provideRouter(routes)
  ]
};
// angular.json — eliminar zone.js de los polyfills
{
  "projects": {
    "mi-app": {
      "architect": {
        "build": {
          "options": {
            "polyfills": []
          }
        }
      }
    }
  }
}

// package.json — desinstalar
// npm uninstall zone.js

Signals: el mecanismo que reemplaza a Zone.js

import { Component, signal, computed, effect } from '@angular/core';

@Component({
  selector: 'app-contador',
  template: `
    <p>Contador: {{ contador() }}</p>
    <p>Doble: {{ doble() }}</p>
    <button (click)="incrementar()">+1</button>
  `
})
export class ContadorComponent {
  contador = signal(0);
  doble = computed(() => this.contador() * 2);

  constructor() {
    effect(() => {
      console.log(`Contador cambió a: ${this.contador()}`);
    });
  }

  incrementar() {
    this.contador.update(v => v + 1);
    // Solo re-renderiza este componente — no el árbol completo
  }
}

Signals vs Observables: cuándo uso cada uno

SituaciónSignalObservable (RxJS)
Estado local del componente✅ IdealInnecesariamente complejo
Estado derivado (computed)computed()pipe + map
Streams de eventos complejosNo aplica bien✅ Ideal
HTTP requests✅ httpResourceHttpClient + async pipe
WebSockets / SSECombinado con toSignal()✅ Ideal
Estado global (store)✅ signals + servicesNgRx / Akita

toSignal() y toObservable(): el puente entre mundos

import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { debounceTime, switchMap } from 'rxjs/operators';

@Component({ ... })
export class MiComponent {
  private http = inject(HttpClient);

  // Observable → Signal (sin async pipe en el template)
  usuarios = toSignal(
    this.http.get<Usuario[]>('/api/usuarios'),
    { initialValue: [] }
  );

  // Signal → Observable (para operadores RxJS)
  busqueda = signal('');
  resultados$ = toObservable(this.busqueda).pipe(
    debounceTime(300),
    switchMap(q => this.http.get(`/api/buscar?q=${q}`))
  );
}

El impacto real en rendimiento

En una aplicación de gestión interna que migramos — dashboard con tablas de datos, filtros y actualizaciones frecuentes — la migración a Zoneless redujo el INP de 380ms a 95ms. La diferencia viene de eliminar los ciclos de detección de cambios innecesarios que Zone.js disparaba con cada interacción del usuario, aunque ningún dato de la vista hubiera cambiado.


Artículo anterior: Angular 21: el cambio de paradigma que no podés ignorar | Serie Angular 20 → 21.2 | Próximo: Signal Forms: cuando los formularios reactivos finalmente tienen sentido →

Angular 21: el cambio de paradigma que no podés ignorar

Cuando Angular anunció que las Signals pasaban de experimental a estables, lo tomé como «otra API más para aprender». Me equivoqué. No era una API nueva: era un cambio de paradigma que afecta cómo se detectan cambios, cómo se construyen formularios, cómo funciona el router y hasta cómo escribimos los tests. Angular 21 consolida todo eso. Esta serie es lo que me hubiera gustado tener cuando arranqué la migración.

El contexto: por qué Angular cambió tan profundamente

Durante años, Angular usó Zone.js para detectar cuándo algo cambiaba en la aplicación y disparar la actualización del DOM. Zone.js intercepta todas las operaciones asíncronas — setTimeout, promesas, eventos — y notifica a Angular para que re-evalúe la vista. Funcionaba. Pero tenía un costo: bundle más grande, detección de cambios innecesaria, y comportamiento difícil de predecir en aplicaciones complejas.

Las Signals cambian el modelo de raíz: en lugar de «revisar todo cuando algo asíncrono ocurre», ahora cada valor sabe exactamente quién depende de él y notifica solo a esos dependientes. Es reactivo de verdad, no reactivo por polling.

Qué cambió de Angular 20 a Angular 21

ÁreaAntes (v20)Angular 21
Detección de cambiosZone.js por defectoZoneless por defecto en proyectos nuevos
FormulariosFormGroup / FormControlSignal Forms: form() y field()
RouterInyección de Router classFunciones standalone con Signals
TestingKarma + JasmineVitest por defecto
TemplateSintaxis estándarRegex literals, spread operator, instanceof
Tooling IAManualSoporte MCP nativo (ng mcp)
HTTPHttpClient observablehttpResource con Signals

httpResource: el nuevo cliente HTTP basado en Signals

Una de las novedades más destacadas de v21.2: httpResource. La respuesta de Angular a «¿cómo hago una llamada HTTP y el resultado sea un Signal?»

import { Component, signal } from '@angular/core';
import { httpResource } from '@angular/common/http';

@Component({
  selector: 'app-usuarios',
  template: `
    @if (usuarios.isLoading()) {
      <p>Cargando...</p>
    } @else if (usuarios.error()) {
      <p>Error: {{ usuarios.error()?.message }}</p>
    } @else {
      <ul>
        @for (u of usuarios.value(); track u.id) {
          <li>{{ u.nombre }}</li>
        }
      </ul>
    }
  `
})
export class UsuariosComponent {
  // Sin subscribe, sin async pipe — el resultado es un Signal
  usuarios = httpResource<Usuario[]>('/api/usuarios');

  // Con parámetros reactivos: se re-fetcha cuando cambia el signal
  filtro = signal('admin');
  usuariosFiltrados = httpResource<Usuario[]>(() =>
    `/api/usuarios?rol=${this.filtro()}`
  );
}

La hoja de ruta de esta serie

  1. Este artículo: el panorama general del salto v20 → v21
  2. Adiós Zone.js: detección de cambios Zoneless en profundidad
  3. Signal Forms: formularios reactivos que finalmente tienen sentido
  4. Novedades del template: regex, spread, instanceof y más
  5. Vitest reemplaza Karma + soporte MCP nativo
  6. Router Signals: navegación standalone sin cargar todo el Router
  7. Guía práctica de migración de Angular 19/20 a Angular 21.2

Si estás trabajando con Angular en proyectos .NET — como yo, donde el backend es ASP.NET y el frontend es Angular — estos cambios son especialmente relevantes. La adopción de Signals reduce considerablemente el gap conceptual entre el modelo reactivo del frontend y los patrones async/await del backend. Empecemos.


Serie Angular 20 → 21.2 | Próximo: Adiós Zone.js: cómo Angular 21 cambió la detección de cambios para siempre →

Docker vs Kubernetes: cuándo me alcanza con uno y cuándo necesito el otro

Me pidieron «alta disponibilidad» para un sistema crítico. Tenía Docker Compose funcionando perfecto en un solo nodo. Pensé: «con –scale lo resuelvo». Hasta que entendí que escalar horizontalmente en múltiples nodos con Compose no es trivial. Ese fue el momento en que Kubernetes dejó de ser «esa tecnología complicada» y se convirtió en la herramienta correcta para el trabajo.

Docker standalone: cuándo alcanza

No todo necesita Kubernetes. Docker solo, o Docker Compose, es perfecto para muchos casos de uso:

  • Aplicaciones en un solo servidor
  • Entornos de desarrollo local
  • Proyectos personales o startups en etapa temprana
  • Workloads que no requieren alta disponibilidad real
  • Pipelines de CI/CD

La tabla que lo resume todo

CaracterísticaDocker soloDocker SwarmKubernetes
ComplejidadBajaMediaAlta
HA multi-nodoNoSí (básico)Sí (avanzado)
Auto-scalingManualBásicoHPA / VPA automático
Self-healingrestart policySí (avanzado)
Rolling updatesManualSí (con control fino)
SecretsEnv vars / archivosDocker SecretsKubernetes Secrets + Vault
NetworkingBridge / overlay básicoOverlayCNI (Calico, Flannel, etc.)
Observabilidaddocker logs / statsBásicaPrometheus, Grafana, Jaeger
Curva de aprendizajeDíasSemanasMeses

Docker Swarm: el punto medio

Swarm es el orquestador nativo de Docker. Más simple que Kubernetes, soporta multi-nodo y HA básica. Si necesitás distribuir contenedores en 2-5 nodos sin la complejidad de K8s, Swarm es una opción válida.

# Inicializar un swarm
docker swarm init --advertise-addr 192.168.1.10

# Agregar un nodo worker
docker swarm join --token SWMTKN-1-xxx 192.168.1.10:2377

# Desplegar un stack (similar a docker compose)
docker stack deploy -c docker-compose.yml mi-app

# Ver servicios del stack
docker service ls
docker service ps mi-app_api

# Escalar un servicio
docker service scale mi-app_api=3

Kubernetes: cuándo lo necesitás de verdad

En mi cluster SUSE Linux HA con dos nodos, Swarm funcionaba. Pero cuando los requisitos crecieron — deploys sin downtime garantizado, auto-scaling basado en métricas, gestión de secretos centralizada, rollbacks automáticos — Kubernetes fue la respuesta correcta.

La diferencia fundamental: Docker (y Swarm) son herramientas para correr contenedores. Kubernetes es una plataforma para gestionar aplicaciones. La distinción importa cuando tu aplicación crece.

# El mismo concepto en Docker Compose vs Kubernetes

# docker-compose.yml
services:
  api:
    image: mi-api:1.4.2
    replicas: 3
    restart: unless-stopped

# ─────────────────────────────────────────────
# En Kubernetes (deployment.yaml)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1    # nunca baja de 2 replicas durante update
      maxSurge: 1          # puede tener 4 temporalmente
  selector:
    matchLabels:
      app: api
  template:
    spec:
      containers:
      - name: api
        image: mi-api:1.4.2
        resources:
          requests:
            memory: "256Mi"
            cpu: "250m"
          limits:
            memory: "512Mi"
            cpu: "500m"
        livenessProbe:
          httpGet:
            path: /health
            port: 80
          initialDelaySeconds: 30
        readinessProbe:
          httpGet:
            path: /ready
            port: 80

El camino natural: Docker → Compose → Kubernetes

No es un salto, es una progresión. Empecé con docker run, pasé a Compose para gestionar múltiples servicios, y cuando los requisitos de producción superaron lo que Compose podía manejar cómodamente, migramos a Kubernetes. Los conceptos son los mismos — imágenes, contenedores, redes, volúmenes — pero Kubernetes agrega la capa de orquestación inteligente que necesitás a escala.

El conocimiento de Docker no se descarta al llegar a Kubernetes: es el prerequisito. Todo pod de Kubernetes corre contenedores Docker. Los Dockerfiles que aprendiste a escribir son exactamente los mismos. La diferencia está en quién los gestiona y con qué nivel de sofisticación.

Mi setup actual

Hoy corro Kubernetes en mi cluster on-premise SUSE con dos nodos en HA. Docker sigue presente — lo uso en CI/CD para buildear imágenes y en desarrollo local. Compose lo uso para levantar entornos de desarrollo con múltiples servicios. Kubernetes gestiona producción. Cada herramienta en su lugar.


Artículo anterior: Seguridad en Docker | Fin de la Serie Docker Completo

Esta fue la serie completa sobre Docker. Si llegaste hasta acá, tenés las bases para trabajar con contenedores en entornos reales. El siguiente paso natural es Kubernetes — cubriremos eso en una serie dedicada.


Artículo anterior: Seguridad en Docker: errores que cometí y cómo los corregí | Fin de la Serie Docker Completo

Seguridad en Docker: errores que cometí y cómo los corregí

Desplegué una imagen con credenciales hardcodeadas en el Dockerfile. Una vez. Cuando me di cuenta, la imagen estaba en el registry privado de la empresa y nadie más la había visto — pero el susto fue suficiente para que revisara la seguridad de todos mis contenedores ese mismo día.

Los errores de seguridad más comunes en Docker

La mayoría de los problemas de seguridad en Docker no son bugs exóticos: son malas prácticas que cometemos por desconocimiento o por apurarnos. Acá van los que yo cometí y cómo los corregí.

Error 1: Correr contenedores como root

# ❌ Por defecto, el proceso corre como root dentro del contenedor
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
# Si el proceso es comprometido, tiene privilegios de root dentro del contenedor

# ✅ Crear y usar un usuario no-root
FROM node:20-alpine
WORKDIR /app

# Crear usuario sin privilegios
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# Copiar archivos con el usuario correcto
COPY --chown=appuser:appgroup . .
RUN npm install --only=production

USER appuser
CMD ["node", "server.js"]

Error 2: Secretos en el Dockerfile o en variables de entorno planas

# ❌ NUNCA - el secreto queda grabado en una capa para siempre
ENV DB_PASSWORD=mipassword123
RUN curl -H "Authorization: Bearer TOKENREALAQUI" https://api.interna.com/config

# Aunque hagas otra capa que lo "borre", sigue en la historia de la imagen:
docker history mi-imagen  # el secreto es visible

# ✅ Para secrets en build-time, usar BuildKit secrets
# Esto NO deja rastro en las capas
# syntax=docker/dockerfile:1
FROM alpine
RUN --mount=type=secret,id=api_token     TOKEN=$(cat /run/secrets/api_token) &&     curl -H "Authorization: Bearer $TOKEN" https://api.interna.com/config

# Build:
docker build --secret id=api_token,src=./secrets/api_token .

# ✅ Para secrets en runtime, usar Docker secrets (Swarm) o variables de entorno
# via archivo .env que NUNCA va al repositorio
docker run --env-file .env.prod mi-imagen

Error 3: Imágenes base desactualizadas

# Escanear una imagen en busca de vulnerabilidades conocidas
docker scout cves mi-imagen:latest

# O con Trivy (más completo, lo que uso en CI)
docker run --rm   -v /var/run/docker.sock:/var/run/docker.sock   aquasec/trivy image mi-imagen:latest

# Resultado típico:
# 2026-03-11T10:00:00Z INFO Detected OS: alpine 3.18
# CRITICAL: 0, HIGH: 1, MEDIUM: 3, LOW: 8
# HIGH: libssl CVE-2024-XXXXX - update to 3.1.5-r0

Error 4: El .dockerignore inexistente

# Sin .dockerignore, COPY . . incluye todo — incluyendo:
# - .git/ (historial completo del repo)
# - .env (credenciales locales)
# - node_modules/ (pesado e innecesario)
# - tests/ (código de tests en la imagen de producción)

# .dockerignore completo que uso en todos mis proyectos:
.git
.gitignore
.env
.env.*
!.env.example
**/node_modules
**/bin
**/obj
**/*.log
**/.DS_Store
docker-compose*.yml
Dockerfile*
tests/
docs/
README.md
.github/

Dockerfile inseguro vs seguro: comparación completa

# ❌ Dockerfile inseguro
FROM node:latest                    # versión impredecible
ENV API_KEY=abc123supersecret       # secreto en capa
WORKDIR /app
COPY . .                            # sin .dockerignore, incluye .env y .git
RUN npm install                     # instala todo incluyendo devDependencies
EXPOSE 3000
CMD ["node", "server.js"]           # corre como root
# ✅ Dockerfile seguro
FROM node:20.11.0-alpine3.19        # versión fija y verificable

WORKDIR /app

# Usuario no-root
RUN addgroup -S app && adduser -S app -G app

# Dependencias primero (aprovecha cache, sin devDependencies)
COPY --chown=app:app package*.json ./
RUN npm ci --only=production && npm cache clean --force

# Código fuente (sin secretos - .dockerignore los excluye)
COPY --chown=app:app src/ ./src/

# Sin variables de entorno sensibles en la imagen
# Se pasan en runtime con --env-file

USER app
EXPOSE 3000

# Healthcheck
HEALTHCHECK --interval=30s --timeout=3s --retries=3   CMD wget -q -O /dev/null http://localhost:3000/health || exit 1

CMD ["node", "src/server.js"]

Limitar capacidades del contenedor

# Eliminar todas las capabilities de Linux y agregar solo las necesarias
docker run   --cap-drop=ALL   --cap-add=NET_BIND_SERVICE \   # solo si necesita bind a puerto < 1024
  --read-only \                   # sistema de archivos de solo lectura
  --tmpfs /tmp \                  # área de escritura temporal
  --security-opt=no-new-privileges   --user 1001:1001   mi-imagen:latest

El checklist que uso antes de cada deploy

  • ✅ ¿La imagen corre con usuario no-root?
  • ✅ ¿Hay .dockerignore con .env excluido?
  • ✅ ¿Los secretos van en runtime, no en la imagen?
  • ✅ ¿La imagen base tiene versión fija?
  • ✅ ¿Pasé Trivy o docker scout y no hay CVEs críticos?
  • ✅ ¿Los puertos expuestos son solo los necesarios?
  • ✅ ¿Hay healthcheck configurado?

Artículo anterior: Microservicios con Docker | Serie Docker Completo | Próximo: Docker vs Kubernetes →


Artículo anterior: Microservicios con Docker: lo que aprendí armando mi primera arquitectura | Serie Docker Completo | Próximo: Docker vs Kubernetes: cuándo me alcanza con uno y cuándo necesito el otro →

Microservicios con Docker: lo que aprendí armando mi primera arquitectura

Arrancé con un monolito .NET en un solo contenedor. Funcionaba bien, hasta que el equipo creció y todos tocábamos el mismo código. Desplegar un cambio en la pantalla de login requería redeployar toda la aplicación. Fue entonces cuando empecé a explorar microservicios con Docker.

Monolito vs microservicios: cuándo tiene sentido el cambio

AspectoMonolito en DockerMicroservicios en Docker
Complejidad inicialBajaAlta
Deploy independienteNo — todo o nadaSí — servicio por servicio
Escalabilidad selectivaNoSí — escalar solo lo que lo necesita
Fallo aisladoUn bug afecta todoUn servicio caído no baja todo
Equipos independientesDifícilCada equipo dueño de su servicio
Overhead operacionalBajoAlto — más servicios que monitorear

Mi recomendación: empezá con el monolito. Cuando los puntos de dolor de la tabla de arriba se vuelvan reales en tu día a día, ahí es el momento de dividir.

Mi primera arquitectura de microservicios: auth + api + frontend

# docker-compose.yml — tres servicios independientes

version: '3.8'

services:
  # Servicio de autenticación (JWT, usuarios)
  auth-service:
    build: ./services/auth
    environment:
      - DB_CONNECTION=Host=postgres;Database=auth;Username=auth;Password=${AUTH_DB_PASS}
      - JWT_SECRET=${JWT_SECRET}
      - JWT_EXPIRY=1h
    depends_on:
      postgres:
        condition: service_healthy
    restart: unless-stopped
    # Sin puerto expuesto - solo accesible internamente

  # API principal de negocio
  api-service:
    build: ./services/api
    environment:
      - DB_CONNECTION=Host=postgres;Database=apidb;Username=api;Password=${API_DB_PASS}
      - AUTH_SERVICE_URL=http://auth-service:8080
      - CACHE_URL=redis:6379
    depends_on:
      - auth-service
      - redis
    restart: unless-stopped

  # Frontend React
  frontend:
    build: ./services/frontend
    environment:
      - REACT_APP_API_URL=http://api-service:8080
    restart: unless-stopped

  # Proxy - único punto de entrada externo
  nginx:
    image: nginx:1.25-alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx/microservices.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - frontend
      - api-service

  # Infraestructura compartida
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_MULTIPLE_DATABASES: auth,apidb  # extensión para múltiples DBs
      POSTGRES_PASSWORD: ${POSTGRES_ROOT_PASS}
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready"]
      interval: 10s
      retries: 5

  redis:
    image: redis:7-alpine
    volumes:
      - redis-data:/data

volumes:
  postgres-data:
  redis-data:

Comunicación entre servicios

Dentro de la red Docker, los servicios se llaman por nombre. La API valida tokens llamando al servicio de auth en cada request:

# En el código de api-service (.NET):
// Validar token contra auth-service
var authResponse = await _httpClient.GetAsync(
    $"{_authServiceUrl}/validate?token={token}"
);

// En el docker-compose, AUTH_SERVICE_URL = http://auth-service:8080
// Docker resuelve "auth-service" al contenedor correcto automáticamente

Deploy independiente: la ventaja real

# Actualizar solo el servicio de auth sin tocar nada más
docker compose up -d --build auth-service

# Escalar solo la API (recibe más carga)
docker compose up -d --scale api-service=3

# Rollback solo del frontend
docker compose stop frontend
docker compose rm -f frontend
TAG=anterior docker compose up -d frontend

Lo que aprendí en el proceso

La transición de monolito a microservicios no es solo técnica: es organizacional. Cada servicio necesita su propio repositorio (o al menos su propia carpeta), su propio pipeline de CI/CD y su propio dueño. La complejidad operacional sube. Por eso Docker Compose no es suficiente para microservicios en producción a escala — ese es el camino hacia Kubernetes, que vemos en el próximo artículo.


Artículo anterior: Docker en CI/CD | Serie Docker Completo | Próximo: Seguridad en Docker →


Artículo anterior: Docker en mi pipeline de CI/CD: builds reproducibles sin sorpresas | Serie Docker Completo | Próximo: Seguridad en Docker: errores que cometí y cómo los corregí →