Upgrade to Pro — share decks privately, control downloads, hide ads and more …

TypeScriptとAngular Signal で実現する保守性の高いアプリケーション設計...

TypeScriptとAngular Signal で実現する保守性の高いアプリケーション設計 - 3層アーキテクチャによる責務分離の実践(たつかわ) https://2026.tskaigi.org/talks/10

Component 内にすべてのロジックを実装すると、ビジネスロジック、状態管理、API 呼び出しが混在し、コードの可読性とテスタビリティが低下する課題があります。本トークでは、プレゼンテーション層・ユースケース層・データアクセス層の3層アーキテクチャを導入し、各層の責務を明確に分離することで保守性を向上させた事例を紹介します。特に、Writable signals と Computed signals による状態管理のカプセル化、各層の責務範囲の明確化、TypeScript の型システムを活用した型安全な設計など、実務で直面する具体的な課題への実践的なアプローチをコード例と共に紹介します。

Avatar for Nealle

Nealle

May 22, 2026

More Decks by Nealle

Other Decks in Technology

Transcript

  1. 4 氏名 立川 隆一 / Ryuichi Tatsukawa 所属 株式会社ニーリー プロダクト統括本部

    プラットフォーム本部 ArchitectureG 経歴 2016 - 2019(株式会社かなめ) Webアプリケーションエンジニアとしてキャリアをスタート 2019 - 2021(株式会社 Hmcomm) シニアエンジニアとして経験を積む 2021 - 2024(株式会社ビザスク) 一定期間エンジニアとして従事した後、チームリーダーとしてマネジメント業務を担当 2024 - (株式会社ニーリー) テックリードとしてジョイン 1|自己紹介
  2. 2|よくある実装:すべて Component に書く 7 @Component({ /* ... */ }) export

    class UserEditComponent { user: User | null = null; loading = false; errorMessage: string | null = null; readonly userForm = new FormGroup({ /* ... */ }); ngOnInit(): void { this.loading = true; this.http.get<UserApiResponse>(`${API}/fetch_user/?id=${id}`).subscribe({ next: (res) => { this.user = { ...res, phoneNumber: res.phone_number }; // 命名変換 this.userForm.patchValue(this.user); this.loading = false; }, error: (e) => { this.errorMessage = e.message; this.loading = false; }, }); } submit(): void { /* PUT 呼び出し・成功時 navigate・失敗時 errorMessage… */ } }
  3. 3|何が辛いのか 8 8 可読性 再利用性 • ngOnInit に手続き型ロジックが膨張 • HTTP

    通信・状態フラグ・命名変換が同居 • 一覧画面・詳細画面で同じ取得処理を再実装 • 命名変換ロジックが画面ごとに散らばる テスタビリティ • Component を立ち上げないとロジックを試せな い • HttpClient のモック準備が画面ごとに必要 型安全性 • snake_case と camelCase が混ざる • any や as での妥協が増える
  4. 4|解決策:3層に責務を分離する 10 Component(プレゼンテーション層) UI 表示 / フォーム / ユーザー操作の受付 Usecase

    (ユースケース層) 状態管理(Signal)/ ロジック API Service (データアクセス層) HTTP 通信 / 命名変換(snake_case ⇄ camelCase) inject inject 各層は下の層にのみ依存し、Component は API Service を直接呼ばない。
  5. 5|各層の責務サマリー 11 Component Usecase API Service やること やらないこと 状態管理(Signal)/ ロジック

    / API 呼び出し の組み立て テンプレートバインディング / フォームバリ デーション / 操作の Usecase 委譲 HTTP リクエスト / 命名変換 / エラー整形 状態の判断・計算 / API 直接呼び出し UI 表示判断 / HTTP の詳細 / フォーム検証 状態保持 / ロジック
  6. 6|データアクセス層: HTTP と命名変換に専念 12 @Injectable({ providedIn: 'root' }) export class

    UpdateUserAPIService { private readonly http = inject(HttpClient); updateUser(params: UpdateUserParams): Observable<UpdateUserResponse> { const requestData = this.mapToApiRequestFormat(params); // camelCase → snake_case return this.http .put<UpdateUserResponse>(this.apiUrl, requestData) .pipe(catchError(this.handleError)); } private mapToApiRequestFormat(p: UpdateUserParams): UpdateUserRequest { return { id: p.id, name: p.name, email: p.email, phone_number: p.phoneNumber, address: p.address, }; } } • UpdateUserParams(フロント型・camelCase) と UpdateUserRequest(API 型・snake_case)を別 の型として定義 • 命名規則の違いはこの層のみが知っている
  7. 7|ユースケース層とプレゼンテーション層の関係 13 Component(プレゼンテーション層) Usecase (ユースケース層) 操作 fetchUser() / submit() public

    method fetchUser() / updateUser() private writable signals user / loading / errors public computed signals state(Signal<T>) 表示 {{state().user}} Signal 更新 call readonly 依存追跡 • 書き込み経路 :Component → public method → writable signal • 読み出し経路 :writable signal → computed state → Component(読み取り専用)
  8. 8|ユースケース層: Signal による状態管理 14 @Injectable() export class UserEditUsecase { //

    ① writable signals は private(更新は Usecase 内に閉じる) private readonly user = signal<User | null>(null); private readonly loading = signal<boolean>(false); private readonly fetchErrorMessage = signal<string | null>(null); private readonly updateErrorMessage = signal<string | null>(null); // ② state は computed(外部からは読み取り専用) readonly state = computed<UserEditState>(() => ({ user: this.user(), loading: this.loading(), fetchErrorMessage: this.fetchErrorMessage(), updateErrorMessage: this.updateErrorMessage(), })); } Writable signal を private、Computed signal を public にする 2 層構造。 Component は state() から 読むのみ 。状態更新の入口は Usecase のメソッドに統一される。
  9. 9|状態は最小に、派生は computed で計算する 15 // 派生状態は signal を増やすのではなく computed で導出

    private readonly canSubmit = computed<boolean>( () => !this.loading() && this.user() !== null, ); private readonly errorMessage = computed<string | null>( () => this.fetchErrorMessage() ?? this.updateErrorMessage(), ); // state に合成して公開(Component は state().canSubmit で参照) readonly state = computed<UserEditState>(() => ({ /* user, loading, fetch/updateErrorMessage に加え */ canSubmit: this.canSubmit(), errorMessage: this.errorMessage(), })); • 「ボタンが活性か」「エラーが出ているか」は 状態ではなく状態の関数 • 派生を新しい signal で持つと、依存元の更新時に 同期忘れバグ の温床になる • computed の戻り値は Signal<T>(set 不可)→ 書き換え不可が型レベルで保証 • 依存追跡で 実際に読まれている派生のみ が必要な時に再計算される
  10. 10|状態更新は「メソッド」を入口にカプセル化 16 fetchUser(id: number): Observable<User> { this.loading.set(true); this.fetchErrorMessage.set(null); this.user.set(null); return

    this.fetchUserAPIService.fetchUser({ id }).pipe( takeUntilDestroyed(this.destroyRef), tap({ next: (user) => { this.user.set(user); this.loading.set(false); }, error: (e: Error) => { this.fetchErrorMessage.set(e.message); this.loading.set(false); }, }), ); } • signal.set(...) は Usecase の中のみ • Component から state.user.set(...) のような変な書き換えは型レベルで不可とする
  11. 11|プレゼンテーション層: UI とフォームに専念 17 @Component({ /* ... */ providers: [UserEditUsecase]

    }) export class UserEditComponent implements OnInit { private readonly usecase = inject(UserEditUsecase); readonly state = this.usecase.state; // 読み取り専用 signal readonly userForm = new FormGroup<UserFormGroup>({ name: new FormControl('', { nonNullable: true, validators: [Validators.required] }), /* email / phoneNumber / address も同様に型付き FormControl で定義 */ }); ngOnInit(): void { this.usecase.fetchUser(this.userId).subscribe({ next: (user) => this.applyUserToForm(user), }); } submit(): void { if (this.userForm.invalid) { this.userForm.markAllAsTouched(); return; } this.usecase.updateUser({ id: this.userId, ...this.userForm.getRawValue() }) .subscribe({ next: ({ id }) => void this.router.navigate(['/users', id]) }); } } Component の仕事は 「フォーム定義」「 Usecase への委譲」「画面遷移」 のみ
  12. 12|テスタビリティ: Usecase 単体でテストできる 18 it('fetchUser 成功時は user が更新され、Observable で User

    を受け取れる', () => { const { usecase, fetchUserAPIServiceStub } = setup({ fetchUser$: of(dummyUser) }); let receivedUser: User | undefined; usecase.fetchUser(1).subscribe({ next: (user) => { receivedUser = user; } }); expect(fetchUserAPIServiceStub.fetchUser).toHaveBeenCalledWith({ id: 1 }); expect(receivedUser).toEqual(dummyUser); expect(usecase.state().user).toEqual(dummyUser); expect(usecase.state().loading).toBe(false); }); • Component を立ち上げず、TestBed.inject(UserEditUsecase) のみでロジックを検証 • API Service は薄いスタブで差し替え • state() で 状態と戻り値の両方 をアサートできる
  13. 13|導入してみて感じたこと 20 良くなったこと 注意点 • 変更の影響範囲が層の中に閉じる • Component が薄く読みやすく なった

    • Usecase の単体テストが画面なしで書ける • 小さい画面では やや冗長 に感じる • computed は 純関数限定 — 副作用は effect か Usecase メソッドへ • effect で state 全体を監視すると、関心外の変化で 再発火・上書き事故 が起きる