Inyección de dependencias e inversión de control

Por: Artiko
angularbuenas-practicasinyeccion-dependenciassolidarquitecturatestingtypescript

Inyección de dependencias e inversión de control

La inyección de dependencias (DI) es el motor silencioso de Angular. La usás cada vez que escribís inject(HttpClient), pero rara vez la pensás como lo que realmente es: la implementación que Angular te da del principio de inversión de dependencias (DIP, la “D” de SOLID). Cuando un componente pide inject(ReportService) y no le importa qué clase concreta llega, ni cómo se construyó, ni quién la creó, estás desacoplando el módulo de alto nivel de sus detalles. Ese desacople es lo que hace testeable, intercambiable y mantenible a una app grande.

Este capítulo no te enseña qué es un servicio: eso ya lo sabés. Te enseña a usar el sistema de DI con criterio de diseño: cuándo inyectar una abstracción en vez de una clase concreta, cómo modelar configuración con tokens, dónde ubicar cada proveedor en la jerarquía de inyectores, y qué antipatrones convierten la DI de Angular en un simple new disfrazado.

DI como aplicación del principio de inversión de dependencias

El DIP dice dos cosas:

  1. Los módulos de alto nivel no deben depender de módulos de bajo nivel; ambos deben depender de abstracciones.
  2. Las abstracciones no deben depender de los detalles; los detalles deben depender de las abstracciones.

Traducido a Angular: un componente (alto nivel, orquesta la UI) no debería depender de FirebaseReportRepository (bajo nivel, detalle de infraestructura), sino de un token o interfaz ReportRepository. Quién decide qué implementación concreta satisface ese contrato es el inyector, configurado en el borde de la aplicación (o del feature). El componente queda ciego a la implementación, y esa ceguera es una virtud: podés cambiar Firebase por REST, o por un mock en un test, sin tocar una línea del componente.

flowchart LR
  subgraph AltoNivel["Módulo de alto nivel"]
    C["ReportComponent"]
  end
  subgraph Abstraccion["Abstracción"]
    T["ReportRepository<br/>(InjectionToken)"]
  end
  subgraph Detalles["Detalles / infraestructura"]
    F["HttpReportRepository"]
    M["FakeReportRepository"]
  end
  C -->|depende de| T
  F -.->|implementa| T
  M -.->|implementa| T
  P["Inyector<br/>(provider)"] -->|resuelve a| F
  style T fill:#1e40af,color:#fff
  style P fill:#065f46,color:#fff

El componente apunta a la abstracción; las implementaciones apuntan también a la abstracción; el inyector es el único que conoce el mapeo token → clase concreta. Invertiste la dirección de la dependencia.

inject() vs constructor injection

Angular soporta dos formas de pedir dependencias. La histórica, por constructor:

// Antes (constructor injection): válido pero con fricciones
@Component({ selector: 'app-report' })
export class ReportComponent {
  constructor(
    private http: HttpClient,
    private logger: LoggerService,
    private route: ActivatedRoute,
  ) {}
}

Y la actual, con la función inject():

// Después (inject): la forma recomendada en Angular moderno
@Component({ selector: 'app-report' })
export class ReportComponent {
  private readonly http = inject(HttpClient);
  private readonly logger = inject(LoggerService);
  private readonly route = inject(ActivatedRoute);
}

No es un cambio cosmético. inject() desbloquea patrones que el constructor no permite:

// Herencia sin re-declarar dependencias del padre
export abstract class BaseListComponent<T> {
  protected readonly http = inject(HttpClient);
  protected readonly destroyRef = inject(DestroyRef);
  // ... lógica común de paginación, orden, etc.
}

@Component({ selector: 'app-users' })
export class UsersComponent extends BaseListComponent<User> {
  // Sin constructor. Sin super(). Solo lo propio.
  private readonly userMapper = inject(UserMapper);
}

Un patrón de composición reutilizable que solo inject() habilita:

