跳到主要内容

サービスと依存性注入(DI)

依存性注入(DI)はAngularのコアコンセプトであり、React/Next.jsには存在しないAngular特有の設計思想です。

1. サービスとは何か

サービスは、UIと直接関係しないビジネスロジックやデータアクセス処理をカプセル化するクラスです。コンポーネントはUIのレンダリングに専念し、HTTP通信・状態管理・バリデーションなどの処理はサービスに委ねます。

// user.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

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

@Injectable({
providedIn: 'root',
})
export class UserService {
private readonly apiUrl = 'https://api.example.com/users';

constructor(private http: HttpClient) {}

getUsers(): Observable<User[]> {
return this.http.get<User[]>(this.apiUrl);
}

getUserById(id: number): Observable<User> {
return this.http.get<User>(`${this.apiUrl}/${id}`);
}
}

Reactとの比較

Reactではビジネスロジックを Custom Hooks やユーティリティ関数で分離します。

AngularReact / Next.js
@Injectable サービスCustom Hook (useUsers) / ユーティリティ関数
DIコンテナが依存を解決import で直接インポート
シングルトン(デフォルト)モジュールスコープのシングルトン
テスト時は DI で差し替えJest のモック (jest.mock)

2. @Injectable デコレータ

@Injectable() デコレータを付けたクラスが DI システムの対象になります。providedIn: 'root' を指定すると、アプリ全体で共有されるシングルトンとして登録されます。

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

@Injectable({
providedIn: 'root', // アプリ全体のルートインジェクターに登録
})
export class LoggerService {
log(message: string): void {
console.log(`[App] ${message}`);
}
}

Angular CLI でサービスを生成する

ng generate service services/user
# または短縮形
ng g s services/user

生成されるファイル:

src/app/services/
user.service.ts ← サービス本体
user.service.spec.ts ← テストファイル

3. DIの仕組み

Injector の階層構造

Angularは複数の Injector を階層的に持ちます。依存の解決は下位から上位へと検索されます。

コンストラクタインジェクション(従来の方法)

@Component({ selector: 'app-user-list', ... })
export class UserListComponent {
constructor(private userService: UserService) {}
}

inject() 関数(Angular 14以降・推奨)

import { Component, inject, OnInit } from '@angular/core';
import { UserService, User } from './user.service';

@Component({
selector: 'app-user-list',
template: `
@for (user of users; track user.id) {
<p>{{ user.name }}</p>
}
`,
})
export class UserListComponent implements OnInit {
private userService = inject(UserService);
users: User[] = [];

ngOnInit() {
this.userService.getUsers().subscribe((data) => (this.users = data));
}
}

inject() 関数はコンストラクタの外でも利用でき、カスタムフック的な関数(ファクトリ関数)の作成も可能です。

Reactとの比較

// React: カスタムフック
function useUsers() {
const [users, setUsers] = useState<User[]>([]);
useEffect(() => {
fetch('/api/users').then(r => r.json()).then(setUsers);
}, []);
return users;
}

// Angular: inject() 関数
function injectUsers(): Signal<User[]> {
const userService = inject(UserService);
const users = signal<User[]>([]);
userService.getUsers().subscribe(data => users.set(data));
return users.asReadonly();
}

4. サービスのスコープ

サービスのライフサイクルは providedIn オプションや providers 配列で制御します。

providedIn: 'root'(デフォルト・推奨)

アプリ全体で1つのインスタンスを共有するシングルトンです。ツリーシェイキングが有効なため、未使用のサービスはバンドルに含まれません。

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

コンポーネントレベルのプロバイダー

providers 配列にサービスを登録すると、そのコンポーネントおよびその子コンポーネント専用のインスタンスが作成されます。コンポーネントが破棄されると同時にサービスも破棄されます。

@Component({
selector: 'app-checkout',
providers: [CheckoutStateService], // このコンポーネント専用インスタンス
template: `...`,
})
export class CheckoutComponent { }

providedIn: 'platform'

複数の Angular アプリ(マイクロフロントエンドなど)で1つのインスタンスを共有する場合に使います。

@Injectable({ providedIn: 'platform' })
export class SharedAuthService { ... }
スコープインスタンス数用途
'root'(デフォルト)1(シングルトン)ほとんどのサービス
コンポーネント providersコンポーネントごとウィザードの状態など
'platform'プラットフォームで1つマイクロフロントエンド共有

