Dependency Injection

Fascinating Dependency Injection

Dependency Injection is a technique that we use every single day as Angular developers. It allows us to reuse things, access native constructs like HttpClient, retrieve data from routing, and much more. However, my general feeling has been that Angular developers often underestimate the capabilities

Fascinating Dependency Injection — Dependency Injection article by Armen Vardanyan on Angular In Depth
Fascinating Dependency Injection — Dependency Injection article by Armen Vardanyan on Angular In Depth
On this page · 7 sections

Dependency Injection is a tool that Angular developers reach for on a daily basis. It enables us to reuse logic, tap into native constructs like HttpClient, pull data from routing, and much more. Even so, I have a persistent impression that the Angular community tends to underappreciate what DI is truly capable of. This article digs into some of the more intriguing and practical features that often fly under the radar. Let's dive in.

What does DI actually mean?

In many cases, grasping this answer is the key to uncovering additional capabilities that this mechanism provides. And just as often, a slight misunderstanding of how it truly operates leaves us with limited options when tackling issues that DI could handle with ease.

One common mental model that trips people up is viewing DI as a kind of "container" from which we "pull" instances to use later. In reality, DI is far from being a container or an abstract map of objects keyed by some identifier. Instead, it is a strictly hierarchical system in which the same
"key" (formally known as "InjectionToken") can yield wildly different results depending on where in the system it is requested.

How does that work? We won't venture extremely deep into the internals (there are far more subtleties than we could cover here), but it's worth noting that DI is intimately tied to the DOM structure of your application. Yes, you read that correctly—the mechanism that hands us service instances is actually linked to our DOM tree.

What does that imply? Well, as your application renders the UI, Angular creates a special object for every HTML element it places in the DOM. This object, called an ElementInjector, is responsible for handling dependency injection within the scope of that element and its descendants.

Now, without diving excessively into the specifics, picture an element injector as an object that holds a reference to its parent injector (the one created for the parent element) and maintains its own "dictionary" of tokens paired with their respective instances. Take a look at this example:

@Directive({
    selector: '[appSome]',
})
export class SomeDirective implements OnInit {
    private readonly elementRef = inject(ElementRef);

    ngOnInit() {
        console.log(this.elementRef.nativeElement);
    }
}

Now, if this directive appears twice in the same template...

<div appSome>
    <span appSome>Text</span>
</div>

You'll see different elements printed to the console, even though the same ElementRef was injected! This happens because each of those elements has its own element injector, and Angular automatically supplies the ElementRef instance for each one. When the directive asks for the token, Angular queries the element injector and gets back distinct objects for each element.

This clearly illustrates how the dependency injection machinery functions. When a dependency is requested, Angular checks the closest element injector first—the one linked to what we call the host element, e.g., the element where the directive sits—and if the token is present, it returns the instance that was previously registered for that token (in our case, ElementRef).

If the token isn't found there, Angular moves up to the parent element injector and continues that pattern until it hits the root injector. Beyond that, it consults the platform injector, which isn't really relevant for this discussion. Eventually, if the dependency still isn't located, Angular climbs to the top of the hierarchy and reaches the NullInjector—a name every Angular developer recognizes from the infamous "NullInjectorError: No provider for {token}". The NullInjector is a unique injector that contains nothing and consistently throws an error whenever any token is requested.

You might notice this resembles how JavaScript's prototype chain works. When you access a property on an object, it checks the object itself, then its prototype, and so on, until it reaches Object.prototype, and then tries its prototype, which is null, triggering an error. That's a striking parallel, and holding onto it can be helpful when reasoning about DI.

[!NOTE] The hierarchical search for DI tokens can be influenced by settings like Host, Optional, and others. We'll examine those later in this piece.

With a clearer picture of how DI lookup works—or at least the basics—let's shift our attention to how dependencies come into existence, or, to use the more precise term, how they are provided.

Declaring providers

At this point, we arrive at the heart of the DI mechanism. Of course, before anything can be injected, it must first be registered with an injector. Some dependencies, such as ElementRef which we saw earlier, are registered automatically by Angular during element creation. Others require explicit configuration on our part.