// Función libre que encapsula una preocupación transversal
export function injectPageParams() {
  const route = inject(ActivatedRoute);
  return {
    page: toSignal(route.queryParamMap.pipe(map(p => Number(p.get('page') ?? 1)))),
  };
}

@Component({ /* ... */ })
export class ProductsComponent {
  protected readonly params = injectPageParams(); // reutilizable en cualquier componente
}

Regla práctica: usá inject() por defecto. El constructor injection sigue siendo válido y es imprescindible cuando necesitás recibir dependencias desde código que no está en injection context, pero para el 95% del código de app, inject() es más flexible y más limpio.

El injection context: qué es y por qué importa

inject() no se puede llamar en cualquier lado. Solo funciona dentro de un injection context: un entorno de ejecución donde Angular sabe cuál es el inyector actual. Estás en injection context en:

Llamar inject() fuera de ese contexto (por ejemplo, dentro de un callback asíncrono, un setTimeout, o un handler de evento del DOM) lanza NG0203. Este es el error de DI más común, y la causa casi siempre es la misma: intentar inyectar tarde, cuando el contexto ya se perdió.

// Antipatrón: inject() fuera del injection context → NG0203
@Component({ /* ... */ })
export class BrokenComponent {
  ngOnInit() {
    setTimeout(() => {
      const http = inject(HttpClient); // ❌ ya no hay contexto acá
    }, 1000);
  }
}

// Correcto: inyectá en el inicializador de campo (sí hay contexto)
@Component({ /* ... */ })
export class FixedComponent {
  private readonly http = inject(HttpClient); // ✅
  ngOnInit() {
    setTimeout(() => this.http.get('/data').subscribe(), 1000);
  }
}

Si de verdad necesitás inyectar fuera de contexto (poco frecuente), capturá el EnvironmentInjector antes y usá runInInjectionContext:

private readonly injector = inject(EnvironmentInjector);

someLateCallback() {
  runInInjectionContext(this.injector, () => {
    const service = inject(LateService); // ✅ contexto restaurado
  });
}

inject() en funciones: guards, interceptores y resolvers

Acá es donde inject() brilla y el constructor no puede competir. Las APIs funcionales del router y de HttpClient se ejecutan dentro de un injection context, así que podés inyectar directamente sin clases ni @Injectable.

// Guard funcional
export const authGuard: CanActivateFn = (route, state) => {
  const auth = inject(AuthService);
  const router = inject(Router);
  return auth.isLoggedIn() ? true : router.createUrlTree(['/login']);
};

// Interceptor funcional
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(AuthTokenStore).current();
  const authReq = token
    ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
    : req;
  return next(authReq);
};

// Resolver funcional
export const reportResolver: ResolveFn<Report> = (route) => {
  const repo = inject(ReportRepository);
  return repo.byId(route.paramMap.get('id')!);
};

Se registran con las funciones de provisión modernas:

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(withInterceptors([authInterceptor, loggingInterceptor])),
  ],
};

Comparado con las clases HTTP_INTERCEPTORS y CanActivate de la era NgModule, esto elimina el boilerplate, es tree-shakeable y hace la dependencia explícita en el punto de uso.

Servicios: @Injectable y el nuevo @Service

El patrón estable y universal para declarar un servicio singleton es @Injectable({ providedIn: 'root' }). providedIn: 'root' registra el servicio en el inyector raíz y, crucialmente, lo hace tree-shakeable: si nadie lo inyecta, no entra en el bundle.

@Injectable({ providedIn: 'root' })
export class ReportService {
  private readonly http = inject(HttpClient);
}

Angular 22 introduce el decorador @Service() como atajo ergonómico de @Injectable({ providedIn: 'root' }): singleton de raíz por defecto, sin config. Nudge hacia inject() (no soporta constructor injection).

@Service()
export class ReportService {
  private readonly http = inject(HttpClient);
}

