0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

C#エンジニアが思うAngular v19でのClean UI Architecture設計思想まとめ

0
Last updated at Posted at 2025-08-24

(個人的な備忘録のために作成中)
注意:ここで掲載するコードはイメージであり動作を保証するものではない
僭越ながら以下に設計思想をまとめました。

はじめに

  • 最大のメリットは「複雑なUI・状態管理・非同期処理・再利用性・テスト性」を分離して高めること
  • 特にWebアプリ(Angularなど)は「UI状態の変化・サーバー連携・複雑な非同期処理・画面ごとの状態やり取り」が非常に多く、これを単一層でまとめると破綻しやすいため
  • 主要なパターン「Smart/Dumb分離」、「コンポーネント指向設計」、「Store」も「Clean UI Architecture」の思想や構成要素の一部と考えられなくもない
  • Clean UI Architectureは「すべてを厳密な階層に分けなければいけない」という“型”ではなく、「責務ごとの分離」「依存方向の一方通行」「各層の役割明確化」を徹底する思想の集合体と捉えるのが本質
  • Smart/Dumb分離、コンポーネント指向、Storeパターン、Atomic Design…こういった現場で主流となった実践パターンを「設計指針」として組み合わせ、プロジェクトの複雑さや開発体制に合わせて最適な分割・統合を選ぶことが、まさに“クリーンなUIアーキテクチャ”の現代的な運用法という認識

具体的なメリット

  • 責任の分離(SoC, Separation of Concerns)
    • 画面ごとのUIロジック(Container)
    • 画面に依存しないビジネスロジック・ドメイン操作(Facade/Service)
    • ストアによる状態保持・通知
    • これらを分けることで、個別テストや保守が容易
  • 状態管理とUIレンダリングの分離
    • 複雑な状態管理(Store)はUIの記述と独立して記述でき、ロジックが見通しやすくなる
  • UI部品の再利用やサービスの共有が容易
    • ServiceやStoreは複数画面で共通利用できる
  • テストがしやすい
    • プレゼンテーション層とビジネス層を分離しているため、モックでの単体テストが可能
  • 非同期処理や副作用の分離
    • API通信、ローディング、エラーなどをUIロジックから切り離せる
  • 規模拡大時の柔軟性
    • 大規模になってもメンテが破綻しにくい

C#などローカルアプリ(特に昔ながらのWinFormsやWPFなど)と大きく構造が異なる納得できる理由

  1. Webアプリの特性の違い

    • 状態管理が圧倒的に複雑
      • Webは「常にAPI経由で状態が変わる」「UIのパーツ数が多い」「非同期更新・リアクティブUI」が求められる
      • ローカルアプリ(WinFormsなど)は画面とロジックが密結合でも破綻しにくい
    • 複数ユーザー同時操作、リアルタイム性、SPAの遷移管理
      • Webアプリは「サーバーとのやりとり」「非同期・差分更新」「ユーザー数・環境の多様性」が前提
    • 「状態の源泉」が複数になる(サーバー/クライアント/キャッシュ…)
      • 単一プロセスで完結するC#アプリとは構造が違う
  2. MVVM・MVCでも限界を感じる部分が多い

    • C# WPF等のMVVMでも、「View」「ViewModel」「Model」で分離しますが、WebはAPI/非同期/複数画面/複数ストア/イベント管理が複雑になりがち
    • AngularやReactのようなSPAは「グローバルな状態管理・複数画面間のデータフロー・副作用制御」が不可欠
  3. テスト・保守性を極限まで高めるための構造

    • Webの大規模アプリでは「テストしやすさ」「リファクタリングしやすさ」「チーム開発の効率」を徹底的に重視する必要があるため

まとめ(納得しやすい理由を一言で)

  • 「Webアプリ(特にSPA)は状態と非同期処理が非常に複雑で破綻しやすいため、責任分離してテストと保守性・再利用性を高める必要がある」
  • 「C#等のローカルアプリではプロセスが単一かつ同期的なため、昔ながらの密結合な構造でも動くが、Webでは破綻しやすい」
  • 「チーム開発・規模拡大・将来的な拡張性・品質向上には、こうした多層構造が事実上ベストプラクティスになっている」