5. InjectionToken

文字列・数値・オブジェクトなど、クラス以外の値を DI で提供する場合は InjectionToken を使います。

// tokens.ts
import { InjectionToken } from '@angular/core';

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

export const APP_CONFIG = new InjectionToken<AppConfig>('app.config');
// app.config.ts (Standalone アプリのルート設定)
import { ApplicationConfig } from '@angular/core';
import { APP_CONFIG } from './tokens';

export const appConfig: ApplicationConfig = {
providers: [
{
provide: APP_CONFIG,
useValue: {
apiUrl: 'https://api.example.com',
maxRetries: 3,
},
},
],
};
// コンポーネントやサービスでの利用
import { inject } from '@angular/core';
import { APP_CONFIG } from './tokens';

@Injectable({ providedIn: 'root' })
export class ApiService {
private config = inject(APP_CONFIG);

getBaseUrl() {
return this.config.apiUrl;
}
}

Reactとの比較

// React: Context + Provider パターン
const AppConfigContext = createContext<AppConfig | null>(null);

export function AppConfigProvider({ children }: { children: ReactNode }) {
return (
<AppConfigContext.Provider value={{ apiUrl: '...', maxRetries: 3 }}>
{children}
</AppConfigContext.Provider>
);
}

export function useAppConfig() {
const ctx = useContext(AppConfigContext);
if (!ctx) throw new Error('AppConfigProvider required');
return ctx;
}

6. 実践パターン

API通信サービスの作成

// product.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { catchError, map } from 'rxjs/operators';

export interface Product {
id: number;
name: string;
price: number;
}

@Injectable({ providedIn: 'root' })
export class ProductService {
private http = inject(HttpClient);
private apiUrl = '/api/products';

getAll(category?: string): Observable<Product[]> {
let params = new HttpParams();
if (category) params = params.set('category', category);
return this.http.get<Product[]>(this.apiUrl, { params });
}

create(product: Omit<Product, 'id'>): Observable<Product> {
return this.http.post<Product>(this.apiUrl, product);
}

update(id: number, changes: Partial<Product>): Observable<Product> {
return this.http.patch<Product>(`${this.apiUrl}/${id}`, changes);
}

delete(id: number): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
}

状態管理サービス(Signals を使用)

// cart.service.ts
import { Injectable, signal, computed } from '@angular/core';

export interface CartItem {
productId: number;
name: string;
price: number;
quantity: number;
}

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

// 読み取り専用の公開シグナル
readonly items = this._items.asReadonly();

readonly total = computed(() =>
this._items().reduce((sum, item) => sum + item.price * item.quantity, 0)
);

readonly itemCount = computed(() =>
this._items().reduce((count, item) => count + item.quantity, 0)
);

addItem(item: CartItem): void {
this._items.update((items) => {
const existing = items.find((i) => i.productId === item.productId);
if (existing) {
return items.map((i) =>
i.productId === item.productId
? { ...i, quantity: i.quantity + item.quantity }
: i
);
}
return [...items, item];
});
}

removeItem(productId: number): void {
this._items.update((items) =>
items.filter((i) => i.productId !== productId)
);
}

clear(): void {
this._items.set([]);
}
}

サービス間の依存関係

// order.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { CartService } from './cart.service';
import { AuthService } from './auth.service';
import { tap } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class OrderService {
private http = inject(HttpClient);
private cartService = inject(CartService);
private authService = inject(AuthService);

placeOrder() {
const userId = this.authService.currentUser()?.id;
const items = this.cartService.items();

return this.http
.post('/api/orders', { userId, items })
.pipe(tap(() => this.cartService.clear())); // 注文後カートを空にする
}
}

テスト時のモック注入

Angular の DI を利用すると、テスト時に本物のサービスをモックに差し替えることが容易です。

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

describe('UserListComponent', () => {
let mockUserService: jasmine.SpyObj<UserService>;

beforeEach(() => {
mockUserService = jasmine.createSpyObj('UserService', ['getUsers']);
mockUserService.getUsers.and.returnValue(
of([{ id: 1, name: 'Alice', email: 'alice@example.com' }])
);

TestBed.configureTestingModule({
imports: [UserListComponent],
providers: [
{ provide: UserService, useValue: mockUserService }, // モックを注入
],
});
});

it('should display users', () => {
const fixture = TestBed.createComponent(UserListComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('p')?.textContent).toContain('Alice');
});
});