Intermediate

Angular: Services and Dependency Injection

Custom services, reactive/async services and Angular’s providers and injectors.

Application demo: Joe’s Robot Shop (robot parts e-commerce)


Table of Contents

  1. Course Overview
  2. Creating Custom Angular Services
  3. Reactive and Asynchronous Services
  4. Understanding Angular Dependency Injection
  5. Angular Service Providers
  6. Angular Service Injectors
  7. Architecture Diagrams
  8. Reference Tables
  9. Best Practices and Summary

1. Course Overview

This course covers the fundamentals and advanced topics related to Angular services and the Dependency Injection (DI) system. The main themes:

  • Creating and using Angular services
  • Managing reactivity with RxJS and Signals
  • Asynchronous services (HTTP)
  • Angular DI architecture
  • Providers and Injectors: impact on service instantiation

Prerequisites: Solid understanding of Angular fundamentals (components, modules or standalone, basic routing).


2. Creating Custom Angular Services

Why Are Services Necessary?

An Angular service is simply a TypeScript class used to execute actions or store data shared between multiple parts of the application.

Problem without a service: a component takes on too many responsibilities.

// ❌ Bad practice: business logic inside the component
@Component({ ... })
export class CatalogComponent implements OnInit {
  products: Product[] = [];

  ngOnInit() {
    // The component knows the API URL, handles JSON conversion, etc.
    fetch('/api/products')
      .then(res => res.json())
      .then(data => this.products = data);
  }

  addToCart(product: Product) {
    // The component also manages the cart...
    this.cart.push(product);
  }
}

Solution with a service: each class has a single responsibility.

// ✅ Good practice: delegation to services
@Component({ ... })
export class CatalogComponent {
  constructor(
    private productsService: ProductsService,
    private cartService: CartService
  ) {}

  ngOnInit() {
    this.productsService.getProducts().subscribe(data => this.products = data);
  }

  addToCart(product: Product) {
    this.cartService.add(product);
  }
}

The Single Responsibility Principle (SRP)

Single Responsibility Principle: every class should have only one reason to change.

ComponentIts responsibility
CatalogComponentDisplay products, manage user interactions
ProductsServiceRetrieve product data from the API
CartServiceManage cart state

Creating an Angular Service

The Angular CLI generates the skeleton: ng generate service catalog/products

// src/app/catalog/products.service.ts
import { Injectable } from '@angular/core';
import { Product } from './product.model';
import { productsArray } from './products-data';

@Injectable({ providedIn: 'root' })
export class ProductsService {
  getProducts(): Product[] {
    return productsArray;
  }
}

The @Injectable decorator tells Angular that this class can be injected as a dependency. providedIn: 'root' means the service is available throughout the application (singleton at the root level).


Injecting and Using a Service

Injection via the constructor (TypeScript shorthand syntax):

@Component({
  selector: 'bot-catalog',
  templateUrl: './catalog.component.html',
})
export class CatalogComponent {
  products: Product[];

  // Angular automatically injects an instance of ProductsService
  constructor(private productsService: ProductsService) {
    this.products = this.productsService.getProducts();
  }
}

The private productsService: ProductsService syntax in the constructor is equivalent to:

private productsService: ProductsService;
constructor(productsService: ProductsService) {
  this.productsService = productsService;
}

Injection via the inject() function (Angular 14+, modern approach):

import { inject } from '@angular/core';

@Component({ ... })
export class CatalogComponent {
  private productsService = inject(ProductsService);
  private cartService = inject(CartService);
}

Inter-Component Communication with Services

Services enable communication between components that are not in a direct parent-child relationship (no @Input/@Output).

Example: CatalogComponent, SearchComponent, and CartComponent share the same CartService.

// src/app/core/cart.service.ts (initial simple version)
import { Injectable } from '@angular/core';
import { Product } from '@shared/product.model';

@Injectable({ providedIn: 'root' })
export class CartService {
  cart: Product[] = [];

  add(product: Product) {
    this.cart.push(product);
  }

  remove(product: Product) {
    this.cart = this.cart.filter(p => p !== product);
  }

  get cartTotal() {
    return this.cart.reduce((prev, next) => {
      let discount = next.discount && next.discount > 0 ? 1 - next.discount : 1;
      return prev + next.price * discount;
    }, 0);
  }
}

3. Reactive and Asynchronous Services

The Need for Reactivity

Reactivity refers to an application’s ability to automatically reflect data changes in the user interface.

A non-reactive service returns a one-time value:

// ❌ Non-reactive: if products change, the template does not update
getProducts(): Product[] {
  return this.products;
}

Reactivity with RxJS (Subject / BehaviorSubject)

import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { Product } from './product.model';

@Injectable({ providedIn: 'root' })
export class ProductsService {
  constructor(private httpClient: HttpClient) {}

  // Returns an Observable: the component can subscribe and react to changes
  getProducts(): Observable<Product[]> {
    return this.httpClient.get<Product[]>('/api/products');
  }
}

HTTP service pattern flow:

HttpClient.get('/api/products')
    │
    ▼
Observable<Product[]>
    │
    ▼ (subscribe)
CatalogComponent.products
    │
    ▼
Template (*ngFor)

Reactivity with Angular Signals

Since Angular 16, Signals offer a simpler alternative to RxJS for internal service reactivity.

import { Injectable, computed, signal } from '@angular/core';
import { Product } from '@shared/product.model';

@Injectable({ providedIn: 'root' })
export class CartService {
  // Writable signal (private)
  private cartItems = signal<Product[]>([]);

  // Read-only signal exposed to the outside
  get cart() {
    return this.cartItems.asReadonly();
  }

  add(product: Product) {
    this.cartItems.update((oldCart) => [...oldCart, product]);
  }

  remove(product: Product) {
    this.cartItems.update((oldCart) => oldCart.filter(p => p !== product));
  }

  // Computed signal: automatically recalculated when cartItems changes
  get cartTotal() {
    return computed(() => this.cartItems().reduce((prev, next) => {
      let discount = next.discount && next.discount > 0 ? 1 - next.discount : 1;
      return prev + next.price * discount;
    }, 0));
  }
}

Key differences: Signal vs Observable

CharacteristicSignalObservable (RxJS)
Syntaxsignal(value), .set(), .update()Subject, .next(), .subscribe()
Reading the valuemySignal() (function call).subscribe(val => ...)
Computedcomputed(() => ...)pipe(map(...))
Ideal scenarioSynchronous local stateAsynchronous streams, HTTP
Change detectionGranular and efficientZone.js or async pipe

Service Encapsulation

Problem: exposing mutable properties directly gives too much power to consumers.

// ❌ cart is public and mutable
cart: Product[] = [];

Solution: read-only signal via asReadonly()

// ✅ Proper encapsulation
private cartItems = signal<Product[]>([]);

get cart() {
  return this.cartItems.asReadonly(); // No .set() or .update() available
}

HTTP Calls from a Service

Angular proxy configuration (development) in angular.json:

{
  "serve": {
    "options": {
      "proxyConfig": "proxy.conf.json"
    }
  }
}

proxy.conf.json:

{
  "/api": {
    "target": "http://localhost:3000",
    "secure": false
  }
}

Service with HttpClient:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Product } from './product.model';

@Injectable({ providedIn: 'root' })
export class ProductsService {
  constructor(private httpClient: HttpClient) {}

  getProducts(): Observable<Product[]> {
    return this.httpClient.get<Product[]>('/api/products');
  }
}

Registration in an NgModule:

// app.module.ts
import { HttpClientModule } from '@angular/common/http';

@NgModule({
  imports: [..., HttpClientModule],
})
export class AppModule {}

Standalone application (Angular 15+):

// app.config.ts
import { provideHttpClient } from '@angular/common/http';

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

The AsyncPipe

The async pipe allows subscribing directly to an Observable in the template, without manually managing the subscription.

// catalog.component.ts
@Component({ ... })
export class CatalogComponent {
  // Observable directly exposed to the template
  products: Observable<Product[]> = this.productsService.getProducts();

  constructor(private productsService: ProductsService) {}
}
<!-- catalog.component.html -->
<!-- async pipe handles subscribe/unsubscribe automatically -->
<div *ngFor="let product of products | async">
  {{ product.name }}
</div>

Advantages of the async pipe:

  • No memory leaks (automatic unsubscribe on component destruction)
  • Less code in the component
  • Compatible with OnPush change detection

4. Understanding Angular Dependency Injection

What is DI?

Definition: Dependency Injection is a design pattern that prescribes passing to a class the dependencies it needs, rather than letting it create them itself.

Without DI (manual instantiation):

class ProductComponent {
  private repo: ProductRepository;

  constructor() {
    // The component creates its own dependency
    this.repo = new ProductRepository(new DbConnectionService());
  }
}

With DI (dependency injection):

