Biblia — Desarrollador Front Semi Sr Angular

Vacante: Capgemini (vía reclutadora externa) · Candidato: Roy Airy García Beltrán · 5 años de Angular en un punto de venta nacional
v1 · 2026-09-23 Angular 20+ · Signals · RxJS · Reactive Forms Híbrido CDMX · 8:30–18:00 Hablado + escrito

La vacante en una pantalla

Capgemini es una consultora grande: te van a entrevistar primero la reclutadora (encaje, salario, modalidad), después un líder técnico (hablado + a veces un ejercicio en vivo) y muchas veces el cliente final. La lista de "imprescindibles" es casi un temario: cada punto es una pregunta segura. Esta biblia sigue ese orden.

Lo que piden, literal → dónde está aquí

RequisitoPestañaTu ancla real
Angular (v20+ preferente)TodasMigraste el backoffice a Angular 20.3 con Native Federation, e hiciste las actualizaciones del POS de 17→18 y 18→19.2 (204 archivos). Estás justo en la versión que piden
Arquitectura modular (Core, Shared, Feature)Arquitectura modularEl punto de venta está hecho literalmente así: core/ (servicios, interceptores, guards), shared/ (componentes + 25 modales de negocio) y 14 features lazy (tienda, pago, entrega, lealtad…). El backoffice lo lleva a microfrontends: host + 16 módulos (13 remotos en el manifiesto) + librería
Signals para estado en componentesSignalsInventarios y recepción electrónica: signal() + computed() (puedeAgregarLote)
RxJS para flujos asíncronos y eventosRxJSBuscador de productos compartido: Subject + debounceTime(500) + takeUntil, paginación por hash
Reactive FormsReactive FormsTodo el repo: más de 1,200 FormControl, 570 FormBuilder, validadores required/pattern/email/compose
Componentes reutilizables y desacopladosComponentes reutilizablesLa librería común del backoffice (216 de sus 328 commits son tuyos) y tu directiva anti doble clic, usada en 23 botones de pago y cancelación del POS
APIs REST e interceptores HTTPHTTP e interceptoresEl POS tiene 4 interceptores HTTP (auth, timeout, logging, contexto) que escribieron tus compañeros y tú mantuviste; tú creaste el GlobalErrorHandler que registra cada error de las terminales con sucursal, caja y línea
Rendimiento (OnPush, lazy loading, trackBy)RendimientoOnPush en ~32 componentes, cada remoto se carga bajo demanda
Deseable: Java 21 (la reclutadora dijo 20), QuarkusJava 20 · QuarkusNo lo usas. Tu backend es .NET — traza el paralelo con honestidad

Semáforo de encaje

RequisitoCómo lo manejas
4 años de experiencia✅ 5+Enero 2021 a agosto 2026, menos el hueco de ago–dic 2021 ≈ 5 años. Di siempre "más de cinco años" en total y "más de cuatro años" en la cadena farmacéutica
Stack Angular completo✅ FuerteEs exactamente tu día a día. La vacante está hecha para tu perfil técnico
Inglés técnico✅ Alcanza"Técnico" = leer documentación y escribir commits/tickets. Lo cumples. No prometas conversación
Java 20 / Quarkus🟡 NoEs deseable, no imprescindible. Pestaña Java: 5 minutos de conceptos para no quedarte en blanco
Licenciatura concluida🟡 PasanteDilo tú antes de que salga en el estudio socioeconómico: "Soy pasante de Ingeniería en TIC por el Tecnológico de Tlalnepantla; la titulación está en trámite." En consultoras suele bastar con carta de pasante o historial. Pregunta si es requisito del cliente
Híbrido CDMX❌ ChocaTu perfil es solo remoto y vives en Culiacán. Decide antes de la llamada (card de abajo)
Horario 8:30–18:00🟡 LargoEs horario de cliente. Anótalo como dato para la negociación, no como problema

Decide esto ANTES de hablar con la reclutadora

La vacante es híbrida en CDMX. Es la primera pregunta de filtro que te van a hacer ("¿cuántos días puedes ir a oficina?"). Tienes tres respuestas honestas; escoge una y sostenla:

  1. Me mudo. "Tengo disponibilidad para reubicarme a CDMX; necesitaría unas dos semanas." Entonces el sueldo tiene que cubrir la mudanza: pide arriba de tu objetivo.
  2. Voy por periodos. "Puedo ir a oficina cuando el proyecto lo pida, con aviso; mi base es remota." Sirve si el híbrido es flexible (pasa mucho en consultoras: depende del cliente).
  3. Solo remoto. "Mi modalidad es remota. ¿El cliente tiene alguna posición remota o el híbrido es obligatorio?" Puede cerrar la puerta, pero Capgemini tiene muchos proyectos y te pueden mover a otro.

❌ Lo que no: decir que sí y luego pedir remoto el primer día. Se pierde la confianza de la reclutadora, que es quien te propone para las siguientes vacantes.

Los 5 focos de estudio, en orden

  1. Signals + RxJS juntos — es la pregunta de moda en Angular 20: cuándo uno, cuándo el otro, y el puente (toSignal/toObservable). Pestañas Signals y RxJS.
  2. Arquitectura Core/Shared/Feature en standalone — en Angular 20 standalone es el enfoque recomendado (los NgModules siguen soportados, pero ya no son el default); tienes que saber explicar cómo se traduce eso a carpetas, rutas y providers. Pestaña Arquitectura.
  3. Reactive Forms a fondo — tipados, validador propio, FormArray, validación cruzada, validador asíncrono. Pestaña Forms.
  4. Un componente de cero, tecleando — tu última entrevista real fue eso: teclear un buscador completo. Pestaña Ejercicios escritos E1–E6.
  5. Guion de salario, modalidad y título — la reclutadora filtra con esto antes de que llegues al técnico. Pestaña Guion.

Lección de tu última entrevista

Ensayaste lo complejo y te pidieron lo simple pero tecleado: un buscador de productos completo, con una restricción rara ("sin HttpClient"). Aquí no te va a pasar: cada tema tiene su parte hablada y su ejercicio escrito, y la regla es la misma — pregunta la restricción antes de teclear y resuelve con una abstracción.

Cómo hablar

Las tres reglas de tu ex jefe. Un Semi Sr se distingue de un Jr por cómo explica, no por cuántas APIs recita.

Regla 1 — Seguridad

Cero "creo", "siento", "me parece", "más o menos". Si no sabes algo, dilo con firmeza: "No lo he usado en producción. Lo que sí conozco es X, y el equivalente sería Y."

Regla 2 — Contexto

Cada respuesta situada: dónde lo usaste, qué problema resolvía, qué decidiste. "En el backoffice de un punto de venta con 86 sucursales…"

Regla 3 — Término exacto

"Change detection", "zoneless", "glitch-free", "higher-order observable", "ControlValueAccessor", "content projection", "code splitting". Un término preciso vale por años de experiencia.

El molde: afirmación directa → caso Seus → término exacto → matiz. En 30–60 segundos, y ofreces profundizar: "¿Quieres que entre al detalle?"

Tu frase de contexto (úsala para abrir cualquier respuesta técnica)

"Trabajé más de cuatro años en el punto de venta y el backoffice de una cadena farmacéutica: 86 sucursales, más de 500 terminales. Si el punto de venta se cae, las sucursales dejan de vender, así que cada decisión de front tenía que ser estable y mantenible."
No digas: el nombre de la cadena ni la cifra exacta de facturación. Di "del orden de millones de pesos en transacciones al día". Y no digas "86 sucursales" y "86 commits" en la misma respuesta: suena a error.

Cuando no sabes (plantillas)

"Eso no lo he usado en producción. El concepto es [X]; lo más parecido que resolví fue [caso]. ¿Es central para el proyecto?"
"En mi stack el equivalente es [Y]. Los principios son los mismos: [1–2]. La sintaxis la aprendo rápido."

Arquitectura modular — Core, Shared, Feature

La vacante dice "Core, Shared, Feature Modules", pero pide Angular 20, donde lo estándar ya son componentes standalone. La respuesta que te distingue: la idea de las tres capas sigue viva; lo que cambió es cómo se implementa.

¿Cómo organizas una aplicación Angular grande?

"En tres capas con reglas de dependencia. Core es lo que existe una sola vez en la app: interceptores, guards, servicios de sesión, el layout del shell. Shared son piezas reutilizables sin estado de negocio: componentes de UI, pipes, directivas, validadores. Y cada Feature es un área de negocio que se carga por lazy loading. La regla es que un feature puede usar Shared y Core, pero nunca importa de otro feature. El punto de venta donde trabajé está hecho exactamente así: en core los interceptores, el guard de sesión y los servicios; en shared los componentes y unos 25 modales de negocio — búsqueda de medicamento, pago, tarjeta de lealtad —; y catorce features cargados por lazy loading: tienda, pago, entrega, lealtad. Y en el backoffice lo llevamos al extremo: el shell es el Core, una librería común es el Shared, y cada feature es un microfrontend independiente con Native Federation."

