Angular Basics #5: Service and Dependency Injection

8 min read

Last time, we looked at Directives and Pipes for broader template expressiveness. This time, let’s turn our attention beyond the template — to keeping all the logic out of the component — Service and dependency injection.

Why putting everything in a component is a bad idea #

Suppose you have a UserListComponent that shows a list of users. Inside, you have logic to fetch data, logic to process it, actions like add and delete, and the template that draws the screen — all of it. It looks fine at first. But what if another page also needs the same user data? You start copy-pasting the same fetch logic into a second component. Then the next screen, and the next… the same code scatters everywhere.

On top of that, when you try to test this component, screen rendering and data fetching are lumped together, so it’s hard to verify either side in isolation.

The fix is simple. Separate “drawing the screen” and “handling data” into different classes. In Angular, what handles the latter is the Service.

What is a Service #

A Service is just a regular TypeScript class. There’s no special syntax. It typically holds business logic (discount calculations, permission checks), data access (calling backend APIs with HttpClient), state shared across components, and common utilities like logging, authentication, and notifications.

The component focuses on “what to show,” and the Service is responsible for “how to handle data and logic.” And the component doesn’t create the Service itself — it asks Angular for one. This request-and-provide mechanism is dependency injection (DI).

Creating a Service #

One CLI line creates one.

ng generate service user
# Or short: ng g s user

This generates src/app/user.service.ts and a test file. The generated file looks like this.

src/app/user.service.ts
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class UserService {
  constructor() {}
}

There are two key parts.

  • @Injectable() decorator: marks this class so Angular’s DI system can handle it. It means “an injectable class.”
  • providedIn: 'root': registers this Service in the app’s root injector. In other words, no matter where you request it in the app, it returns the same instance every time — a singleton.

Now let’s fill in real logic. A simple Service that holds a list of users.

src/app/user.service.ts
import { Injectable, signal } from '@angular/core';

export interface User {
  id: number;
  name: string;
}

@Injectable({
  providedIn: 'root',
})
export class UserService {
  // Expose as readonly so outside code can't modify it directly
  private readonly _users = signal<User[]>([
    { id: 1, name: 'Curtis Kim' },
    { id: 2, name: 'Angular Lee' },
  ]);

  readonly users = this._users.asReadonly();

  addUser(name: string) {
    const id = Date.now();
    this._users.update((list) => [...list, { id, name }]);
  }

  removeUser(id: number) {
    this._users.update((list) => list.filter((u) => u.id !== id));
  }
}

State is managed with a signal and exposed via asReadonly() so outside code can’t replace it directly. Changes happen only through methods like addUser and removeUser. It works as a kind of small store.

Using a Service in a component #

Now let’s receive this Service in a component. In modern Angular, the most recommended way is the inject() function.

src/app/user-list/user-list.component.ts
import { Component, inject } from '@angular/core';
import { UserService } from '../user.service';

@Component({
  selector: 'app-user-list',
  standalone: true,
  template: `
    <h2>User list</h2>
    <ul>
      @for (user of userService.users(); track user.id) {
        <li>
          {{ user.name }}
          <button (click)="userService.removeUser(user.id)">Delete</button>
        </li>
      }
    </ul>

    <input #nameInput placeholder="Name" />
    <button (click)="add(nameInput.value); nameInput.value = ''">Add</button>
  `,
})
export class UserListComponent {
  protected readonly userService = inject(UserService);

  add(name: string) {
    if (name.trim()) {
      this.userService.addUser(name.trim());
    }
  }
}

inject(UserService) is all it takes in one line. Angular looks up (and creates if missing) a UserService instance from the root injector and returns it to us. We just take it and use it.

In the template, we read the signal value with userService.users(). Just remember that signals expose the current value via a function call. When the signal value changes, Angular redraws only that part for you.

Note
The inject() function was introduced in Angular 14, and from v16 it can be used freely in component class field initializers as well. For new projects, prefer inject() by default unless there’s a specific reason. It works the same in non-class places like functional route guards and interceptors, keeping the code consistent.

Constructor injection — the old way #

Before inject() became the standard, constructor injection was the standard for a long time. It still works, and you’ll see it more often in existing codebases.

Constructor injection (legacy)
export class UserListComponent {
  constructor(private readonly userService: UserService) {}
}