class ProductComponent {
  // Angular injects the dependency from the outside
  constructor(private repo: ProductRepository) {}
}

Benefits of DI

BenefitDescription
TestabilityMocks can be injected in tests without modifying production code
FlexibilityOne implementation can be substituted for another
ModularityClasses are decoupled from each other
ScalabilityEasy to add new dependencies
ReusabilityServices can be shared across modules
Reduced complexityCreation logic is centralized

Testability example:

// In production
const repo = new ProductRepository(new RealDbConnection());

// In tests
const repo = new ProductRepository(new MockDbConnection());

DI in Angular

Angular implements DI via a system of injectors (IoC containers). The four conceptual steps:

  1. Mark the class as injectable: @Injectable()
  2. Register a provider: providers: [...] or providedIn
  3. Declare the dependency: constructor parameter or inject()
  4. Resolution by the injector: Angular provides the instance

In practice, steps 2–4 are often automatic thanks to providedIn: 'root'.

// Step 1: Mark as injectable
@Injectable({ providedIn: 'root' }) // Step 2: Also defines the provider
export class ProductsService {
  getProducts(): Observable<Product[]> { ... }
}

// Step 3: Declare the dependency (Angular resolves step 4 automatically)
@Component({ ... })
export class CatalogComponent {
  constructor(private productsService: ProductsService) {} // Injection!
}

5. Angular Service Providers

What is a Provider?

A provider is a set of instructions that tells Angular how to create a service instance. It associates a token (identifier) with a creation recipe.

// Declarative provider in an NgModule
providers: [
  {
    provide: CartService,     // Token = the class itself
    useClass: CartService,    // Recipe = instantiate this class
  }
]

// Equivalent shorthand form
providers: [CartService]

When you write @Injectable({ providedIn: 'root' }), Angular automatically generates this provider in the background.


How Providers Affect Instantiation

Fundamental rule: an injector creates one instance per token (singleton within that injector). Two providers with different tokens = two distinct instances.

// Module A: provider with CartService token
providers: [CartService]
// → Instance A in Module A's injector

// Module B: provider with a custom token
providers: [{ provide: CART_SERVICE_TOKEN, useClass: CartService }]
// → Instance B in Module B's injector (different token = new instance)

useClass, useFactory, useValue

useClass: provides an instance of a class.

providers: [
  { provide: CartService, useClass: CartService }
  // Or: use a different class
  { provide: CartService, useClass: MockCartService } // Useful in tests
]

useFactory: uses a factory function to create the instance. Useful when creation requires business logic.

providers: [
  {
    provide: CartService,
    useFactory: () => {
      // Custom logic before instantiating the service
      const options = { persistenceType: 'local', persistenceKey: 'shopping-cart' };
      return new CartService(options);
    }
  }
]

useValue: provides a direct value (object, primitive, function). Often used with InjectionToken.

export const CART_OPTIONS_TOKEN = new InjectionToken<CartOptions>('CART_OPTIONS');

providers: [
  {
    provide: CART_OPTIONS_TOKEN,
    useValue: { persistenceType: 'local', persistenceKey: 'shopping-cart' }
  }
]

InjectionToken and Interfaces

TypeScript interfaces are erased at compilation time (they don’t exist in JavaScript). They therefore cannot be used directly as DI tokens. The solution is InjectionToken.

// shared/products-service.interface.ts
import { InjectionToken } from '@angular/core';
import { Observable } from 'rxjs';
import { Product } from './product.model';

export interface IProductsService {
  getProducts(): Observable<Product[]>;
}

// Token usable at runtime
export const IProductsServiceToken = new InjectionToken<IProductsService>('IProductsService');
// squad.module.ts: use the token to provide a different implementation
providers: [
  {
    provide: IProductsServiceToken,
    useClass: EngineersService // Implements IProductsService
  }
]
// squad-catalog.component.ts: injection via the token
constructor(
  @Inject(IProductsServiceToken) private productsService: IProductsService
) {}

Configurable Service with InjectionToken

Complete example of a configurable CartService with InjectionToken:

// cart.service.ts
import { Inject, Injectable, InjectionToken, computed, signal } from '@angular/core';
import { Product } from '@shared/product.model';

export type CartOptions = {
  persistenceType: string; // 'local' | 'none' | 'remote'
  persistenceKey: string;
};

export const CART_OPTIONS_TOKEN = new InjectionToken<CartOptions>('CART_OPTIONS');

@Injectable({ providedIn: 'root' })
export class CartService {
  private cartItems = signal<Product[]>([]);