Knowing the various ways to register a provider allows us to keep our code clean and avoid redundant declarations throughout the application. Let's begin with the most straightforward method, providing a dependency through a class:

export const appConfig: ApplicationConfig = {
    providers: [
        SomeService, 
    ],
};

This shorthand is equivalent to the more verbose form:

export const appConfig: ApplicationConfig = {
    providers: [
        {provide: SomeService, useClass: SomeService},
    ],
};

The syntax is essentially self-documenting; Angular interprets it as instructions to instantiate the given class. This pattern is most commonly seen when a service isn't provided at the application root, which, while less frequent, remains a perfectly legitimate approach.

The next option is to provide a static value rather than a class. This is incredibly handy for distributing configuration data while maintaining type safety. For example, many projects rely on environment files, which swap their contents depending on the build target (e.g., development, staging, production).

A best practice is to define a token, whether a class or other type, that mirrors the structure of the environment data. Consider this environment file:

export const environment = {
    production: true,
    apiUrl: 'https://api.example.com',
};

To create a typed way of accessing this data, we can define a class that represents its shape:

export class ApplicationConfig {
    readonly production: boolean;
    readonly apiUrl: string;
}

We can then use the useValue option to supply an object of that class as the provider:

import { environment } from './environments/environment';

export const appConfig: ApplicationConfig = [
    {provide: ApplicationConfig, useValue: environment},
];

This allows us to inject the environment configuration anywhere without directly importing the environment files throughout the codebase:

@Injectable()
export class SomeService {
    private readonly environment = inject(Environment);
    private readonly http = inject(HttpClient);

    getData() {
        return this.http.get(this.environment.apiUrl + '/data');
    }
}

Moving on, the useExisting option provides a way to alias one provider to another. This is a less common but powerful technique, particularly when you want to restrict access to a third-party library. Such libraries often expose a wide array of methods, some of which might manipulate the DOM or affect performance by adding event listeners, which you may not want your team to invoke casually.

In this scenario, you can define a lean "shell" class that exposes only the specific utility methods you deem safe or necessary. This shell is then provided to your application, even though the underlying implementation is the full-featured third-party service.

// list only the methods we want
type ExposedThirdPartyApi = Pick<ThirdPartyService, 'someMethod' | 'someOtherMethod'>;

export abstract class ShellService implements ExposedThirdPartyApi {
    abstract someMethod(): void;
    abstract someOtherMethod(): void;
}

The registration for this would look like:

export const appConfig: ApplicationConfig = {
    providers: [
        {provide: ShellService, useExisting: ThirdPartyService},
    ],
};

Subsequently, components can inject the shell service to access only the curated set of methods:

@Injectable()
export class SomeService {
    private readonly shellService = inject(ShellService);

    getData() {
        return this.shellService.someMethod();
    }
}

[!NOTE] These provider types such as useValue and useExisting are versatile and can be used wherever an array of providers is expected—like within route definitions or component metadata—not just in the root application configuration.

This keeps the rest of the library's functionality private, a great way to manage code we don't own.

Finally, we come to the most flexible and engaging provider type: useFactory. As the name suggests, this lets you provide a factory function. This function is executed only when the dependency is first requested, and its return value—the constructed dependency—is what gets injected. A classic use case is switching out implementations based on the current environment.

For instance, you might have multiple logging services: one for development that logs to the browser console, and one for production that sends logs to a remote server. A factory function can check the environment and return the appropriate logger:

import { environment } from './environments/environment';

export const appConfig: ApplicationConfig = {
    providers: [
        {
            provide: LoggerService,
            useFactory: () => {
                if (environment.production) {
                    return new ThirdPartyLoggerService();
                }

                return new ConsoleLoggerService();
            },
        },
    ],
};

Then, you can inject the logger anywhere in your application without knowing which implementation you'll get:

@Injectable()
export class SomeService {
    private readonly logger = inject(LoggerService);

    getData() {
        this.logger.log('Some data');
    }
}