// Para un servicio que NO querés auto-provisto en root (lo proveés vos a nivel de componente):
@Service({ autoProvided: false })
export class ReportUiState { }

Ten en cuenta el alcance de @Service: expone una única opción factory y no soporta los recipes avanzados (useClass, useExisting, useFactory con deps, useValue, multi). Para abstracciones intercambiables, tokens y providers configurables (el corazón de este capítulo) seguís necesitando @Injectable + el objeto provider o InjectionToken. Usá @Service para servicios concretos simples; @Injectable/tokens para todo lo que quieras invertir.

InjectionToken y los provider recipes

Una interfaz de TypeScript desaparece en runtime: no podés inyectar inject(ReportRepository) si ReportRepository es solo una interface. Para inyectar abstracciones, necesitás un valor que exista en runtime y sirva de clave: un InjectionToken.

// La abstracción (contrato) vive en una interface...
export interface ReportRepository {
  byId(id: string): Observable<Report>;
  save(report: Report): Observable<void>;
}

// ...y el token es la clave de runtime para inyectarla
export const REPORT_REPOSITORY = new InjectionToken<ReportRepository>('ReportRepository');

Los tokens también modelan configuración, con una factory por defecto que los hace tree-shakeable:

export interface AppConfig {
  apiUrl: string;
  pageSize: number;
}

export const APP_CONFIG = new InjectionToken<AppConfig>('AppConfig', {
  providedIn: 'root',
  factory: () => ({ apiUrl: '/api', pageSize: 20 }),
});

Los cinco recipes de provisión

Un provider mapea un token a cómo obtener su valor. Estos son los cinco recipes, cada uno con su caso de uso:

export const appConfig: ApplicationConfig = {
  providers: [
    // useClass: "cuando pidan este token, instanciá esta clase"
    { provide: REPORT_REPOSITORY, useClass: HttpReportRepository },

    // useValue: un valor estático ya construido (config, constantes, mocks)
    { provide: APP_CONFIG, useValue: { apiUrl: 'https://prod.api', pageSize: 50 } },

    // useFactory: construcción con lógica; deps se inyectan como argumentos
    {
      provide: LoggerService,
      useFactory: (config: AppConfig) => new LoggerService(config.apiUrl),
      deps: [APP_CONFIG],
    },

    // useExisting: alias; dos tokens comparten LA MISMA instancia
    { provide: OLD_LOGGER, useExisting: LoggerService },

    // multi: varios providers contribuyen a un array bajo un mismo token
    { provide: VALIDATORS, useClass: EmailValidator, multi: true },
    { provide: VALIDATORS, useClass: PhoneValidator, multi: true },
  ],
};

El patrón provideX

Las librerías modernas ya no exponen tokens y providers sueltos: exponen funciones de provisión que encapsulan varios providers y opciones. Es el patrón que ves en provideRouter, provideHttpClient, provideAnimationsAsync. Escribí los tuyos igual:

export function provideReports(options: { readonly: boolean }): EnvironmentProviders {
  return makeEnvironmentProviders([
    { provide: REPORT_REPOSITORY, useClass: HttpReportRepository },
    { provide: REPORT_CONFIG, useValue: options },
    options.readonly
      ? { provide: REPORT_WRITER, useClass: NoopReportWriter }
      : { provide: REPORT_WRITER, useClass: HttpReportWriter },
  ]);
}

// Uso: una sola línea, con las opciones tipadas y validadas
export const appConfig: ApplicationConfig = {
  providers: [provideReports({ readonly: false })],
};

makeEnvironmentProviders devuelve EnvironmentProviders, un tipo que Angular solo permite en inyectores de entorno (config de app, rutas), no en providers de componente. Eso previene por tipos el error de registrar providers de infraestructura en el lugar equivocado.

La jerarquía de inyectores

Angular no tiene un inyector, tiene un árbol de inyectores con dos jerarquías paralelas:

Cuando pedís una dependencia, Angular resuelve de abajo hacia arriba: primero busca en el ElementInjector del componente, sube por los ElementInjectors de los componentes padre, y si no la encuentra, salta a la jerarquía de EnvironmentInjectors hasta la raíz. Si llega al NullInjector sin encontrarla, lanza NG0201 (a menos que hayas marcado la inyección como opcional).

flowchart TD
  Null["NullInjector<br/>(lanza NG0201)"]
  Platform["PlatformInjector"]
  Root["Root EnvironmentInjector<br/>providedIn: 'root'"]
  RouteInj["Route Injector (lazy)<br/>providers de la ruta"]
  ParentEl["ElementInjector: ParentComponent<br/>providers: [...]"]
  ChildEl["ElementInjector: ChildComponent<br/>providers: [...]"]

  Null --> Platform --> Root --> RouteInj
  RouteInj -.->|fallback si no está en Element| ChildEl
  ParentEl --> ChildEl

  Q["inject(Service) desde ChildComponent"] -.->|1. busca acá| ChildEl
  ChildEl -->|2. no está, sube| ParentEl
  ParentEl -->|3. no está, salta a entorno| RouteInj
  RouteInj -->|4. sube| Root

  style Root fill:#065f46,color:#fff
  style ChildEl fill:#1e40af,color:#fff
  style Null fill:#7f1d1d,color:#fff

providedIn: 'root' vs providers de componente

La ubicación del provider decide cuántas instancias hay y quién las comparte:

// Servicio de estado efímero, aislado por instancia de componente
@Injectable()  // ← SIN providedIn: se provee a nivel de componente
export class WizardState {
  readonly step = signal(1);
  next() { this.step.update(s => s + 1); }
}

@Component({
  selector: 'app-wizard',
  providers: [WizardState],  // ← una instancia por cada <app-wizard>
  template: `...`,
})
export class WizardComponent {
  protected readonly state = inject(WizardState);
}

¿Cuándo un servicio a nivel de componente?

Proveé a nivel de componente cuando querés aislar estado por subárbol o múltiples instancias independientes:

Regla: estado global y servicios sin estado → root. Estado efímero por instancia y sobrescrituras localizadas → providers de componente.

Inversión de control real: una interfaz, muchas implementaciones

Acá cerramos el círculo. El valor de todo lo anterior no es “inyectar cosas”, es poder proveer implementaciones distintas del mismo contrato según el contexto, sin tocar a los consumidores.

export interface AnalyticsTracker {
  track(event: string, payload?: Record<string, unknown>): void;
}
export const ANALYTICS = new InjectionToken<AnalyticsTracker>('Analytics');

// Implementación de producción
@Injectable()
export class SegmentAnalytics implements AnalyticsTracker {
  private readonly http = inject(HttpClient);
  track(event: string, payload?: Record<string, unknown>) {
    this.http.post('/analytics', { event, payload }).subscribe();
  }
}

// Implementación no-op para desarrollo / SSR / tests
@Injectable()
export class NoopAnalytics implements AnalyticsTracker {
  track() { /* silencio */ }
}

Producción vs desarrollo con una factory que decide en runtime:

export const appConfig: ApplicationConfig = {
  providers: [
    {
      provide: ANALYTICS,
      useClass: environment.production ? SegmentAnalytics : NoopAnalytics,
    },
  ],
};

Feature flags: la misma técnica selecciona implementaciones según un flag remoto, sin if esparcidos por los componentes.

{
  provide: CHECKOUT_FLOW,
  useFactory: () => inject(FeatureFlags).isOn('new-checkout')
    ? inject(NewCheckoutFlow)
    : inject(LegacyCheckoutFlow),
}

Testing: en el TestBed, sobrescribís el token con un doble. El componente ni se entera. Esta es la razón principal por la que inyectás abstracciones y no clases concretas — lo desarrollamos en profundidad en Testing.