Tus dos arquitecturas, para dibujarlas

Punto de venta (Angular 19.2)Backoffice (Angular 20.3)
Corecore/: 4 interceptores HTTP (auth, timeout, logging, contexto) + el GlobalErrorHandler, sessionGuardFn, servicioshost/: shell, rutas, sidebar, manifiesto de federation (13 remotos)
Sharedshared/ + catalogo/ (botones sw-button) + directives/common/tools/projects/libreria, compartida en federation
Feature14 NgModules lazy dentro de LayoutComponent16 módulos (13 publicados como remotos en el manifiesto), cada uno con su build y su deploy
EstadoServicios + ObservablesSignals-first en lo nuevo (signal/computed/inject)

Cómo lo cuentas: "Conozco las dos versiones del mismo patrón: la clásica, con NgModules y lazy loading dentro de una sola app, y la distribuida, con microfrontends standalone. Sé cuándo conviene cada una."

CapaQué vaQué NO vaEn Angular 20 (standalone)
CoreInterceptores, guards, AuthService, manejo global de errores, layout/shell, configComponentes que usan los featuresProviders en app.config.ts (provideHttpClient(withInterceptors(...)), provideRouter). Ya no hay CoreModule con guard anti doble import: el singleton lo da providedIn: 'root'
SharedBotones, inputs, tablas, modales, pipes, directivas, validadores, modelos comunesServicios con estado de negocio, llamadas HTTP de un featureCada pieza es standalone y se importa donde se usa. Ya no hay un SharedModule gigante que arrastra todo a todos los bundles
FeaturePantallas de un área (pedidos, inventario), sus servicios y su estadoImports desde otro featureloadChildren: () => import('./features/pedidos/pedidos.routes') con su propio arreglo de rutas y providers a nivel de ruta

Estructura de carpetas que puedes dibujar

src/app/
  core/
    interceptors/   auth.interceptor.ts · error.interceptor.ts
    guards/         auth.guard.ts
    services/       session.service.ts · notification.service.ts
    layout/         shell.component.ts
  shared/
    ui/             button/ · data-table/ · form-field/ · modal/
    pipes/          currency-mx.pipe.ts
    directives/     autofocus.directive.ts
    validators/     rfc.validator.ts
    models/         paged-result.ts
  features/
    pedidos/
      pedidos.routes.ts        ← lazy
      data/  pedidos.api.ts    ← HTTP del feature
      state/ pedidos.store.ts  ← signals
      pages/ lista-pedidos/ · detalle-pedido/
      ui/    pedido-card/      ← presentacionales del feature
  app.config.ts · app.routes.ts
// app.routes.ts
export const routes: Routes = [
  { path: '', component: ShellComponent, canActivate: [authGuard], children: [
    { path: 'pedidos', loadChildren: () => import('./features/pedidos/pedidos.routes')
                                            .then(m => m.PEDIDOS_ROUTES) },
    { path: 'inventario', loadComponent: () => import('./features/inventario/inventario.page')
                                            .then(m => m.InventarioPage) },
  ]},
  { path: '**', redirectTo: '' }
];

// features/pedidos/pedidos.routes.ts
export const PEDIDOS_ROUTES: Routes = [
  { path: '', providers: [PedidosStore],      // estado vive mientras vives en el feature
    children: [
      { path: '', component: ListaPedidosPage },
      { path: ':id', component: DetallePedidoPage, resolve: { pedido: pedidoResolver } },
    ]}
];

Repreguntas

"¿Todavía usas NgModules?"
"En código nuevo no: desde Angular 17 standalone es el default y en 19 pasó a ser implícito. NgModules los mantengo en módulos heredados; la migración se hace con el schematic ng g @angular/core:standalone, por pasos. Lo importante no es el NgModule, es la frontera: qué expone cada capa y quién puede importar a quién."
"¿Cómo evitas que un feature importe de otro?"