[!WARNING] When using this pattern, it's crucial to ensure that all returned implementations share the same interface. Defining an interface and having both services implement it is a good safeguard to enforce this contract.

Now that we've covered the basics, let's explore some more advanced and perhaps surprising applications of the useFactory pattern.

Dynamic dependencies from query parameters

In typical Angular development, we tend to categorize things as either static, like services and providers configured at startup, or dynamic, like component state and routing parameters. Often, we treat these as distinct realms.

However, it's possible to bridge this gap and make our providers dynamic too, even based on something as volatile as a query parameter. Let's consider an application for displaying financial transactions, distinguishing between expenses and incomes. These entities are related but require different service behavior, even though they share the same UI components. It would be ideal to have two specialized services and just one reusable component, with the correct service chosen based on, say, a route query parameter.

Here’s how to set this up. First, define a public API for these services:

export abstract class TransactionService {
    abstract getTransactions(): Observable<Transaction[]>;
    abstract addTransaction(transaction: Transaction): void;
    abstract deleteTransaction(id: number): void;
}

[!NOTE] An abstract class is used here instead of a TypeScript interface because interfaces are erased at compile time and thus cannot serve as an InjectionToken. Abstract classes, however, are preserved in the compiled JavaScript and can be used both as a DI token and, as we do here, as a base class to be implemented.

Next, create the two separate service implementations:

@Injectable()
export class ExpensesService implements TransactionService {
    private readonly http = inject(HttpClient);

    getTransactions() {
        return this.http.get<Transaction[]>('/api/transactions');
    }

    addTransaction(transaction: Transaction) {
        this.http.post('/api/transactions', transaction);
    }

    deleteTransaction(id: number) {
        this.http.delete(`/api/transactions/${id}`);
    }
}
@Injectable()
export class IncomeService implements TransactionService {
    private readonly http = inject(HttpClient);

    getTransactions() {
        return this.http.get<Transaction[]>('/api/transactions/income');
    }

    addTransaction(transaction: Transaction) {
        this.http.post('/api/transactions/income', transaction);
    }

    deleteTransaction(id: number) {
        this.http.delete(`/api/transactions/income/${id}`);
    }
}

So far this is pretty standard. The challenge is to provide the right service to the component based on the query parameter. This is where a factory function becomes invaluable.

@Component({
    providers: [
        {
            provide: TransactionService,
            useFactory: (route: ActivatedRoute) => {
                switch (route.snapshot.queryParamMap.get('type')) {
                    case 'income':
                        return new IncomeService();
                    case 'expense':
                        return new ExpensesService();
                    default:
                        throw new Error('Invalid query parameter');
                }
            },
            deps: [ActivatedRoute],
        }
    ],
})
export class TransactionsComponent {
    private readonly transactionService = inject(TransactionService);

    addTransaction(transaction: Transaction) {
        this.transactionService.addTransaction(transaction);
    }
}

The factory function is associated with the component and runs upon its creation. The component itself only injects the abstract TransactionService. The factory inspects the route to determine the parameter and returns the appropriate concrete service. This provides tremendous flexibility.

Now, let's look at a problem that might seem unrelated to DI at first glance: sharing complex data structures between components.

Sharing form instances elegantly

Consider a large form that you want to break down into smaller, more manageable components. For example, a registration form might have personal details and a nested address section. Creating a separate component for the address fields is a good idea to keep the code organized. However, we need the child to access the parent's form model without making the child component heavy with logic.

Imagine the parent component is responsible for building the entire form:

@Component({...})
export class RegistrationComponent {
    private readonly form = new FormGroup({
        firstName: new FormControl(),
        lastName: new FormControl(),
        email: new FormControl(),
        address: new FormGroup({
            street: new FormControl(),
            city: new FormControl(),
            zipCode: new FormControl(),
        }),
    });

    onSubmit() {
        // submit the form
    }
}

How do we get the this.form.controls.address to the child? The conventional method might be an @Input:

@Component({...})
export class AddressComponent {
    form = input.required<FormGroup>();
}