依存関係

[Page]  →  [Params]
   ↓
[Container]  → [Messages]
   ↓       
[Facade]   →   [Store] 
   ↓                ↘
[Presenter]       [Service]
   ↓                  ↓
[Presentational]  [API,他Service]

Service (サービス)

  • 役割:API通信や外部サービスとのやり取りのみを担当
    • データの取得・登録・削除など「副作用」だけを行う
    • 状態やUIロジックには一切関与しない
    • 必要な値は呼び出し元から引数として受け取る
    • 純粋な関数型サービスを目指し、テストしやすい形に保つ

Store (ストア)

  • 役割:アプリ全体または機能単位の「状態管理」専用
    • Signal(やObservable)でデータ状態、エラー状態、ローディング状態等を管理し、購読できる形で公開
    • 必要なタイミングでServiceを呼び出し、取得・更新結果を状態に反映
    • UIや表示制御、入力変換などには一切関与しない
    • 「ドメイン」単位・「責務」単位で分割
    • 「1画面1Store」や「1エンティティ1Store」という単純な分割ではなく、「一緒に管理すべき状態は1Storeに」**という方針が基本

Facade (ファサード) ※導入は任意

  • 役割:複数StoreやService・複雑な操作の「まとめ役」
    • Store/Service/ロジックのオーケストレーションや合成を担う
    • UI層はFacadeのみを知れば済むように抽象化
    • Store・Serviceの実装詳細や変更をUI層から隠蔽
    • UI層のテスト容易性・疎結合性をさらに高める
    • Facadeが「ただの薄いパススルー」だと冗長になるので、複数StoreやServiceをまとめる/複雑な調整がある場合に効果大
    • 小規模なら無理に導入しなくてもよい(設計過剰になるため)

Container(コンテナ・スマートコンポーネント)

  • 役割:StoreやFacadeの状態取得・受け渡し、UIイベントの仲介
    • StoreやFacadeを注入し、Signal値やデータをPresenterやPresentationalに渡す
    • ユーザー操作やイベントを受けて、StoreやFacadeのメソッドを呼び出す
    • UI副作用(トースト通知、モーダル表示など)も担当
    • ビジネスロジックや状態の直接保持はしない
    • 通知サービス(Snackbar/Toast)等のUI関連副作用もここで担う

Presenter(プレゼンター)

  • 役割:「UI固有のロジック」のみを担当
    • 入力値の検証・変換、表示用データへの加工、UI状態の判定等、主にテンプレートで書きにくい/肥大化しやすい処理を分離
    • Containerから必要なデータ・イベントを受け、Presentationalにシンプルな形で渡す
    • ビジネスロジックや状態管理には関与しない
    • Containerから後から切り出していくでもあり

Presentational(プレゼンテーショナル・ダムコンポーネント)

  • 役割:純粋なUI描画とイベント発火のみ
    • @Input()でデータ受信、@Output()でイベント通知
    • ロジック・状態管理・サービス呼び出し等は一切持たない
    • 再利用性と単純性、テスト容易性を最重視
    • 必須ではなく必要に応じて
    • Containerから後から切り出していくでもあり

Params ※導入は任意

  • URLパラメーターなどを型やクラスで明示的に定義し、型安全&可読性アップ
    • 例:UserPageParams { userId: string; showDetail: boolean; }
  • PageでルートからParamsを生成し、Containerにセット

Messages ※導入は任意

  • alartやToastで表示するメッセージのリストを管理する

【要約】

  • Service:副作用のみ
  • Store:状態管理と状態遷移・データ取得/更新
  • Facade:Store/Service/ロジックの“まとめ役”(任意導入)
  • Container:データ・状態・イベントの中継、UI副作用担当
  • Presentational:UI描画とイベント発火のみ
  • Params:型・クラスなどで明示的に表現しパラメーターの型安全&明確に
  • Messages:表示メッセージの管理