Regla de lint. En Nx: @nx/enforce-module-boundaries con tags (type:feature solo depende de type:ui, type:data-access, type:util). Sin Nx: eslint-plugin-boundaries o path aliases (@shared/*, @core/*) + revisión en PR. Si dos features necesitan lo mismo, eso se sube a Shared (o a una librería), no se importa de lado.

"¿Smart vs dumb components?"
"Contenedor (smart) sabe de dónde vienen los datos: inyecta el store o el servicio y orquesta. Presentacional (dumb) solo recibe input() y emite output(), es OnPush y no inyecta servicios de negocio. Así el presentacional se reusa y se testea sin mocks de HTTP."
"¿Por qué microfrontends y no un monolito modular?" (te lo van a preguntar por tu CV)
"Porque el backoffice tenía más de 16 áreas que se desplegaban a ritmos distintos y un build único tardaba demasiado. Con Native Federation cada remoto se construye y despliega solo, y el shell los carga por un manifiesto. Lo que cuesta: compartir dependencias sin duplicar versiones de Angular, y la comunicación entre remotos, que resolvimos con la librería común y no con imports cruzados. Para un equipo chico, un monolito modular bien cortado es mejor — microfrontends se justifican por la organización, no por la tecnología."
"¿Dónde pones el estado?"

Tres niveles: local del componente (signal dentro del componente), del feature (servicio con signals provisto en la ruta del feature → muere al salir), global (providedIn: 'root': sesión, usuario, permisos). NgRx/SignalStore solo si hay mucho estado compartido con efectos complejos; en el backoffice no lo usamos — servicios con signals bastaban.

Signals — estado en componentes

Es la pregunta obligada de Angular 20. Ten claro: qué es, las 5 primitivas, cuándo no usar effect, y cómo convive con RxJS.

¿Qué es un signal y por qué existe?

"Un signal es un contenedor de valor reactivo que sabe quién lo lee. Cuando cambia, Angular sabe exactamente qué vistas y qué computed dependen de él, y solo actualiza eso. Antes, con Zone.js, cualquier evento disparaba change detection sobre todo el árbol. Con signals la reactividad es fina y explícita, y abre la puerta a apps zoneless. En inventarios lo usé así: la lista de lotes es un signal, y si se puede agregar un lote es un computed que depende de la lista y del formulario — no hay suscripciones que limpiar."

Las primitivas

APIPara quéEjemplo
signal(v)Estado escribibleitems = signal<Item[]>([]) · .set() · .update(fn)
computed(fn)Derivado, memoizado, lazy, solo lecturatotal = computed(() => this.items().reduce(...))
effect(fn)Efecto secundario hacia fuera de AngularGuardar en localStorage, log, pintar un canvas
linkedSignalEscribible que se resetea cuando cambia su fuentePágina actual que vuelve a 1 cuando cambia el filtro
input() / input.required()Inputs como signalproducto = input.required<Producto>()
output()Evento hacia el padre (no es signal)seleccionado = output<Producto>()
model()Two-way binding con signalvalor = model(0)[(valor)]="x"
viewChild() / contentChildren()Queries como signalinput = viewChild.required<ElementRef>('q')
resource / httpResourceCarga asíncrona como signal (valor, loading, error)Experimental en v19–20; menciónalo, no lo vendas como estándar
@Component({
  selector: 'app-carrito',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (l of lineas(); track l.sku) {
      <app-linea [linea]="l" (quitar)="quitar(l.sku)" />
    } @empty { <p>Carrito vacío</p> }
    <p>Total: {{ total() | currency:'MXN' }}</p>
    <button [disabled]="!puedeCobrar()">Cobrar</button>`
})
export class CarritoComponent {
  lineas = signal<Linea[]>([]);
  total = computed(() => this.lineas().reduce((s, l) => s + l.precio * l.cantidad, 0));
  puedeCobrar = computed(() => this.lineas().length > 0 && this.total() > 0);

  agregar(l: Linea) { this.lineas.update(ls => [...ls, l]); }       // inmutable
  quitar(sku: string) { this.lineas.update(ls => ls.filter(x => x.sku !== sku)); }
}
Error clásico: this.lineas().push(l). Mutas el arreglo, la referencia no cambia y el signal no notifica. Siempre update con un arreglo nuevo.

Repreguntas

"¿Cuándo usas effect?"
"Casi nunca. effect es para sincronizar con algo que no es Angular: localStorage, una librería de gráficas, analítica. Si lo uso para escribir en otro signal, es olor: eso es un computed o un linkedSignal. Un effect que copia estado genera ciclos y renders de más."
"¿Signals reemplaza a RxJS?"
"No, se complementan. Signals es para estado: un valor que siempre existe y se lee sincrónicamente. RxJS es para eventos en el tiempo: teclazos, websockets, cancelar peticiones, reintentos, combinar flujos. El patrón que uso: RxJS en el borde asíncrono y toSignal para llevar el resultado a la plantilla."
private q = signal('');
resultados = toSignal(
  toObservable(this.q).pipe(
    debounceTime(300), distinctUntilChanged(),
    switchMap(q => q.length < 3 ? of([]) : this.api.buscar(q))
  ), { initialValue: [] as Producto[] }
);
"¿Qué es glitch-free?"

Un computed nunca ve un estado intermedio inconsistente: si a y b cambian, el derivado se recalcula una vez con los dos valores nuevos, no con uno viejo y uno nuevo. Además es lazy (no calcula hasta que alguien lo lee) y memoizado (si las dependencias no cambian, no recalcula).

"¿Qué es zoneless?"

Quitar Zone.js: provideZonelessChangeDetection() (estable en Angular 20.2). Change detection se dispara por signals, eventos de plantilla, async pipe y markForCheck — no por cualquier setTimeout. Gana bundle más chico y menos ciclos. Requisito: componentes compatibles con OnPush (estado en signals o observables con async).

"¿Cómo migras un @Input a signal?"

@Input() x!: Tx = input.required<T>(); en la plantilla xx(); ngOnChanges que recalculaba algo → computed. Hay schematic: ng g @angular/core:signal-input-migration (y los de output y queries).

"¿Y NgRx / SignalStore?"
"En el backoffice no usamos NgRx: servicios con signals por feature bastaban. Si el estado compartido crece y hay muchos efectos, evaluaría NgRx SignalStore, que es más ligero que el Store clásico con acciones y reducers. La decisión la tomo por complejidad, no por moda."

RxJS — flujos asíncronos y eventos

¿Observable vs Promesa?

"La promesa emite un solo valor, arranca al crearse y no se cancela. El Observable emite cero o muchos valores, es lazy — no hace nada hasta que te suscribes — y se cancela con unsubscribe. En el backoffice conviven los dos: los flujos de un solo disparo, como guardar un pedido, los modelamos con promesas y await; los flujos continuos, como el buscador de productos, con RxJS."

Los 4 operadores de aplanado (la pregunta más común)

OperadorSi llega uno nuevo mientras el anterior sigue…Úsalo en
switchMapCancela el anteriorBúsqueda, autocompletar, cambio de filtro
concatMapLo encola, en ordenGuardados que deben ir en secuencia
mergeMapCorren en paraleloSubir varios archivos independientes
exhaustMapLo ignora hasta que termineBotón "Cobrar" / login: evita doble envío
"Justo tuve ese problema en el punto de venta: doble clic en 'Cobrar'. Lo resolví con una directiva con throttleTime, que bloquea 800 ms. Si lo hiciera hoy usaría exhaustMap, porque bloquea mientras la petición siga viva y no por un tiempo fijo."
Regla para recordarlo: ¿leer? → switchMap. ¿escribir en orden? → concatMap. ¿escribir y que no se repita? → exhaustMap. ¿muchos a la vez? → mergeMap.

Combinar flujos

OperadorEmiteCaso
forkJoinUna vez, cuando todos completanCargar catálogos al abrir una pantalla
combineLatestCada vez que cualquiera emite (tras el primero de todos)Filtros + página + orden → recargar
withLatestFromSolo cuando emite el principal, con el último del otroClic en guardar + estado actual del form
mergeTodo lo de todos, intercaladoVarios eventos que disparan lo mismo

Subjects

TipoAl suscribirte recibesCaso
SubjectNada del pasadoEventos: clic, teclazo
BehaviorSubjectEl último valor (requiere inicial)Estado (hoy casi siempre lo reemplaza un signal)
ReplaySubject(n)Los últimos nCaché de las últimas respuestas

Fugas de memoria — cómo te desuscribes

  1. async pipe o toSignal — se desuscriben solos. Primera opción.
  2. takeUntilDestroyed() (Angular 16+) — en contexto de inyección, o pasándole DestroyRef.
  3. El patrón viejo: takeUntil(this.destroy$) + ngOnDestroy. Es el que tiene el buscador compartido del backoffice.
export class MonitorComponent {
  private api = inject(PedidosApi);
  constructor() {
    interval(30_000).pipe(
      startWith(0),
      switchMap(() => this.api.pendientes()),
      takeUntilDestroyed()
    ).subscribe(p => this.pendientes.set(p));
  }
  pendientes = signal<Pedido[]>([]);
}
Detalle que te distingue: takeUntilDestroyed va al final del pipe. Si lo pones antes de un switchMap, el interno sigue vivo.

Manejo de errores

this.api.buscar(q).pipe(
  retry({ count: 2, delay: 1000 }),          // reintenta solo errores transitorios
  catchError(err => {                        // DENTRO del switchMap: el flujo externo sobrevive
    this.error.set('No se pudo buscar');
    return of([]);
  })
)
"¿Por qué el catchError va dentro del switchMap?"
"Porque un error completa el observable. Si el catchError está afuera, el primer error mata el buscador y los siguientes teclazos ya no hacen nada. Dentro del switchMap, solo muere esa petición y el flujo de teclazos sigue vivo."
"¿Cold vs hot?"

Cold: cada suscriptor dispara su propia ejecución (HTTP: dos subscribe = dos peticiones). Hot: la fuente existe aparte y los suscriptores la comparten (fromEvent, un Subject). Para compartir una petición: shareReplay({ bufferSize: 1, refCount: true }).

"¿Cómo harías un buscador con eventos del DOM?"
fromEvent<InputEvent>(this.input().nativeElement, 'input').pipe(
  map(e => (e.target as HTMLInputElement).value.trim()),
  debounceTime(300), distinctUntilChanged(), filter(q => q.length >= 3),
  switchMap(q => this.api.buscar(q).pipe(catchError(() => of([])))),
  takeUntilDestroyed(this.destroyRef)
).subscribe(r => this.resultados.set(r));

Aunque en la práctica prefieres FormControl.valueChanges — ya tienes el valor sin tocar el DOM.

Reactive Forms

Aquí tienes ventaja real: el backoffice usa Reactive Forms en todos lados. Lo que un Semi Sr debe mostrar: formularios tipados, validadores propios, validación cruzada, asíncrona y FormArray.

¿Template-driven vs Reactive?

"Reactive: el modelo del formulario vive en TypeScript, es síncrono, tipado y testeable sin renderizar. Template-driven vive en la plantilla con ngModel; sirve para formularios de dos campos. En el backoffice todo es reactive porque las capturas son largas — pedidos, recepción de mercancía — con validaciones que dependen de otros campos."

El formulario completo que tienes que poder teclear

type LineaForm = FormGroup<{
  sku: FormControl<string>;
  cantidad: FormControl<number>;
}>;

@Component({ /* ... */ imports: [ReactiveFormsModule] })
export class PedidoFormComponent {
  private fb = inject(NonNullableFormBuilder);   // no-nullable: reset() vuelve al inicial, no a null
  private api = inject(ProveedoresApi);

  form = this.fb.group({
    proveedor: this.fb.control('', {
      validators: [Validators.required],
      asyncValidators: [proveedorExiste(this.api)],
      updateOn: 'blur'                           // el async no pega al servidor en cada tecla
    }),
    rfc: ['', [Validators.required, rfcValidator()]],
    fechaEntrega: ['', Validators.required],
    fechaLimite: ['', Validators.required],
    lineas: this.fb.array<LineaForm>([], Validators.minLength(1)),
  }, { validators: rangoFechas('fechaEntrega', 'fechaLimite') });

  get lineas() { return this.form.controls.lineas; }

  agregarLinea() {
    this.lineas.push(this.fb.group({
      sku: ['', Validators.required],
      cantidad: [1, [Validators.required, Validators.min(1)]],
    }));
  }
  quitarLinea(i: number) { this.lineas.removeAt(i); }

  guardar() {
    if (this.form.invalid) { this.form.markAllAsTouched(); return; }
    const pedido = this.form.getRawValue();       // tipado; incluye campos deshabilitados
    // ...
  }
}
<form [formGroup]="form" (ngSubmit)="guardar()">
  <input formControlName="rfc" />
  @if (form.controls.rfc.touched && form.controls.rfc.hasError('rfc')) {
    <small>RFC inválido</small>
  }
  @if (form.hasError('rangoFechas')) { <small>La entrega debe ser antes del límite</small> }

  <div formArrayName="lineas">
    @for (l of lineas.controls; track l; let i = $index) {
      <div [formGroupName]="i">
        <input formControlName="sku" />
        <input type="number" formControlName="cantidad" />
        <button type="button" (click)="quitarLinea(i)">Quitar</button>
      </div>
    }
  </div>
  <button type="button" (click)="agregarLinea()">Agregar línea</button>
  <button type="submit" [disabled]="form.pending">Guardar</button>
</form>

Validadores