This works but has downsides. The form in the child isn't strongly typed. To fix that, you'd have to create separate type definitions, which adds maintenance overhead. We can avoid this by using an injection token. This token can represent the entire form, and both the parent and child can inject the same instance.

export function createRegistrationForm(): FormGroup {
    return new FormGroup({
        firstName: new FormControl(),
        lastName: new FormControl(),
        email: new FormControl(),
        address: new FormGroup({
            street: new FormControl(),
            city: new FormControl(),
            zipCode: new FormControl(),
        }),
    });
}
export const AddressForm = new InjectionToken<FormGroup<ReturnType<typeof createRegistrationForm>>>('AddressForm');

Now both components can simply inject the token to access the shared form:

@Component({...})
export class RegistrationComponent {
    private readonly form = inject(AddressForm);

    onSubmit() {
        // submit the form
    }
}
@Component({...})
export class AddressComponent {
    form = inject(AddressForm).controls.address;
}

This is a much cleaner and more flexible approach. There is an alternative, stricter method: if you are absolutely certain that the address form is only ever used as a sub-form within the registration form, you can leverage hierarchical DI. In this case, a component can inject the parent component instance itself to get a direct reference to the form.

@Component({...})
export class AddressComponent {
    form = inject(RegistrationComponent).form.controls.address;
}

However, this approach tightly couples the components, so it should be used with caution and only when you are certain about the component's usage context, as it can be less obvious for other developers reading the AddressComponent.

Our final example tackles the concept of providing configurable defaults for a component.

Making global configuration overridable

Let's say we are building a reusable loading indicator component. We want it to display a "Loading..." message by default, but occasionally we might need a custom message. The immediate solution is an @Input with a default value:

@Component({...})
export class LoadingComponent {
    text = input('Loading...');
}

This covers most needs. But imagine this component is part of a shared library within a monorepo (using a tool like Nx) used by multiple applications. A simple default value becomes too rigid; other apps might need a different default text or a different language.

How do we handle this? We can combine DI with the optional lookup flag to provide a default value that is easily replaced.

export const LoadingText = new InjectionToken<string>('LoadingText');

@Component({...})
export class LoadingComponent {
    text = input<string>(inject(LoadingText, { optional: true }) ?? '');
}

Now, any consumer of the LoadingComponent can override the default text by providing a configuration token:

export const appConfig: ApplicationConfig = {
    providers: [
        {provide: LoadingText, useValue: 'Some other loading text...'}, 
    ],
};

If the application provides a value for this token, it will be used as the default. However, this won't affect cases where the consuming component passes a more specific input directly:

<app-loading text="Yet another loading text...">
    Content
</app-loading>

So we use the optional modifier here. It tells Angular not to error if the token isn't provided, and our component will fall back to the input or its default hardcoded value.

Summary

Angular's Dependency Injection is a deep and, as suggested in the title, fascinating topic. This article has explored some unconventional scenarios where DI provides elegant solutions, but we've only just begun to uncover its potential. It's a powerful and often underappreciated feature. My goal is to help you see DI not just as a way to pass services but as a tool to write more adaptive and cleaner code, which can be a huge efficiency win in complex, large-scale applications.

A brief announcement

Modern Angular.jpeg

You may have noticed this article capitalizes on signal inputs and the inject function. The recent wave of updates in Angular has left many developers uncertain about the recommended practices and migration paths. I have a solution for you: my first book is approaching publication!

It's titled "Modern Angular" and serves as a thorough guide to the core features introduced in versions 14 through 18, including standalone components, signals (of course!), the improved input system, better RxJS interoperability, and more. If this sounds helpful, you can check it out on Manning's website. It's currently in Early Access while in copy-editing, with all 10 chapters already available online. To follow along with progress and promotions for the print edition, connect with me on Twitter or LinkedIn.

P.S. For a more in-depth look at dependency injection, be sure to check out chapter 3 of the book!


Fascinating Dependency Injection — figure 2

Tagged in:

Angular 17, Articles

Last Update: September 20, 2024

AV
Armen Vardanyan

Writes about RxJS, State, Dependency Injection. Active 2019–2026.

All 57 articles →