【注意点】

  • Containerが肥大化したら「Presenter」でUIロジックを分離
  • Serviceが状態を保持しない(状態は必ずStoreで管理)
  • Presentationalでロジックを持たない(特に副作用や状態管理)
  • UI寄りの責務は下層(Presentational/Presenter)、ロジック/状態は中位層(Container/Store)、副作用・APIは下位層(Service)に分担
  • 依存の向きは必ず一方向。UIからデータ層へ流れる
  • 再利用性・テスト容易性・責務分離を強く意識

実装イメージ

  • Model
// user.model.ts
export interface User {
  id: number;
  name: string;
}
  • Service
// user.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { User } from './user.model';
import { Observable } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class UserService {
  constructor(private http: HttpClient) {}

  fetchUsers(): Observable<User[]> {
    // 仮にAPIエンドポイントが /api/users
    return this.http.get<User[]>('/api/users');
  }

  addUser(user: User): Observable<User> {
    return this.http.post<User>('/api/users', user);
  }
}
  • Store
// user.store.ts
import { Injectable, signal, computed } from '@angular/core';
import { UserService } from './user.service';
import { User } from './user.model';
import { firstValueFrom } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class UserStore {
  private _users = signal<User[]>([]);
  users = computed(() => this._users());

  constructor(private userService: UserService) {}

  async fetchUsers() {
    const users = await firstValueFrom(this.userService.fetchUsers());
    this._users.set(users);
  }

  async addUser(user: User) {
    const addedUser = await firstValueFrom(this.userService.addUser(user));
    this._users.update(users => [...users, addedUser]);
  }
}
  • Container
// user-container.component.ts
import { Component, OnInit } from '@angular/core';
import { UserStore } from './user.store';
import { User } from './user.model';

@Component({
  selector: 'app-user-container',
  template: `
    <app-user-presentational
      [users]="store.users()"
      (add)="onAdd($event)">
    </app-user-presentational>
  `
})
export class UserContainerComponent implements OnInit {
  constructor(public store: UserStore) {}

  ngOnInit() {
    this.store.fetchUsers();
  }

  onAdd(user: User) {
    this.store.addUser(user);
  }
}
  • Presentational
// user-presentational.component.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { User } from './user.model';

@Component({
  selector: 'app-user-presentational',
  template: `
    <ul>
      <li *ngFor="let user of users">{{ user.name }}</li>
    </ul>
    <button (click)="add.emit({id: 0, name: '新規ユーザー'})">追加</button>
  `
})
export class UserPresentationalComponent {
  @Input() users: User[] = [];
  @Output() add = new EventEmitter<User>();
}

エラーハンドリングの例

  • Service
// user.service.ts
addUser(user: User): Observable<User> {
  return this.http.post<User>('/api/users', user);
  // catchErrorはServiceではなくStoreやUI層で使うのが基本
}
  • Store
// user.store.ts
import { signal, computed } from '@angular/core';
import { UserService } from './user.service';
import { firstValueFrom } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class UserStore {
  private _users = signal<User[]>([]);
  users = computed(() => this._users());

  private _error = signal<string | null>(null);
  error = computed(() => this._error());

  constructor(private userService: UserService) {}

  async addUser(user: User) {
    this._error.set(null); // エラー初期化
    try {
      const addedUser = await firstValueFrom(this.userService.addUser(user));
      this._users.update(users => [...users, addedUser]);
    } catch (e: any) {
      this._error.set(e?.message ?? 'ユーザー追加に失敗しました');
    }
  }
}
  • Container
// user-container.component.ts
@Component({ ... })
export class UserContainerComponent {
  constructor(public store: UserStore) {}

  get error() {
    return this.store.error();
  }

  // テンプレートで [error]="error" やエラー表示処理に使う
}
  • Presentational