// Síncrono, de un control
export function rfcValidator(): ValidatorFn {
  const re = /^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/;
  return (c: AbstractControl): ValidationErrors | null =>
    !c.value || re.test(c.value.toUpperCase()) ? null : { rfc: true };
}

// Cruzado, a nivel de grupo
export function rangoFechas(desde: string, hasta: string): ValidatorFn {
  return (g: AbstractControl) => {
    const a = g.get(desde)?.value, b = g.get(hasta)?.value;
    return a && b && a > b ? { rangoFechas: true } : null;
  };
}

// Asíncrono
export function proveedorExiste(api: ProveedoresApi): AsyncValidatorFn {
  return (c) => !c.value ? of(null) : api.existe(c.value).pipe(
    map(ok => ok ? null : { proveedorNoExiste: true }),
    catchError(() => of(null))                    // si el servicio falla, no bloquees el form
  );
}
Detalles que te distinguen: null = válido (no false). Validador vacío devuelve null y deja a required encargarse. El async solo corre si los síncronos pasan. form.pending mientras corre.

Repreguntas

"¿Cómo reaccionas a cambios de un campo?"
// Si el tipo de pago es tarjeta, habilita "referencia" y hazla obligatoria
this.form.controls.tipoPago.valueChanges.pipe(takeUntilDestroyed()).subscribe(tipo => {
  const ref = this.form.controls.referencia;
  if (tipo === 'tarjeta') { ref.enable(); ref.addValidators(Validators.required); }
  else { ref.disable(); ref.removeValidators(Validators.required); ref.reset(); }
  ref.updateValueAndValidity();
});

O a signals: tipoPago = toSignal(this.form.controls.tipoPago.valueChanges, { initialValue: ... }).

"¿value vs getRawValue?"

value excluye los controles deshabilitados (y en forms no tipados todo es Partial). getRawValue() trae todo. Para enviar al backend casi siempre getRawValue().

"¿setValue vs patchValue?"

setValue exige la estructura completa (falla si falta una llave). patchValue acepta parcial. Para cargar un registro para edición: patchValue. Con { emitEvent: false } no disparas valueChanges.

"¿Cómo haces un input propio que funcione con formControlName?"

Implementando ControlValueAccessor. Está resuelto en la pestaña Componentes reutilizables y en el ejercicio E3.

"¿Signal Forms?"

Es la API nueva basada en signals (experimental a partir de Angular 21). Menciónalo como algo que estás siguiendo; en producción hoy se usan Reactive Forms.

Componentes reutilizables y sistemas de UI

Es la "actividad a desempeñar" principal: componentes reutilizables y sistemas de UI escalables, mantenibilidad y consistencia del diseño. Van a querer ver que sabes hacer una pieza que otros usan sin leer su código.

¿Qué hace reutilizable a un componente?

"Cuatro cosas. Uno, API chica y tipada: input() para datos, output() para eventos, y nada de inyectar servicios de negocio. Dos, content projection para que el que lo usa ponga su contenido sin que yo prevea cada caso. Tres, se integra con formularios implementando ControlValueAccessor. Y cuatro, el estilo sale de tokens, variables CSS, no de colores escritos a mano. En el backoffice el buscador de productos era así: lo usaban varios módulos, recibía configuración, emitía el producto elegido y no sabía nada del pedido que lo usaba."

1. API: input, output, model

@Component({
  selector: 'ui-button',
  changeDetection: ChangeDetectionStrategy.OnPush,
  host: { '[class]': '"btn btn-" + variant()', '[attr.aria-busy]': 'loading()' },
  template: `
    <button [type]="type()" [disabled]="disabled() || loading()" (click)="clicked.emit($event)">
      @if (loading()) { <span class="spinner" aria-hidden="true"></span> }
      <ng-content />
    </button>`
})
export class UiButton {
  variant = input<'primary' | 'secondary' | 'danger'>('primary');
  type = input<'button' | 'submit'>('button');
  disabled = input(false, { transform: booleanAttribute });   // permite <ui-button disabled>
  loading = input(false, { transform: booleanAttribute });
  clicked = output<MouseEvent>();
}

2. Content projection

<!-- ui-card.html -->
<header><ng-content select="[card-title]" /></header>
<section><ng-content /></section>
<footer><ng-content select="[card-actions]" /></footer>

<!-- uso -->
<ui-card>
  <h3 card-title>Pedido 1024</h3>
  <p>3 líneas · $1,250</p>
  <ui-button card-actions (clicked)="ver()">Ver</ui-button>
</ui-card>

Para tablas reutilizables donde el que usa decide cómo pintar cada celda: ng-template + ngTemplateOutlet con contexto (let-row). Es el patrón de mat-table.

3. ControlValueAccessor (la pregunta de Semi Sr)

@Component({
  selector: 'ui-cantidad',
  providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => UiCantidad), multi: true }],
  template: `
    <button type="button" (click)="cambiar(-1)" [disabled]="disabled()">−</button>
    <span>{{ valor() }}</span>
    <button type="button" (click)="cambiar(1)" [disabled]="disabled()">+</button>`
})
export class UiCantidad implements ControlValueAccessor {
  min = input(0);
  valor = signal(0);
  disabled = signal(false);
  private onChange: (v: number) => void = () => {};
  private onTouched: () => void = () => {};

  writeValue(v: number | null) { this.valor.set(v ?? 0); }             // form → componente
  registerOnChange(fn: (v: number) => void) { this.onChange = fn; }    // componente → form
  registerOnTouched(fn: () => void) { this.onTouched = fn; }
  setDisabledState(d: boolean) { this.disabled.set(d); }

  cambiar(delta: number) {
    const v = Math.max(this.min(), this.valor() + delta);
    this.valor.set(v); this.onChange(v); this.onTouched();
  }
}
// uso: <ui-cantidad formControlName="cantidad" [min]="1" />
Los 4 métodos: writeValue (entra), registerOnChange (sale), registerOnTouched (tocado), setDisabledState (deshabilitar). Y el provider NG_VALUE_ACCESSOR con multi: true.

4. Consistencia de diseño — tokens

/* styles/tokens.scss */
:root {
  --color-primary: #1f5fa8; --color-danger: #b3261e;
  --space-2: 8px; --space-4: 16px; --radius: 8px;
  --font-body: 14px/1.5 system-ui;
}
/* ui-button.scss — solo tokens, nunca hex sueltos */
:host { display: inline-block; }
.btn-primary button { background: var(--color-primary); padding: var(--space-2) var(--space-4); border-radius: var(--radius); }

Repreguntas

"¿Directiva o componente?"

Directiva cuando agregas comportamiento a un elemento existente sin cambiar su plantilla (autofocus, permisos, máscara de input). Componente cuando tienes plantilla propia. Para componer comportamientos: hostDirectives.

"Un ejemplo mío: en el punto de venta los cajeros daban doble clic en 'Cobrar' o 'Cancelar pedido' y se mandaba la operación dos veces. En vez de parchar cada botón hice una directiva, appPreventDoubleClick: intercepta el clic, hace preventDefault y stopPropagation, lo pasa por un Subject con throttleTime configurable y emite un output throttledClick. Se desuscribe en ngOnDestroy. Hoy está en 23 botones: pago, envío, cancelación de folios y de pedidos a domicilio. Es comportamiento reutilizable sin tocar la plantilla de nadie."
El matiz que te sube de nivel (dilo tú antes de que te lo pregunten): "throttleTime bloquea por tiempo fijo, 800 ms. Si el servidor tarda más, un tercer clic pasaría. Hoy lo haría con exhaustMap atado a la petición: ignora clics mientras la operación sigue en curso, dure lo que dure."
@Directive({ selector: '[appSiPermiso]' })
export class SiPermisoDirective {
  private tpl = inject(TemplateRef); private vcr = inject(ViewContainerRef);
  private sesion = inject(SesionService);
  permiso = input.required<string>({ alias: 'appSiPermiso' });
  constructor() {
    effect(() => {
      this.vcr.clear();
      if (this.sesion.tiene(this.permiso())) this.vcr.createEmbeddedView(this.tpl);
    });
  }
}
// <button *appSiPermiso="'pedidos.cancelar'">Cancelar</button>
"¿Cómo desacoplas un componente del origen de sus datos?"
"Con una abstracción inyectable. El componente depende de un InjectionToken o de una clase abstracta, y cada contexto provee su implementación. Es justo lo que me pidieron en una prueba: un buscador sin HttpClient. El componente recibe un ProductSearchService por token; en producción es HTTP, en pruebas es un arreglo en memoria."
"¿Librería compartida entre proyectos?"

ng g library ui → se compila con ng-packagr, expone un public-api.ts (lo que no se exporta ahí es privado), versionada con semver. En el backoffice era common/tools/projects/libreria, consumida por los remotos y compartida en federation para no duplicarla.

"¿Accesibilidad en componentes propios?"