TestBed.configureTestingModule({
  providers: [{ provide: ANALYTICS, useClass: NoopAnalytics }],
});

El componente que hace inject(ANALYTICS) corre idéntico en prod, en dev, con el flag on/off y en el test. Cambiaste el comportamiento sin cambiar el consumidor: eso es inversión de control.

Antipatrones

new Service() a mano

// Antipatrón: instanciar a mano evita la DI por completo
export class ReportComponent {
  private readonly service = new ReportService(new HttpClient(/* ??? */));
  // No podés testear, no podés intercambiar, y HttpClient ni se construye así.
}

Instanciar con new te saca de la DI: perdés la resolución de dependencias transitivas, el ciclo de vida gestionado, la intercambiabilidad y la testeabilidad. Si algo tiene dependencias, se inyecta.

Estado global mutable en singletons

Un servicio root con estado mutable compartido es un singleton global disfrazado. Si dos partes no relacionadas de la app escriben el mismo signal en un servicio root, acoplaste features que deberían ser independientes y creaste una fuente de bugs por orden de ejecución. Si el estado es efímero o por-feature, proveé el servicio a nivel de componente/ruta, no en root.

Acoplarse a la clase concreta en vez de al token

// Antipatrón: el componente depende de la implementación concreta
private readonly repo = inject(HttpReportRepository);

Inyectar HttpReportRepository en vez de REPORT_REPOSITORY clava la implementación en el consumidor. No podés sustituirla en tests ni por feature flag sin editar el componente. Inyectá el token, proveé la clase en el borde.

Sobre-proveer (over-providing)

Poner un servicio en providers de un componente “por las dudas” cuando debería ser un singleton root crea una instancia nueva por componente. Si ese servicio guardaba caché o estado que se esperaba compartido, cada componente tiene su copia y el estado se fragmenta silenciosamente. Peor con multi: un provider duplicado en dos niveles agrega la implementación dos veces al array. Proveé en el nivel más alto que dé el scope correcto, no más abajo.

Abusar de useFactory para lógica que es un servicio

Una useFactory con veinte líneas de lógica es un servicio mal ubicado. La factory debe seleccionar o ensamblar, no contener la lógica de negocio. Si la factory crece, extraé un servicio y hacé que la factory solo lo elija.

Diagrama: decisión de dónde proveer

flowchart TD
  Start["Necesito proveer un servicio"] --> Q1{"¿Estado compartido<br/>por toda la app?"}
  Q1 -->|Sí, sin estado<br/>o estado global legítimo| Root["providedIn: 'root'<br/>@Injectable / @Service()<br/>Singleton, tree-shakeable"]
  Q1 -->|No| Q2{"¿Estado efímero<br/>por instancia de<br/>componente?"}
  Q2 -->|Sí| Comp["providers: [Service] en @Component<br/>Instancia por componente,<br/>ciclo de vida atado a la vista"]
  Q2 -->|No| Q3{"¿Scope de un<br/>feature / ruta lazy?"}
  Q3 -->|Sí| Route["providers en la ruta<br/>provideX() + EnvironmentProviders"]
  Q3 -->|No| Root
  Q1 --> Q4{"¿Consumidor debe<br/>ignorar la implementación?"}
  Q4 -->|Sí| Token["Inyectá un InjectionToken,<br/>proveé la clase con useClass/useFactory"]
  style Root fill:#065f46,color:#fff
  style Comp fill:#1e40af,color:#fff
  style Token fill:#7c2d12,color:#fff

Checklist

Con las dependencias bien invertidas y los servicios en su lugar, el siguiente paso es el servicio que casi toda app inyecta primero: el acceso a datos. En HTTP, datos y complejidad vemos HttpClient, interceptores funcionales, caching, httpResource, deduplicación de requests y cómo la complejidad algorítmica en el cliente se cuela por el data layer.