  constructor(@Inject(CART_OPTIONS_TOKEN) private cartOptions: CartOptions) {
    // Hydration from localStorage if configured
    if (this.cartOptions?.persistenceType === 'local') {
      const cartString = localStorage.getItem(this.cartOptions.persistenceKey);
      const cart: Product[] = cartString ? JSON.parse(cartString) as Product[] : [];
      this.cartItems.set(cart);
    }
  }

  get cart() {
    return this.cartItems.asReadonly();
  }

  add(product: Product) {
    this.cartItems.update((oldCart) => [...oldCart, product]);
    this.storeCart();
  }

  remove(product: Product) {
    this.cartItems.update((oldCart) => oldCart.filter(p => p !== product));
    this.storeCart();
  }

  private storeCart() {
    if (this.cartOptions?.persistenceType === 'local') {
      localStorage.setItem(this.cartOptions.persistenceKey, JSON.stringify(this.cartItems()));
    }
  }

  get cartTotal() {
    return computed(() => this.cartItems().reduce((prev, next) => {
      let discount = next.discount && next.discount > 0 ? 1 - next.discount : 1;
      return prev + next.price * discount;
    }, 0));
  }
}

In app.module.ts or app.config.ts:

// app.module.ts
@NgModule({
  providers: [
    {
      provide: CART_OPTIONS_TOKEN,
      useValue: { persistenceType: 'local', persistenceKey: 'shopping-cart' }
    }
  ]
})
export class AppModule {}

// app.config.ts (standalone)
export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(),
    {
      provide: CART_OPTIONS_TOKEN,
      useValue: { persistenceType: 'none', persistenceKey: 'shopping-cart' }
    }
  ]
};

The deps Property

deps allows declaring additional dependencies for a useFactory provider.

providers: [
  {
    provide: CART_OPTIONS_TOKEN,
    useValue: { persistenceType: 'local', persistenceKey: 'shopping-cart' }
  },
  {
    provide: CartService,
    useFactory: (cartOptions: CartOptions) => new CartService(cartOptions),
    deps: [CART_OPTIONS_TOKEN] // Angular resolves this dependency and passes it to the factory
  }
]

The multi Property

multi: true allows having multiple providers for the same token, which will be returned as an array.

providers: [
  { provide: CART_OPTIONS_TOKEN, useValue: { persistenceType: 'local', persistenceKey: 'cart' }, multi: true },
  { provide: CART_OPTIONS_TOKEN, useValue: { persistenceType: 'none',  persistenceKey: 'cart' }, multi: true },
]
// Injection will return an array: CartOptions[]

Common use case: NG_VALUE_ACCESSOR for ControlValueAccessor in Angular Forms.


6. Angular Service Injectors

What is an Injector?

An injector is an instance of Angular’s Injector class. It:

  1. Receives a list of providers (instructions)
  2. Resolves requested dependencies
  3. Caches created instances (singleton within that injector)
  4. Delegates to parent injectors if the dependency is not found locally

The Injector Hierarchy

Angular has multiple injectors organized in a tree:

Environment Injectors (application level)

InjectorCreated byUsage
NullInjectorBootstrapThrows an error if no dependency found (@Optional() returns null)
PlatformInjectorplatformBrowserDynamic()Services shared across multiple Angular apps on the same page
RootInjectorBootstrapGlobal services (providedIn: 'root')
Module/Route InjectorModule or lazy-loaded routeServices specific to a module or route

Element Injectors (component level)

Each component can have its own injector via its providers property.

Resolution Algorithm

Component requests a dependency
        │
        ▼
Element Injector of the component
        │ (if not found)
        ▼
Element Injector of the parent component
        │ (if not found, travels up the hierarchy...)
        ▼
Root Injector (Environment)
        │ (if not found)
        ▼
Platform Injector
        │ (if not found)
        ▼
NullInjector → Error: "No provider found for X"

Configuring Injectors

providedIn: 'root' — global singleton in the root injector:

@Injectable({ providedIn: 'root' })
export class ProductsService {}

providedIn: 'platform' — shared across multiple Angular apps (rare):

@Injectable({ providedIn: 'platform' })
export class SharedPlatformService {}

Module NgModule — scope limited to the module (and its children):

@NgModule({
  providers: [SomeModuleService]
})
export class FeatureModule {}

Lazy-loaded route — new injector created for this route scope:

// app-routing.module.ts
{
  path: 'squad',
  loadChildren: () => import('./squad/squad.module').then(m => m.SquadModule),
}