Usa el elemento nativo (<button>, no <div (click)>), aria-* cuando no hay nativo, foco visible, navegación con teclado. El CDK trae FocusTrap, LiveAnnouncer y ListKeyManager para no reinventarlo.

APIs REST e interceptores HTTP

¿Cómo organizas el consumo de APIs?

"El componente nunca llama a HttpClient. Hay una capa de datos por feature que sabe las URLs y los tipos, y un servicio que orquesta: loading, errores, transformar la respuesta. En el backoffice era un patrón de cuatro capas: interfaz del contrato, un assembly que arma el request, la capa que hace el HTTP y el servicio que orquesta. Lo transversal va en interceptores: en el punto de venta había cuatro — el que agrega el token, uno de timeout, uno de logging y uno que valida el contexto de venta. Y para los errores que no son HTTP hice un ErrorHandler global."

Tu historia fuerte aquí: el GlobalErrorHandler (lo creaste tú, ene 2024)

"Con más de 500 terminales en 86 sucursales, un error en el navegador de un cajero era invisible: nadie lo reportaba bien. Creé un ErrorHandler global que captura cualquier excepción no controlada y la manda a un servicio de log con el contexto que necesitábamos para reproducirla: sucursal, caja, IP, versión desplegada, la URL, y el método y la línea sacados del stack. Además trata aparte el ChunkLoadError: pasa cuando publicas una versión nueva y una terminal que tenía la vieja abierta intenta cargar un módulo lazy que ya no existe. Ese lo registramos distinto porque la causa es el despliegue, no el código. Y filtra los errores de red de status 0 para no llenar el log de ruido."
La repregunta que te van a hacer: "¿ErrorHandler o interceptor?" → El interceptor solo ve errores HTTP y puede reaccionar por petición (redirigir en 401, reintentar). El ErrorHandler ve todo lo no controlado: excepciones de JavaScript, promesas rechazadas, fallos de carga de chunks. Se complementan: el interceptor maneja y el ErrorHandler registra lo que se escapó.
// app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withComponentInputBinding()),
    provideHttpClient(withInterceptors([authInterceptor, loadingInterceptor, errorInterceptor])),
  ]
};

// features/pedidos/data/pedidos.api.ts
@Injectable({ providedIn: 'root' })
export class PedidosApi {
  private http = inject(HttpClient);
  private base = `${environment.apiUrl}/pedidos`;

  listar(f: FiltroPedidos) {
    const params = new HttpParams({ fromObject: { page: f.page, size: f.size, estatus: f.estatus ?? '' } });
    return this.http.get<PagedResult<Pedido>>(this.base, { params });
  }
  obtener(id: number) { return this.http.get<Pedido>(`${this.base}/${id}`); }
  crear(p: NuevoPedido) { return this.http.post<Pedido>(this.base, p); }
  actualizar(id: number, p: NuevoPedido) { return this.http.put<void>(`${this.base}/${id}`, p); }
  eliminar(id: number) { return this.http.delete<void>(`${this.base}/${id}`); }
}

Los tres interceptores que siempre te piden

// 1. Token
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(SesionService).token();
  if (!token || req.url.includes('/auth/')) return next(req);
  return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));   // req es inmutable
};

// 2. Loading global con opción de excluir
export const SIN_LOADING = new HttpContextToken<boolean>(() => false);
export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
  if (req.context.get(SIN_LOADING)) return next(req);
  const loading = inject(LoadingService);
  loading.inc();
  return next(req).pipe(finalize(() => loading.dec()));      // contador, no booleano
};
// uso: this.http.get(url, { context: new HttpContext().set(SIN_LOADING, true) })

// 3. Errores centralizados
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const router = inject(Router); const notif = inject(NotificacionService);
  return next(req).pipe(catchError((e: HttpErrorResponse) => {
    if (e.status === 401) router.navigate(['/login']);
    else if (e.status === 403) notif.error('No tienes permiso');
    else if (e.status === 0) notif.error('Sin conexión con el servidor');
    else if (e.status >= 500) notif.error('Error del servidor, intenta de nuevo');
    return throwError(() => e);        // re-lanza: el que llamó decide si hace algo más
  }));
};
Detalles que te distinguen: (1) el orden del arreglo importa: la petición pasa en ese orden y la respuesta en el inverso. (2) Loading con contador, porque con booleano la primera petición que termina apaga el spinner de las demás. (3) finalize corre en éxito, error y cancelación.

Repreguntas

"¿Refresh token?"

En el interceptor de errores, al recibir 401: llamar a /refresh una sola vez (compartido con shareReplay o un flag para que diez peticiones fallidas no disparen diez refresh), y reintentar la petición original con el token nuevo vía switchMap. Si el refresh falla → logout.

"¿Interceptor funcional vs de clase?"

Funcional (HttpInterceptorFn) es el recomendado desde Angular 15: sin clase, con inject(), registrado con withInterceptors. Los de clase siguen funcionando con withInterceptorsFromDi() — útil al migrar código viejo.

"¿Cómo manejas CORS?"

CORS lo resuelve el servidor (cabeceras Access-Control-Allow-*), no Angular. En desarrollo: proxy.conf.json en ng serve para que el navegador vea mismo origen. En el backoffice había un proxy .NET para el visor de reportes SSRS justamente por eso.

"¿Dónde guardas el token?"

Lo más seguro: cookie HttpOnly + Secure + SameSite que pone el backend (JavaScript no la lee → XSS no la roba). localStorage es común pero expuesto a XSS. Si es localStorage, compensa con CSP estricta y tokens de vida corta.

"¿Cómo pruebas un servicio HTTP?"

provideHttpClient() + provideHttpClientTesting() y HttpTestingController: expectOne(url), req.flush(datos), verify() en el afterEach. Ver pestaña Testing.

Optimización de rendimiento — OnPush, lazy loading, trackBy

¿Cómo optimizas una app Angular?

"Por capas. Carga: lazy loading por ruta y @defer para lo pesado dentro de una pantalla, y presupuestos de bundle en angular.json. Render: componentes OnPush con estado en signals, y track en las listas para no recrear el DOM. Datos: no pedir lo que no se ve — paginación en servidor, debounce en búsquedas, cancelar peticiones viejas con switchMap. Y mido antes de optimizar, con Angular DevTools y Lighthouse. En el backoffice el lazy loading lo llevamos a nivel de microfrontend: cada módulo solo se descarga cuando el usuario entra."

OnPush

Con ChangeDetectionStrategy.OnPush Angular revisa el componente solo cuando:

  1. Cambia la referencia de un input (no si mutas el objeto por dentro).
  2. Ocurre un evento en su plantilla o en sus hijos.
  3. Emite un observable con async pipe, o cambia un signal que la plantilla lee.
  4. Llamas markForCheck() a mano.
La trampa: this.pedido.estatus = 'X' en el padre no actualiza un hijo OnPush — la referencia es la misma. Inmutabilidad: this.pedido = { ...this.pedido, estatus: 'X' }, o mejor, estado en signals.

trackBy → track

<!-- Angular 17+: track es OBLIGATORIO en @for -->
@for (p of pedidos(); track p.id) { <app-pedido-card [pedido]="p" /> }

<!-- Equivalente viejo -->
<app-pedido-card *ngFor="let p of pedidos; trackBy: porId" [pedido]="p" />
porId = (_: number, p: Pedido) => p.id;
"Sin track por id, cuando llega una lista nueva — aunque sean los mismos pedidos — Angular compara por referencia, cree que todo es nuevo y destruye y recrea todo el DOM. Con track por id solo toca lo que cambió. Por eso en el control flow nuevo es obligatorio. Y ojo con track $index: sirve para listas estáticas, pero si reordenas o insertas, reutiliza mal los elementos."

Lazy loading y @defer

{ path: 'reportes', loadComponent: () => import('./features/reportes/reportes.page').then(m => m.ReportesPage) }

@defer (on viewport; prefetch on idle) {
  <app-grafica-ventas [datos]="ventas()" />     <!-- su código va en otro chunk -->
} @placeholder (minimum 300ms) {
  <div class="skeleton"></div>
} @loading (after 150ms) {
  <app-spinner />
} @error { <p>No se pudo cargar la gráfica</p> }
El costo del lazy loading que casi nadie menciona (y tú viviste): después de un deploy, una pestaña abierta con la versión vieja pide un chunk que ya no existe → ChunkLoadError. En el POS lo detectaba tu GlobalErrorHandler y lo registraba aparte. Solución típica: al detectarlo, recargar la página una vez, o usar SwUpdate para avisar que hay versión nueva.

Triggers de @defer: on idle (default), viewport, interaction, hover, immediate, timer(2s), when condicion. Estrategias de precarga de rutas: withPreloading(PreloadAllModules) o una propia.

Más cosas que suman puntos

