Slide 1

Slide 1 text

TSKaigi 2026 2026.05.22 NEALLE TypeScriptとAngular Signal で実現する 保守性の高いアプリケーション設計 - 3層アーキテクチャによる責務分離の実践 1

Slide 2

Slide 2 text

目次 2|なぜ Component に全部書くと辛くなるのか 3|3 層アーキテクチャ( Component / Usecase / API Service) 2 1|自己紹介 4|まとめ

Slide 3

Slide 3 text

自己紹介 Nealle 1|会社概要 3

Slide 4

Slide 4 text

4 氏名 立川 隆一 / Ryuichi Tatsukawa 所属 株式会社ニーリー プロダクト統括本部 プラットフォーム本部 ArchitectureG 経歴 2016 - 2019(株式会社かなめ) Webアプリケーションエンジニアとしてキャリアをスタート 2019 - 2021(株式会社 Hmcomm) シニアエンジニアとして経験を積む 2021 - 2024(株式会社ビザスク) 一定期間エンジニアとして従事した後、チームリーダーとしてマネジメント業務を担当 2024 - (株式会社ニーリー) テックリードとしてジョイン 1|自己紹介

Slide 5

Slide 5 text

なぜ Component に全部書くと辛くなるのか Nealle 1|会社概要 5

Slide 6

Slide 6 text

題材は 「ユーザー編集画面」 の実装。 小さな例で、責務がどう分かれるかを追っていきます。 Nealle 1|会社概要 6

Slide 7

Slide 7 text

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(`${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… */ } }

Slide 8

Slide 8 text

3|何が辛いのか 8 8 可読性 再利用性 ● ngOnInit に手続き型ロジックが膨張 ● HTTP 通信・状態フラグ・命名変換が同居 ● 一覧画面・詳細画面で同じ取得処理を再実装 ● 命名変換ロジックが画面ごとに散らばる テスタビリティ ● Component を立ち上げないとロジックを試せな い ● HttpClient のモック準備が画面ごとに必要 型安全性 ● snake_case と camelCase が混ざる ● any や as での妥協が増える

Slide 9

Slide 9 text

3層アーキテクチャ (Component / Usecase / API Service) Nealle 1|会社概要 9

Slide 10

Slide 10 text

4|解決策:3層に責務を分離する 10 Component(プレゼンテーション層) UI 表示 / フォーム / ユーザー操作の受付 Usecase (ユースケース層) 状態管理(Signal)/ ロジック API Service (データアクセス層) HTTP 通信 / 命名変換(snake_case ⇄ camelCase) inject inject 各層は下の層にのみ依存し、Component は API Service を直接呼ばない。

Slide 11

Slide 11 text

5|各層の責務サマリー 11 Component Usecase API Service やること やらないこと 状態管理(Signal)/ ロジック / API 呼び出し の組み立て テンプレートバインディング / フォームバリ デーション / 操作の Usecase 委譲 HTTP リクエスト / 命名変換 / エラー整形 状態の判断・計算 / API 直接呼び出し UI 表示判断 / HTTP の詳細 / フォーム検証 状態保持 / ロジック

Slide 12

Slide 12 text

6|データアクセス層: HTTP と命名変換に専念 12 @Injectable({ providedIn: 'root' }) export class UpdateUserAPIService { private readonly http = inject(HttpClient); updateUser(params: UpdateUserParams): Observable { const requestData = this.mapToApiRequestFormat(params); // camelCase → snake_case return this.http .put(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)を別 の型として定義 ● 命名規則の違いはこの層のみが知っている

Slide 13

Slide 13 text

7|ユースケース層とプレゼンテーション層の関係 13 Component(プレゼンテーション層) Usecase (ユースケース層) 操作 fetchUser() / submit() public method fetchUser() / updateUser() private writable signals user / loading / errors public computed signals state(Signal) 表示 {{state().user}} Signal 更新 call readonly 依存追跡 ● 書き込み経路 :Component → public method → writable signal ● 読み出し経路 :writable signal → computed state → Component(読み取り専用)

Slide 14

Slide 14 text

8|ユースケース層: Signal による状態管理 14 @Injectable() export class UserEditUsecase { // ① writable signals は private(更新は Usecase 内に閉じる) private readonly user = signal(null); private readonly loading = signal(false); private readonly fetchErrorMessage = signal(null); private readonly updateErrorMessage = signal(null); // ② state は computed(外部からは読み取り専用) readonly state = computed(() => ({ user: this.user(), loading: this.loading(), fetchErrorMessage: this.fetchErrorMessage(), updateErrorMessage: this.updateErrorMessage(), })); } Writable signal を private、Computed signal を public にする 2 層構造。 Component は state() から 読むのみ 。状態更新の入口は Usecase のメソッドに統一される。

Slide 15

Slide 15 text

9|状態は最小に、派生は computed で計算する 15 // 派生状態は signal を増やすのではなく computed で導出 private readonly canSubmit = computed( () => !this.loading() && this.user() !== null, ); private readonly errorMessage = computed( () => this.fetchErrorMessage() ?? this.updateErrorMessage(), ); // state に合成して公開(Component は state().canSubmit で参照) readonly state = computed(() => ({ /* user, loading, fetch/updateErrorMessage に加え */ canSubmit: this.canSubmit(), errorMessage: this.errorMessage(), })); ● 「ボタンが活性か」「エラーが出ているか」は 状態ではなく状態の関数 ● 派生を新しい signal で持つと、依存元の更新時に 同期忘れバグ の温床になる ● computed の戻り値は Signal(set 不可)→ 書き換え不可が型レベルで保証 ● 依存追跡で 実際に読まれている派生のみ が必要な時に再計算される

Slide 16

Slide 16 text

10|状態更新は「メソッド」を入口にカプセル化 16 fetchUser(id: number): Observable { 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(...) のような変な書き換えは型レベルで不可とする

Slide 17

Slide 17 text

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({ 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 への委譲」「画面遷移」 のみ

Slide 18

Slide 18 text

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() で 状態と戻り値の両方 をアサートできる

Slide 19

Slide 19 text

まとめ Nealle 1|会社概要 19

Slide 20

Slide 20 text

13|導入してみて感じたこと 20 良くなったこと 注意点 ● 変更の影響範囲が層の中に閉じる ● Component が薄く読みやすく なった ● Usecase の単体テストが画面なしで書ける ● 小さい画面では やや冗長 に感じる ● computed は 純関数限定 — 副作用は effect か Usecase メソッドへ ● effect で state 全体を監視すると、関心外の変化で 再発火・上書き事故 が起きる

Slide 21

Slide 21 text

No content

Slide 22

Slide 22 text

ブースにもぜひ!! 22