Angular sees the type annotation (UserService) and injects an instance for you. The behavior is identical. It does have subtle limitations with inheritance and decorators, and it can’t be used outside of classes (e.g., route guard functions), so for new code, inject() is recommended.

Multiple components sharing the same Service #

A Service registered with providedIn: 'root' is a single instance shared across the entire app. When one component adds data, other components referencing the same Service immediately see the change. Let’s place a small component alongside the UserListComponent above that just shows the user count.

src/app/user-count/user-count.component.ts
import { Component, computed, inject } from '@angular/core';
import { UserService } from '../user.service';

@Component({
  selector: 'app-user-count',
  standalone: true,
  template: `<p>Current users: {{ count() }}</p>`,
})
export class UserCountComponent {
  private readonly userService = inject(UserService);
  protected readonly count = computed(() => this.userService.users().length);
}

When you put <app-user-list> and <app-user-count> together on a page, adding a user from the list immediately bumps the number in the count component. Both components are looking at the same UserService instance, the same signal. Without components communicating directly with each other, state flows through the Service as a single hub.

This is how Angular most naturally handles a role similar to React’s Context or Vue’s store. Without a separate library, a small app gets clean state management with just Service + Signals.

Service lifetime — the providedIn option #

Where you register a Service determines the lifetime and shared scope of its instance. Let’s compare the two most common cases.

1. providedIn: 'root' (the recommended default)

A single instance lives across the entire app. No matter where you inject(UserService), you get the same object. In most cases, this is the right answer.

2. Component-level provider

When you register in a component’s providers array, the component and its child components share a new instance. When the component is destroyed, the Service is destroyed with it.

Component-scoped Service
@Component({
  selector: 'app-editor',
  standalone: true,
  providers: [EditorStateService],
  template: `...`,
})
export class EditorComponent {
  private readonly state = inject(EditorStateService);
}

Useful for “state that’s only meaningful inside this screen.” It fits well with data that should be cleaned up when the page is left, like the temporary state of a single edit form.

The decision criterion is simple. “One per app is enough?”'root'. “A separate instance only needed while this component lives?” → component providers.

The real value of DI — testing #

The biggest stage where DI shines is testing. If a component built its object directly with new UserService(), tests would have to use the real Service that calls real HTTP — slow and unstable. With DI, you can swap in a fake (mock) implementation.

src/app/user-list/user-list.component.spec.ts
import { TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { UserListComponent } from './user-list.component';
import { UserService } from '../user.service';

it('shows users from the Service on screen', () => {
  const fakeUsers = signal([{ id: 1, name: 'Test User' }]);
  const fakeUserService = {
    users: fakeUsers.asReadonly(),
    addUser: jasmine.createSpy('addUser'),
    removeUser: jasmine.createSpy('removeUser'),
  };

  TestBed.configureTestingModule({
    imports: [UserListComponent],
    providers: [{ provide: UserService, useValue: fakeUserService }],
  });

  const fixture = TestBed.createComponent(UserListComponent);
  fixture.detectChanges();
  expect(fixture.nativeElement.textContent).toContain('Test User');
});

With one line — { provide: UserService, useValue: fakeUserService } — when the component requests UserService, it gets the fake we built instead of the real one. Without changing a single character of the component code. It’s possible because the component doesn’t depend on the concrete class itself — it only depends on the fact that something “gets injected.” That’s the real value of dependency injection: free swappability, and the test ergonomics that naturally follow from it.

Tip
If you’ve worked with Spring or NestJS on the backend, this pattern will feel very familiar. Angular’s DI essentially brings the same philosophy to the frontend. It’s also one of the reasons full-stack teams adopt Angular at unusually high rates.

Recap #

This post covered Service and dependency injection. To recap:

  • Service is a plain class that holds business logic, data, and shared state
  • Registering with @Injectable({ providedIn: 'root' }) gives you the same instance anywhere in the app (singleton)
  • In components, receiving via inject(UserService) is the modern Angular recommendation
  • providedIn: 'root' vs component providers — pick by sharing scope
  • Thanks to DI, swapping in a mock for tests becomes easy

Now that you know how to separate components and logic, it’s time to build apps with multiple screens. In the next post, “Angular Basics #6: Routing,” we’ll walk through composing a multi-page app with Angular Router — route definitions, RouterLink, RouterOutlet, plus guards and parameters — step by step.

X