TécnicaQué resuelve
Pipes puros en vez de métodos en plantilla{{ calcular(x) }} se ejecuta en cada ciclo; un pipe puro o un computed solo cuando cambia la entrada
NgOptimizedImage (ngSrc)Lazy de imágenes, srcset, prioridad para el LCP, evita saltos de layout
CDK Virtual ScrollListas de miles de filas: solo pinta las visibles
Budgets en angular.jsonEl build falla si el bundle crece de más
source-map-explorer / esbuild statsVer qué librería infla el bundle
ZonelessMenos ciclos de change detection y bundle más chico
SSR + hydration incrementalPrimer pintado rápido (si el proyecto lo necesita; un backoffice detrás de login normalmente no)
"Una pantalla va lenta. ¿Qué haces?"
  1. Medir: Angular DevTools → Profiler: qué componente tarda y cuántos ciclos de change detection hay. Pestaña Performance del navegador.
  2. ¿Es la red? Pestaña Network: peticiones duplicadas (cold observable suscrito dos veces), respuestas enormes (falta paginar).
  3. ¿Es render? Listas sin track, métodos en plantilla, componentes Default que se revisan de más → OnPush + signals.
  4. ¿Es carga? Bundle inicial grande → lazy loading, @defer, quitar librerías pesadas.
  5. Volver a medir y comparar.

Ejercicios escritos — para teclear

Tu última entrevista fue teclear un componente completo. Estos seis cubren los imprescindibles de la vacante. Protocolo para cada uno: (1) repite el enunciado con tus palabras, (2) pregunta restricciones (¿Material? ¿puedo usar HttpClient? ¿versión?), (3) di la estructura antes de teclear, (4) narra mientras escribes.

E1 · Buscador de productos (Signals + RxJS) 15 min

Enunciado típico: "Un input que busca productos mientras escribes; muestra resultados, estado de carga y error."

export interface Producto { sku: string; nombre: string; precio: number; }
export abstract class BuscadorProductos {                  // abstracción: no dependo de HttpClient
  abstract buscar(q: string): Observable<Producto[]>;
}

@Component({
  selector: 'app-buscador',
  imports: [ReactiveFormsModule, CurrencyPipe],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <input [formControl]="q" placeholder="Buscar (mín. 3 letras)" aria-label="Buscar producto" />
    @if (cargando()) { <p>Buscando…</p> }
    @if (error()) { <p role="alert">{{ error() }}</p> }
    <ul>
      @for (p of resultados(); track p.sku) {
        <li (click)="elegido.emit(p)">{{ p.nombre }} — {{ p.precio | currency:'MXN' }}</li>
      } @empty { @if (buscado()) { <li>Sin resultados</li> } }
    </ul>`
})
export class BuscadorComponent {
  private api = inject(BuscadorProductos);
  elegido = output<Producto>();
  q = new FormControl('', { nonNullable: true });
  cargando = signal(false);
  error = signal<string | null>(null);
  buscado = signal(false);

  resultados = toSignal(
    this.q.valueChanges.pipe(
      map(v => v.trim()),
      debounceTime(300),
      distinctUntilChanged(),
      tap(() => this.error.set(null)),
      switchMap(q => {
        if (q.length < 3) { this.buscado.set(false); return of([]); }
        this.cargando.set(true);
        return this.api.buscar(q).pipe(
          catchError(() => { this.error.set('No se pudo buscar'); return of([]); }),
          finalize(() => { this.cargando.set(false); this.buscado.set(true); })
        );
      })
    ), { initialValue: [] as Producto[] }
  );
}
// provider: { provide: BuscadorProductos, useClass: BuscadorProductosHttp }
"debounceTime para no pegarle al servidor en cada tecla, distinctUntilChanged para no repetir la misma búsqueda, switchMap para cancelar la anterior y evitar la race condition, catchError dentro para que un error no mate el flujo, y toSignal para que la plantilla lea un signal sin suscripciones manuales."

E2 · Formulario reactivo con FormArray y validador propio 15 min

Enunciado típico: "Captura de un pedido: proveedor, RFC válido, y N líneas con SKU y cantidad mayor a cero. No se puede guardar sin al menos una línea."

Solución completa en la pestaña Reactive Forms (formulario + plantilla + rfcValidator + rangoFechas). Practícalo sin mirar hasta que salga en menos de 12 minutos.

Lo que evalúan: NonNullableFormBuilder, validador que devuelve null, formArrayName + [formGroupName]="i", markAllAsTouched() al enviar inválido, getRawValue().

E3 · Componente reutilizable que funciona con formularios 12 min

Enunciado típico: "Haz un selector de cantidad (− n +) que se pueda usar con formControlName."

Solución en la pestaña Componentes reutilizablesUiCantidad con ControlValueAccessor.

"Para que funcione con formControlName implemento ControlValueAccessor: writeValue recibe el valor del formulario, registerOnChange me da la función para avisarle cuando cambia, registerOnTouched para marcarlo como tocado, y setDisabledState para cuando el formulario lo deshabilita. Me registro con NG_VALUE_ACCESSOR."

E4 · Interceptor de token + errores 8 min

Solución en la pestaña HTTP e interceptores. Tecléalo de memoria: funcional, req.clone({ setHeaders }), catchError por status, throwError al final, registro en withInterceptors.

E5 · Lista paginada con estado en un servicio de signals 15 min

Enunciado típico: "Tabla de pedidos paginada en servidor, con filtro por estatus. Si cambia el filtro, vuelve a la página 1."

@Injectable()   // provisto en la ruta del feature
export class PedidosStore {
  private api = inject(PedidosApi);
  readonly estatus = signal<string | null>(null);
  readonly pagina = linkedSignal({ source: this.estatus, computation: () => 1 });  // se resetea
  readonly tamano = signal(20);

  private resp = toSignal(
    toObservable(computed(() => ({ page: this.pagina(), size: this.tamano(), estatus: this.estatus() }))).pipe(
      tap(() => this.cargando.set(true)),
      switchMap(f => this.api.listar(f).pipe(
        catchError(() => of({ items: [], total: 0 } as PagedResult<Pedido>)),
        finalize(() => this.cargando.set(false))
      ))
    ), { initialValue: { items: [], total: 0 } as PagedResult<Pedido> }
  );

  readonly cargando = signal(false);
  readonly pedidos = computed(() => this.resp().items);
  readonly totalPaginas = computed(() => Math.max(1, Math.ceil(this.resp().total / this.tamano())));
  readonly hayAnterior = computed(() => this.pagina() > 1);
  readonly haySiguiente = computed(() => this.pagina() < this.totalPaginas());

  filtrar(e: string | null) { this.estatus.set(e); }
  siguiente() { if (this.haySiguiente()) this.pagina.update(p => p + 1); }
  anterior() { if (this.hayAnterior()) this.pagina.update(p => p - 1); }
}
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <select (change)="store.filtrar($any($event.target).value || null)">
      <option value="">Todos</option><option value="PENDIENTE">Pendiente</option>
    </select>
    <table><tbody>
      @for (p of store.pedidos(); track p.id) {
        <tr><td>{{ p.folio }}</td><td>{{ p.estatus }}</td></tr>
      }
    </tbody></table>
    <button (click)="store.anterior()" [disabled]="!store.hayAnterior()">‹</button>
    {{ store.pagina() }} / {{ store.totalPaginas() }}
    <button (click)="store.siguiente()" [disabled]="!store.haySiguiente()">›</button>`
})
export class ListaPedidosPage { store = inject(PedidosStore); }
Si no te acuerdas de linkedSignal: en filtrar() haz this.estatus.set(e); this.pagina.set(1);. Funciona igual y es honesto. Menciona que linkedSignal existe para eso.

E6 · Padre / hijo con input, output y OnPush 8 min

Enunciado típico: "Una tarjeta de producto que muestra datos y avisa al padre cuando lo agregan al carrito. El padre lleva el total."

@Component({
  selector: 'app-producto-card',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [CurrencyPipe],
  template: `
    <h4>{{ producto().nombre }}</h4>
    <p>{{ producto().precio | currency:'MXN' }}</p>
    <button (click)="agregar.emit(producto())" [disabled]="agotado()">Agregar</button>`
})
export class ProductoCard {
  producto = input.required<Producto & { existencia: number }>();
  agregar = output<Producto>();
  agotado = computed(() => this.producto().existencia === 0);
}

// padre
@for (p of productos(); track p.sku) { <app-producto-card [producto]="p" (agregar)="carrito.agregar($event)" /> }

Si te ponen una restricción rara

