Skip to the solution
MediumFrontend8 min read

inject() vs constructor injection — when should you use each?

864 views

The problem

Question presented to candidate: "We are starting a new Angular 22 project and the team is split: half write constructor injection, half write inject(). Which should we standardise on, and are there cases where the other one is still the right call?"

What a strong answer should cover:

  • A clear default: inject() for new code. It is what the Angular docs, the CLI-generated @Service() decorator and functional APIs (guards, interceptors, resolvers, initializers) are built around.
  • Concrete reasons: works outside classes (reusable injectX() helpers), no super(...) forwarding in subclasses, typed optional results, no dependence on compiler type metadata, and required by @Service() (constructor parameters are compile error NG2028).
  • Where constructor injection still fits: existing codebases with a consistent style, classes you want to new directly in plain unit tests, and libraries supporting older Angular versions.
  • The cost of inject(): it only runs in an injection context, so tests must create the class through an injector (TestBed, Injector.create or runInInjectionContext).
  • Migration path: ng generate @angular/core:inject converts constructor injection automatically; do it per folder and keep one style per class.

Clarifying questions expected:

  • "Is this greenfield, or are we converting an existing codebase?"
  • "How are services unit tested today: new with fakes, or TestBed?"

Code / implementation expected: Optional — a reusable inject helper, and a test showing how each style is instantiated with a fake.

  • di
  • inject
  • angular-14

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

8 min read · 11 sections

Read the code

AngularA reusable inject helper that only inject() makes possible
import { Component, DestroyRef, Injectable, inject, signal } from "@angular/core";

@Injectable({ providedIn: "root" })
export class Analytics {
  readonly events = signal<string[]>([]);
  track(e: string) { this.events.update((list) => [...list, e]); }
}

// A reusable "inject function": only possible with inject(), because it runs
// inside whichever component calls it and can reach that component's DestroyRef.
export function injectScreenTracking(screen: string) {
  const analytics = inject(Analytics);
  analytics.track(`enter ${screen}`);
  inject(DestroyRef).onDestroy(() => analytics.track(`leave ${screen}`));
  return (action: string) => analytics.track(`${screen}: ${action}`);
}

@Component({
  selector: "app-cart",
  template: `<button (click)="track('checkout clicked')">checkout</button>`,
})
export class Cart {
  track = injectScreenTracking("cart"); // one line, no constructor, no ngOnDestroy
}

@Component({
  selector: "app-profile",
  template: `<span>profile</span>`,
})
export class Profile {
  track = injectScreenTracking("profile");
}

@Component({
  selector: "app-root",
  imports: [Cart, Profile],
  template: `
    @if (onCart()) { <app-cart /> } @else { <app-profile /> }
    <button (click)="onCart.set(!onCart())">switch screen</button>
    <ol>
      @for (e of analytics.events(); track $index) { <li>{{ e }}</li> }
    </ol>
  `,
})
export class AppComponent {
  analytics = inject(Analytics);
  onCart = signal(true);
}
AngularTesting seams: new with a fake vs an injector around inject()
import { Component, Injectable, Injector, inject, runInInjectionContext } from "@angular/core";

@Injectable({ providedIn: "root" })
export class PriceApi {
  price(sku: string) { return 100; }
}

// Constructor style: a plain class you can "new" with any fake.
@Injectable({ providedIn: "root" })
export class CtorCheckout {
  constructor(private api: PriceApi) {}
  total(skus: string[]) { return skus.reduce((sum, s) => sum + this.api.price(s), 0); }
}

// inject() style: needs an injector around it when it is created.
@Injectable({ providedIn: "root" })
export class InjectCheckout {
  private api = inject(PriceApi);
  total(skus: string[]) { return skus.reduce((sum, s) => sum + this.api.price(s), 0); }
}

const fakeApi: PriceApi = { price: () => 1 };

@Component({ selector: "app-root", template: `<pre>{{ report }}</pre>` })
export class AppComponent {
  report = "";
  constructor() {
    const lines: string[] = [];
    // A unit test runs OUTSIDE any injection context. Simulate that with setTimeout.
    setTimeout(() => {
      lines.push(`new CtorCheckout(fake).total = ${new CtorCheckout(fakeApi).total(["a", "b"])}`);
      try {
        new InjectCheckout();
      } catch (e) {
        lines.push(`new InjectCheckout() -> ${(e as Error).message.slice(0, 6)}`);
      }
      const testInjector = Injector.create({ providers: [{ provide: PriceApi, useValue: fakeApi }, InjectCheckout] });
      lines.push(`via Injector.create   = ${testInjector.get(InjectCheckout).total(["a", "b"])}`);
      const viaContext = runInInjectionContext(testInjector, () => new InjectCheckout());
      lines.push(`via runInInjectionContext = ${viaContext.total(["a", "b"])}`);
      console.log(lines.join("\n"));
    });
  }
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 28 of 121 in Angular

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Angular questions
Up nextMedium

What is the difference between computed() signals and standard method/getter calls in templates?

Continue