// user-presentational.component.ts
@Component({ ... })
export class UserPresentationalComponent {
  @Input() users: User[] = [];
  @Input() error: string | null = null;
  @Output() add = new EventEmitter<User>();
}
<!-- user-presentational.component.html -->
<ul>
  <li *ngFor="let user of users">{{ user.name }}</li>
</ul>
<button (click)="add.emit({id: 0, name: '新規ユーザー'})">追加</button>
<div *ngIf="error" class="error">
  {{ error }}
</div>

Messagesで管理する場合の例

  • 定義
// messages.ts
export const MESSAGES = {
  userAddSuccess: 'ユーザーを追加しました',
  userAddError: 'ユーザー追加に失敗しました',
  productAddSuccess: '商品を追加しました',
  productAddError: '商品追加に失敗しました',
  // 必要にパラメータ埋め込みも可
};
  • Messages
// message.service.ts
import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class MessageService {
  get(key: string, params?: Record<string, any>): string {
    const templates: Record<string, string> = {
      userAddSuccess: 'ユーザー「{name}」を追加しました',
      userAddError: 'ユーザー追加に失敗しました: {reason}',
      // ...
    };
    let message = templates[key] || '';
    if (params) {
      for (const [k, v] of Object.entries(params)) {
        message = message.replace(`{${k}}`, v);
      }
    }
    return message;
  }
}
  • Container
import { MessageService } from './message.service';

constructor(
  private snackBar: MatSnackBar,
  private messageService: MessageService,
  public facade: DashboardFacade
) {}

someMethod() {
  // 成功
  this.snackBar.open(
    this.messageService.get('userAddSuccess', { name: '山田太郎' }),
    '閉じる',
    { duration: 3000 }
  );

  // エラー
  this.snackBar.open(
    this.messageService.get('userAddError', { reason: 'ネットワークエラー' }),
    '閉じる',
    { duration: 3000 }
  );
}

Facadeの例

@Injectable({ providedIn: 'root' })
export class DashboardFacade {
  constructor(
    private userStore: UserStore,
    private productStore: ProductStore
  ) {}

  // ユーザー関連
  get users() { return this.userStore.users(); }
  get userError() { return this.userStore.error(); }
  fetchUsers() { this.userStore.fetchUsers(); }
  addUser(user: User) { this.userStore.addUser(user); }

  // 商品関連
  get products() { return this.productStore.products(); }
  get productError() { return this.productStore.error(); }
  fetchProducts() { this.productStore.fetchProducts(); }
  addProduct(product: Product) { this.productStore.addProduct(product); }

  readonly isLoading = computed(() =>
    this.userStore.isLoading()() || this.productStore.isLoading()()
  );

  readonly error$ = merge(
    this.userStore.error$.pipe(startWith(null)),
    this.productStore.error$.pipe(startWith(null))
  );

Presenterの例

  • Presenter
// user.presenter.ts
import { Injectable } from '@angular/core';
import { FormBuilder, Validators, FormGroup } from '@angular/forms';
import { User } from './user.model';

@Injectable()
export class UserPresenter {
  // ユーザー追加フォーム
  form: FormGroup;

  constructor(private fb: FormBuilder) {
    this.form = this.fb.group({
      name: ['', [Validators.required, Validators.maxLength(16)]]
    });
  }

  // バリデーション結果
  get nameError(): string | null {
    const control = this.form.get('name');
    if (control?.hasError('required')) return '名前は必須です';
    if (control?.hasError('maxLength')) return '16文字以内で入力してください';
    return null;
  }

  // UI用データ加工例
  getUserDisplayName(user: User): string {
    return `${user.name}(ID:${user.id})`;
  }

  // フォーム値の取得
  get userName(): string {
    return this.form.value.name;
  }