// squad.module.ts
@NgModule({
  providers: [
    CartService, // Isolated new instance for the squad module
    { provide: CART_OPTIONS_TOKEN, useValue: { persistenceType: 'none', persistenceKey: 'squad' } }
  ]
})
export class SquadModule {}

Standalone routes — providers at the route level:

// app.routes.ts
{
  path: 'squad',
  providers: [
    CartService,
    { provide: CART_OPTIONS_TOKEN, useValue: { persistenceType: 'none', persistenceKey: 'squad' } }
  ],
  loadChildren: () => import('./squad/squad.routes').then(r => r.SQUAD_ROUTES)
}

Component-Level Providers

Providers can be defined directly in a component’s metadata:

@Component({
  selector: 'bot-squad-catalog',
  providers: [
    // This component uses EngineersService, not ProductsService
    {
      provide: IProductsServiceToken,
      useClass: EngineersService
    }
  ]
})
export class SquadCatalogComponent {
  constructor(
    @Inject(IProductsServiceToken) private productsService: IProductsService
  ) {}
}

Important: component-level providers create a new instance of the service for each instance of the component (and its direct children).


Resolution Modifiers: @Self, @SkipSelf, @Host, @Optional

These decorators modify the dependency resolution algorithm:

DecoratorBehavior
@Self()The dependency must be provided by the component’s own injector (not from parents). Error if absent.
@SkipSelf()Ignores the component’s own injector and starts resolution at the parent
@Host()Limits the search up to the component’s host element (view boundary)
@Optional()If the dependency is not found, injects null instead of throwing an error
@Component({
  providers: [
    { provide: IProductsServiceToken, useClass: EngineersService }
  ]
})
export class SquadCatalogComponent {
  constructor(
    // @Self(): MUST be in THIS component's providers
    @Self() @Inject(IProductsServiceToken) private service: IProductsService,
    // @Optional(): null if not found, no error
    @Optional() private optionalService?: SomeOptionalService
  ) {}
}

Standalone Applications: Route Injectors and Global Providers

Providers at the route level (equivalent to lazy-loaded module)

// app.routes.ts
export const routes: Routes = [
  {
    path: 'squad',
    providers: [
      // Providers specific to this route and its children
      {
        provide: CART_OPTIONS_TOKEN,
        useValue: { persistenceType: 'none', persistenceKey: 'squad' }
      },
      {
        provide: IProductsServiceToken,
        useClass: EngineersService
      }
    ],
    loadComponent: () => import('./squad/squad-catalog.component')
      .then(c => c.SquadCatalogComponent)
  }
];

Global providers in app.config.ts

// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { CART_OPTIONS_TOKEN } from './catalog/cart.service';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(),
    {
      provide: CART_OPTIONS_TOKEN,
      useValue: { persistenceType: 'none', persistenceKey: 'cart' }
    }
  ]
};

Note: providedIn: 'root' remains the best practice for the majority of services in standalone apps. app.config.ts is used for value providers (config, options) and Angular module providers (provideRouter, provideHttpClient).


7. Architecture Diagrams

Angular Injector Tree

graph TD
    NULL["NullInjector\n(Throws error or null if @Optional)"]
    PLATFORM["PlatformInjector\n(providedIn: 'platform')"]
    ROOT["RootInjector\n(providedIn: 'root')"]
    MODULE["Module/Route Injector\n(providers: [] in lazy NgModule / route)"]
    COMP["Element Injector\n(providers: [] in @Component)"]
    CHILD["Child Element Injector"]

    NULL --> PLATFORM --> ROOT --> MODULE --> COMP --> CHILD

    style NULL fill:#f66,color:#fff
    style ROOT fill:#4a9,color:#fff
    style MODULE fill:#59b,color:#fff
    style COMP fill:#a74,color:#fff

DI Hierarchy: Resolution Flow

flowchart LR
    C[CatalogComponent\nrequests CartService]
    EI[Element Injector\nof the component]
    RI[Root Injector]
    INST[CartService Instance\ncached in Root]

    C -->|1. Looks in| EI
    EI -->|2. Not found, moves up| RI
    RI -->|3. Found! Returns| INST
    INST -->|4. Injected into| C

HTTP Service Pattern