RestricciónRespuesta
"Sin HttpClient"Abstracción (abstract class o InjectionToken) + implementación en memoria con of(datos).pipe(delay(300))
"Sin RxJS"Signals + effect con setTimeout para el debounce y un contador de petición para descartar respuestas viejas
"Sin librerías de UI"HTML nativo semántico + CSS con flex/grid. No te pierdas en estilos: funcionalidad primero
"Versión vieja de Angular"@Input()/@Output(), *ngIf/*ngFor + trackBy, BehaviorSubject en vez de signal, takeUntil(destroy$)

Testing

No está en la lista, pero en consultoras casi siempre preguntan "¿haces pruebas unitarias?". Tu experiencia real: Karma + Jasmine en el backoffice, Jest lo conoces, SonarQube para cobertura.

"En el backoffice las pruebas eran con Karma y Jasmine, y SonarQube medía cobertura en el pipeline de Azure DevOps. La última que escribí fue del servicio que guarda un pedido: probé las tres ramas — éxito, respuesta de negocio con error, y excepción — con jasmine.createSpyObj para aislar la capa HTTP, el loading y el manejo de errores. Lo importante no era el porcentaje sino verificar que en el caso de error se registrara el log y no se siguiera el flujo."
// Componente con signals
describe('CarritoComponent', () => {
  it('calcula el total y habilita cobrar', () => {
    const f = TestBed.createComponent(CarritoComponent);
    f.componentInstance.agregar({ sku: 'A', precio: 10, cantidad: 3 });
    f.detectChanges();
    expect(f.componentInstance.total()).toBe(30);
    expect(f.nativeElement.querySelector('button').disabled).toBeFalse();
  });
});

// Servicio HTTP
beforeEach(() => TestBed.configureTestingModule({
  providers: [provideHttpClient(), provideHttpClientTesting()]
}));
it('lista pedidos con params', () => {
  const api = TestBed.inject(PedidosApi), http = TestBed.inject(HttpTestingController);
  api.listar({ page: 2, size: 20 }).subscribe(r => expect(r.total).toBe(1));
  const req = http.expectOne(r => r.url.endsWith('/pedidos') && r.params.get('page') === '2');
  req.flush({ items: [{ id: 1 }], total: 1 });
  http.verify();
});

// Buscador con debounce
it('no busca antes de 300 ms', fakeAsync(() => {
  comp.q.setValue('para'); tick(299); expect(api.buscar).not.toHaveBeenCalled();
  tick(1); expect(api.buscar).toHaveBeenCalledOnceWith('para');
}));
"¿Karma o Jest?"

Precisión, que aquí es fácil equivocarse: el proyecto Karma (el runner, fuera de Angular) está deprecado desde 2023, pero Angular lo sigue soportando oficialmente para ng test. En Angular 20, Vitest y Jest venían como opciones experimentales; desde Angular 21, Vitest es el default de los proyectos nuevos. Jest y Vitest son más rápidos (corren sin navegador real, sobre jsdom); Karma corre en un navegador real. La API de pruebas de Angular (TestBed) es la misma en los tres.

"¿Qué pruebas en un componente?"

Comportamiento visible, no detalles internos: dado un input, qué se pinta; dado un clic, qué output se emite o qué método del servicio se llama. Los servicios se mockean. Para componentes de Material, component harnesses (MatButtonHarness) en vez de selectores CSS frágiles.

Java 20 · Quarkus — deseable

No lo usas y no tienes que fingirlo. El objetivo es que si preguntan no te quedes en blanco, y que se note que un backend Java no te asusta porque vienes de .NET, que es casi el mismo mundo.

"Java no lo he usado en producción; mi backend es .NET Core, APIs REST desplegadas en AWS Lambda. Los conceptos se trasladan casi uno a uno: Quarkus es para Java lo que un Web API minimalista es para .NET — inyección de dependencias, endpoints anotados, configuración por archivo, arranque rápido para contenedores y serverless. Si el proyecto lo necesita, puedo leer y consumir esos endpoints desde el primer día y ponerme al corriente en el backend."

Tabla de traducción .NET → Java/Quarkus

.NET (lo que sabes)Java / Quarkus
ASP.NET Core Web APIQuarkus REST (antes RESTEasy Reactive), estándar Jakarta REST
[ApiController] + [HttpGet("{id}")]@Path("/pedidos") + @GET @Path("/{id}")
Inyección por constructor, AddScopedCDI: @Inject, @ApplicationScoped, @RequestScoped
Entity Framework / DapperHibernate ORM con Panache
appsettings.jsonapplication.properties
NuGet · dotnet CLIMaven/Gradle · quarkus CLI (quarkus dev = hot reload)
record de C#record de Java (desde 16)
async/await, TaskVirtual threads (preview en 20, estables en 21) · Mutiny Uni/Multi en Quarkus
Native AOTGraalVM native image — la razón de ser de Quarkus: arranque en milisegundos, poca memoria
@Path("/pedidos")
@Produces(MediaType.APPLICATION_JSON)
public class PedidoResource {
  @Inject PedidoService service;

  @GET @Path("/{id}")
  public Response obtener(@PathParam("id") long id) {
    return service.buscar(id)
        .map(p -> Response.ok(p).build())
        .orElse(Response.status(404).build());
  }
}
public record PedidoDto(long id, String folio, BigDecimal total) {}

Dato de la reclutadora: van a trabajar con Java 20

El anuncio dice Java 21, pero te dijeron 20. Conviene saber la diferencia, porque de ahí sale una buena pregunta:

Java 20Java 21
Tipo de versiónNo LTS. Salió en marzo 2023; su soporte público terminó en septiembre 2023LTS (soporte largo). Salió en septiembre 2023
Virtual threadsPreview (hay que activar --enable-preview)Estables
Pattern matching en switch y record patternsPreviewEstables
Records, var, text blocks, sealed classesEstables en las dos (vienen de versiones anteriores)

Qué haces con esto: nada de corregir a nadie. Primero confirma que sea Java y no Angular 20 (es fácil que se crucen los números en una llamada). Si es Java 20, haz esta pregunta en la entrevista técnica, que suena a alguien que sabe de ciclo de vida de versiones:

"Me comentaron que el backend está en Java 20. ¿Tienen planeado pasar a 21, que es la LTS? Lo pregunto porque cambia qué funciones pueden usar sin --enable-preview, como los virtual threads."

A ti, como front, esto te cambia poco: consumes su API REST por contrato (OpenAPI) igual en 20 que en 21.

Java moderno en tres frases

Lo que le importa al front de ese backend: el contrato. Pide el OpenAPI (Quarkus lo expone en /q/openapi y Swagger UI en /q/swagger-ui) y genera los tipos TypeScript desde ahí. Eso es algo concreto que aportas desde el primer día.

Guion hablado y STAR

"Cuéntame de ti" (60–90 s)

"Soy desarrollador front con más de cinco años de experiencia, especializado en Angular. Los últimos cuatro años y medio —de enero de 2022 a agosto de 2026— trabajé en una consultora, asignado a una cadena farmacéutica con 86 sucursales: primero en el punto de venta, que usan más de 500 terminales, y después en el backoffice. En el punto de venta hice las actualizaciones de Angular 17 a 19, y en el backoffice lideré la migración de los módulos a Angular 20 con microfrontends y Native Federation, con una librería compartida entre módulos: fui el mayor contribuidor de ese repositorio. Día a día trabajo con Signals, RxJS, Reactive Forms, interceptores HTTP y OnPush — que es justo lo que pide esta vacante. También hago backend en .NET, así que entiendo bien el contrato con las APIs. Busco un proyecto donde el foco sea construir componentes reutilizables y una arquitectura de front que escale, que es lo que más disfruto."

Preguntas de la reclutadora

PreguntaRespuesta
¿Pretensiones salariales?Da un rango, no un número, y en bruto mensual. Semi Sr Angular en consultora grande CDMX anda en 35–48k brutos (estimación de mercado, no dato de Capgemini). Di: "Estoy buscando entre 40 y 45 mil brutos mensuales, dependiendo del esquema de prestaciones." Si te mudas a CDMX, sube el piso. Luego pregunta: "¿Cuál es el rango que tienen para la posición?"
¿Qué es "PL y PLS"?Prestaciones de ley (aguinaldo, vacaciones, IMSS) y superiores a la ley (vales de despensa, SGMM = seguro de gastos médicos mayores, FA = fondo de ahorro). Pregunta montos: % del fondo de ahorro, tope de vales, suma asegurada del SGMM
¿Nómina o esquema mixto? ¿Directo con Capgemini?Pregúntalo tú. La reclutadora es externa: confirma si la contratación es directa con Capgemini o por un tercero, y si es por tiempo indeterminado
¿Modalidad?La que decidiste en la card de Resumen. Sostenla
¿Escolaridad?"Pasante de Ingeniería en Tecnologías de la Información y Comunicaciones, Tecnológico de Tlalnepantla. La titulación está en trámite. ¿El cliente pide título o basta carta de pasante?" Nunca "licenciatura concluida" si no tienes el documento: en consultoras lo validan
¿Inglés?"Técnico: leo documentación y escribo tickets, commits y correos sin problema. La conversación fluida la estoy trabajando."
¿Disponibilidad?"Inmediata."
¿Por qué dejaste tu último trabajo?"Terminó la asignación con el cliente. Cerré la migración que lideraba y ahora busco un proyecto de front donde pueda aportar eso mismo." Corto, sin quejas

Historias STAR (una por imprescindible)

Arquitectura modular / componentes reutilizables → la migración a microfrontends

S: backoffice de 16+ áreas en una sola app; un cambio en un módulo obligaba a construir y desplegar todo. T: me tocó liderar la migración. A: separé cada área en un remoto con Native Federation, un shell que los carga por manifiesto, y saqué lo común (buscador de productos, loading, manejo de errores) a una librería compartida para no duplicarlo. R: cada módulo se construye y despliega solo; un feature nuevo ya no toca a los demás. Fui el mayor contribuidor del repositorio: unos dos tercios de los commits (815 de 1,251), incluidos tres cuartas partes del shell y dos tercios de la librería común. Los módulos que más trabajé: pedido manual — el primero que se integró —, login, home, recepción electrónica y monitor de pedidos.

Cómo decir las cifras: "unos dos tercios de los commits" suena mejor que "815". Si repreguntan, das el número. No inventes tiempos de build: no hay registro. Si te lo preguntan: "No lo medí formalmente; lo que cambió fue que un cambio en pedidos ya no obligaba a recompilar y redesplegar los otros quince módulos."
Manejo de errores → el GlobalErrorHandler (lo creaste tú, ene 2024)

S: más de 500 terminales en 86 sucursales; cuando algo fallaba en el navegador de un cajero, soporte solo recibía "no me deja cobrar". T: que cada error llegara con lo necesario para reproducirlo. A: un ErrorHandler global que manda cada excepción no controlada a un servicio de log con sucursal, caja, IP, versión desplegada, URL, y método y línea sacados del stack; ChunkLoadError (módulo lazy que ya no existe tras un deploy) registrado aparte, y los errores de red de status 0 filtrados. R: se diagnostica desde el log sin llamar a la sucursal, y se distingue un bug real de un problema de despliegue.

Nota: los interceptores HTTP del POS (auth, timeout, logging, contexto) los escribieron compañeros; tú los mantuviste. Si preguntan, di "trabajé con ellos", no "los hice".

Componente reutilizable → la directiva anti doble clic (oct 2024)

S: doble clic en "Cobrar", "Siguiente" o "Cancelar pedido" mandaba la operación dos veces. T: resolverlo en todos los botones sin reescribirlos. A: directiva appPreventDoubleClick: Subject + throttleTime configurable, output throttledClick, desuscripción en ngOnDestroy; la metí en los botones compartidos sw-button-payment y sw-button-footer. R: hoy está en 23 botones de pago, envío y cancelación. Lo que aprendí: el tiempo fijo no cubre un servidor lento; hoy usaría exhaustMap atado a la petición.

Mantener Angular al día → las actualizaciones del POS (ago–sep 2025)

S: el punto de venta estaba en Angular 17. T: actualizarlo sin detener la operación de las sucursales. A: lo hice por pasos, una versión mayor a la vez: 17→18 en agosto y 18→19.2 en septiembre — este último tocó 204 archivos, con los schematics de ng update y corrigiendo a mano lo que no migraron solos. R: el POS quedó en 19.2 sin interrumpir la venta. Por qué importa para esta vacante: piden v20+, y tú sabes llevar una app de producción de una versión a la siguiente.

"Nunca salto dos versiones mayores de golpe: ng update va de una en una, porque los schematics de migración están escritos para el salto inmediato."
Signals → estado en inventarios

S: la pantalla de inventario/recepción tenía reglas que dependían de varios campos (si se puede agregar un lote). A: estado en signal y reglas en computed (puedeAgregarLote), inject() e input()/output(). R: sin suscripciones que limpiar, la regla vive en un solo lugar y la plantilla solo lee.

RxJS → el buscador de productos compartido

S: el buscador se usaba en varios módulos y pegaba al servidor en cada tecla. A: Subject + debounceTime + mínimo de 3 caracteres + takeUntil para liberar al destruir; paginación por hash al hacer scroll en el autocompletado. R: menos peticiones, sin fugas, y resultados grandes sin cargar todo de golpe.

Error propio (siempre la preguntan)

El spinner que bloqueaba la caja (2023). S: en el punto de venta, al buscar un producto o agregarlo al carrito, si el servicio fallaba el spinner de carga se quedaba en pantalla y el cajero no podía seguir: tenía que recargar y perdía la venta en curso. T: encontrar por qué. A: el patrón que usábamos — y que yo también escribía — era mostrar el spinner antes de la petición y ocultarlo solo en el next de la suscripción: pensábamos en el camino feliz. Agregué el manejo en el caso de error en la búsqueda de productos y en el componente del carrito. R y lección: desde entonces el cierre de un estado de carga va donde corre en cualquier caso — hoy, finalize —, y cuando reviso código busco justo eso: ¿qué pasa con la pantalla si esto falla?

"Mi error fue diseñar para el camino feliz. El spinner se ocultaba solo cuando la petición salía bien; si fallaba, la caja se quedaba bloqueada. Lo corregí, y desde entonces todo estado de carga lo cierro en finalize, que corre en éxito, error y cancelación."

Estructura que cumple: lo reconoces sin culpar a nadie, lo corregiste, y cambiaste tu forma de trabajar.

Tus preguntas al final (elige 3)

Simulacro — 20 preguntas en voz alta

Graba tu voz o pídele a alguien que te las lea. Cada una en menos de 60 segundos, con el molde. Si titubeas, vuelve a la pestaña y repite.

  1. ¿Cómo estructuras un proyecto Angular grande? (Core/Shared/Feature)
  2. ¿Qué cambió con los componentes standalone? ¿Siguen existiendo los NgModules?
  3. ¿Qué es un signal? ¿Diferencia entre signal, computed y effect?
  4. ¿Cuándo NO usarías effect?
  5. ¿Signals reemplaza a RxJS? ¿Cómo los conectas?
  6. Diferencia entre switchMap, mergeMap, concatMap y exhaustMap, con un caso de cada uno.
  7. ¿Cómo evitas fugas de memoria con observables?
  8. ¿forkJoin vs combineLatest?
  9. ¿Reactive Forms vs template-driven?
  10. ¿Cómo haces un validador personalizado? ¿Y uno que compare dos campos? ¿Y uno asíncrono?
  11. ¿Qué es ControlValueAccessor y para qué sirve?
  12. ¿Qué hace reutilizable a un componente?
  13. ¿Cómo mantienes la consistencia visual entre componentes?
  14. ¿Qué es un interceptor? Dame tres usos.
  15. ¿Cómo harías el refresh token?
  16. ¿Cómo funciona OnPush? ¿Cuándo se actualiza un componente OnPush?
  17. ¿Para qué sirve trackBy / track?
  18. ¿Lazy loading vs @defer?
  19. Una pantalla va lenta: ¿qué haces, en orden?
  20. ¿Has usado Java o Quarkus?

Checklist del día

Cheat-sheet — lo que repasas 10 minutos antes

Arquitectura

  • Core = una vez (interceptores, guards, sesión, shell) → app.config.ts
  • Shared = UI sin negocio (componentes, pipes, directivas, validadores)
  • Feature = área de negocio lazy (loadChildren + providers en ruta)
  • Feature → Shared/Core ✅ · Feature → Feature ❌

Signals

  • signal estado · computed derivado · effect hacia fuera
  • linkedSignal se resetea con su fuente
  • input() · output() · model() · viewChild()
  • update inmutable, nunca push
  • toSignal / toObservable

RxJS

  • leer → switchMap · en orden → concatMap
  • no repetir → exhaustMap · paralelo → mergeMap
  • forkJoin todos completan · combineLatest cada cambio
  • catchError dentro del switchMap
  • takeUntilDestroyed() al final del pipe

Reactive Forms

  • NonNullableFormBuilder · tipados
  • Validador: null = válido
  • Cruzado → en el grupo · async → updateOn: 'blur'
  • formArrayName + [formGroupName]="i"
  • markAllAsTouched() · getRawValue()

Reutilizables

  • API: input/output, sin servicios de negocio
  • <ng-content select> · ngTemplateOutlet
  • CVA: writeValue · registerOnChange · registerOnTouched · setDisabledState
  • Tokens CSS, no ::ng-deep

HTTP y rendimiento

  • HttpInterceptorFn + withInterceptors([...])
  • req.clone({ setHeaders }) · loading con contador + finalize
  • OnPush: nueva referencia · evento · async/signal · markForCheck
  • @for (...; track p.id) · loadComponent · @defer (on viewport)

Las tres reglas

Sin "creo". Siempre con contexto (86 sucursales, 500 terminales, migración a Angular 20). Término exacto.