  // 入力値リセット
  resetForm() {
    this.form.reset();
  }
}
  • Container
// user-container.component.ts
import { Component } from '@angular/core';
import { UserStore } from './user.store';
import { UserPresenter } from './user.presenter';
import { User } from './user.model';

@Component({
  selector: 'app-user-container',
  template: `
    <app-user-presentational
      [users]="store.users()"
      [presenter]="presenter"
      (add)="onAdd()">
    </app-user-presentational>
  `,
  providers: [UserPresenter] // Presenterはスコープを限定するためprovidersでDI
})
export class UserContainerComponent {
  constructor(
    public store: UserStore,
    public presenter: UserPresenter
  ) {
    this.store.fetchUsers();
  }

  onAdd() {
    const name = this.presenter.userName;
    if (name && !this.presenter.nameError) {
      this.store.addUser({ id: 0, name });
      this.presenter.resetForm();
    }
  }
}
  • Presentational
// user-presentational.component.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { User } from './user.model';
import { UserPresenter } from './user.presenter';

@Component({
  selector: 'app-user-presentational',
  template: `
    <ul>
      <li *ngFor="let user of users">
        {{ presenter.getUserDisplayName(user) }}
      </li>
    </ul>
    <form [formGroup]="presenter.form" (ngSubmit)="add.emit()">
      <input formControlName="name" placeholder="ユーザー名">
      <div class="error" *ngIf="presenter.nameError">{{ presenter.nameError }}</div>
      <button type="submit" [disabled]="presenter.form.invalid">追加</button>
    </form>
  `
})
export class UserPresentationalComponent {
  @Input() users: User[] = [];
  @Input() presenter!: UserPresenter;
  @Output() add = new EventEmitter<void>();
}

PresentationalはPresenter経由でバリデーションやUIロジックを参照
UI側ではPresenterのAPIだけ意識すればよい

ローディング状態を管理する例

  • Store
// user.store.ts
import { Injectable, signal, computed } from '@angular/core';
import { UserService } from './user.service';
import { User } from './user.model';
import { firstValueFrom } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class UserStore {
  private _users = signal<User[]>([]);
  users = computed(() => this._users());

  private _isLoading = signal<boolean>(false);
  isLoading = computed(() => this._isLoading());

  constructor(private userService: UserService) {}

  async fetchUsers() {
    this._isLoading.set(true);   // ローディング開始
    try {
      const users = await firstValueFrom(this.userService.fetchUsers());
      this._users.set(users);
    } catch (e) {
      // エラー処理
    } finally {
      this._isLoading.set(false); // ローディング終了
    }
  }

  async addUser(user: User) {
    this._isLoading.set(true);
    try {
      const addedUser = await firstValueFrom(this.userService.addUser(user));
      this._users.update(users => [...users, addedUser]);
    } catch (e) {
      // エラー処理
    } finally {
      this._isLoading.set(false);
    }
  }
}
  • Facade (任意)
// dashboard.facade.ts
@Injectable({ providedIn: 'root' })
export class DashboardFacade {
  constructor(
    private userStore: UserStore,
    private productStore: ProductStore
  ) {}

  readonly isLoading = computed(() =>
    this.userStore.isLoading()() || this.productStore.isLoading()()
  );

  // ...他の状態や操作も同様に委譲
}
  • Container
@Component({
  selector: 'app-user-container',
  template: `
    <app-user-presentational
      [users]="store.users()"
      [isLoading]="store.isLoading()"
      (add)="onAdd($event)">
    </app-user-presentational>
  `
})
export class UserContainerComponent implements OnInit {
  constructor(public store: UserStore) {}

  ngOnInit() {
    this.store.fetchUsers();
  }

  onAdd(user: User) {
    this.store.addUser(user);
  }
}
  • Presentational
@Component({
  selector: 'app-user-presentational',
  template: `
    <div *ngIf="isLoading" class="loading-spinner">Loading...</div>
    <ul>
      <li *ngFor="let user of users">{{ user.name }}</li>
    </ul>
    <button (click)="add.emit({id: 0, name: '新規ユーザー'})">追加</button>
  `
})
export class UserPresentationalComponent {
  @Input() users: User[] = [];
  @Input() isLoading: boolean = false;
  @Output() add = new EventEmitter<User>();
}

ポイント:Serviceはローディング状態を一切管理しない

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?