sequenceDiagram
    participant C as CatalogComponent
    participant S as ProductsService
    participant H as HttpClient
    participant API as API Server

    C->>S: getProducts()
    S->>H: get('/api/products')
    H->>API: GET /api/products
    API-->>H: JSON Response
    H-->>S: Observable<Product[]>
    S-->>C: Observable<Product[]>
    C->>C: subscribe() or async pipe
    C->>C: Displays products

Multiple Instances with Module Injectors

graph TB
    subgraph RootInjector["Root Injector"]
        CS1["CartService Instance #1\n(providedIn: 'root')"]
    end

    subgraph SquadModule["Squad Module Injector"]
        CS2["CartService Instance #2\n(providers: [CartService] in SquadModule)"]
    end

    CatalogComp["CatalogComponent"] -->|"injects"| CS1
    CartComp["CartComponent"] -->|"injects"| CS1
    SearchComp["SearchComponent"] -->|"injects"| CS1
    SquadComp["SquadCatalogComponent"] -->|"injects"| CS2
    SquadCart["SquadRosterComponent"] -->|"injects"| CS2

8. Reference Tables

providedIn Scopes

ValueScopeUse case
'root'Entire applicationGlobal services (singleton) — recommended
'platform'Platform (multiple apps on the page)Services shared between micro-frontends
'any'Module/lazy routeDeprecated
Module classSpecific moduleTree-shakable in a specific module

Provider Types

PropertyDescriptionExample
useClassInstantiates a class{ provide: CartService, useClass: CartService }
useExistingAlias to an existing token{ provide: Logger, useExisting: ConsoleLogger }
useFactoryFactory function{ provide: S, useFactory: () => new S() }
useValueDirect value{ provide: API_URL, useValue: 'https://...' }

DI Decorators

DecoratorImportDescription
@Injectable()@angular/coreMarks a class as injectable + DI metadata
@Inject(token)@angular/coreSpecifies a non-class token for injection
@Self()@angular/coreResolution only in the local injector
@SkipSelf()@angular/coreIgnores local injector, starts at parent
@Host()@angular/coreLimits resolution up to the host element
@Optional()@angular/coreReturns null if not found (no error)

Comparison: Module-based vs Standalone

AspectModule-basedStandalone
Global providerAppModule.providers[]app.config.tsApplicationConfig.providers[]
Lazy route providerNgModule.providers[]Route.providers[] in app.routes.ts
BootstrapbootstrapModule(AppModule)bootstrapApplication(AppComponent, appConfig)
HTTPHttpClientModule importprovideHttpClient()
RouterRouterModule.forRoot(routes)provideRouter(routes)
providedIn: 'root'✅ Identical✅ Identical

9. Best Practices and Summary

Key Takeaways

  1. providedIn: 'root' is the standard way to create singleton services. Do not declare in a module’s providers: [] unless there is a specific need.

  2. Single responsibility: one service = one responsibility. Do not mix data retrieval, business logic, and state management in the same service.

  3. Encapsulate state with asReadonly() Signals or Observables. Never directly expose mutable arrays.

  4. InjectionToken for configuration values (options, URLs, constants) that are not classes.

  5. Multiple instances: use lazy-loaded modules (or routes with providers) to create isolated service instances in specific areas of the application.

  6. @Optional() for optional dependencies — prevents crashes if the provider is not configured.

  7. Interfaces + InjectionToken for abstract dependencies. TypeScript interfaces do not exist at runtime, so they cannot serve as DI tokens.

  8. async pipe in templates rather than .subscribe() in the component — automatic memory management.

Anti-Patterns to Avoid

// ❌ Manual instantiation (bypasses DI)
const service = new ProductsService(new HttpClient(...));

// ❌ Exposing a writable signal publicly
public cartItems = signal<Product[]>([]);

// ❌ State in the component when it should be shared
addToCart(product: Product) {
  this.localCart.push(product); // Invisible to other components
}

// ❌ HTTP retrieval logic in the component
ngOnInit() {
  fetch('/api/products').then(r => r.json()).then(data => this.products = data);
}

Service Creation Checklist

  • @Injectable({ providedIn: 'root' }) or appropriate scope
  • Explicit typing of public methods
  • Internal state exposed via asReadonly() (Signal) or Observable
  • Public methods to modify state (add(), remove(), etc.)
  • InjectionToken if configuration options are needed
  • Error handling on HTTP calls (catchError)

Search Terms

angular · services · dependency · injection · frontend · development · service · injectors · providers · injector · level · reactivity · resolution · global · hierarchy · http · injectiontoken · property · provider · route · standalone

Interested in this course?

Contact us to book it or get a custom training plan for your team.