(個人的な備忘録のために作成中)
注意:ここで掲載するコードはイメージであり動作を保証するものではない
僭越ながら以下に設計思想をまとめました。
はじめに
- 最大のメリットは「複雑な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など)と大きく構造が異なる納得できる理由
-
Webアプリの特性の違い
- 状態管理が圧倒的に複雑
- Webは「常にAPI経由で状態が変わる」「UIのパーツ数が多い」「非同期更新・リアクティブUI」が求められる
- ローカルアプリ(WinFormsなど)は画面とロジックが密結合でも破綻しにくい
- 複数ユーザー同時操作、リアルタイム性、SPAの遷移管理
- Webアプリは「サーバーとのやりとり」「非同期・差分更新」「ユーザー数・環境の多様性」が前提
- 「状態の源泉」が複数になる(サーバー/クライアント/キャッシュ…)
- 単一プロセスで完結するC#アプリとは構造が違う
- 状態管理が圧倒的に複雑
-
MVVM・MVCでも限界を感じる部分が多い
- C# WPF等のMVVMでも、「View」「ViewModel」「Model」で分離しますが、WebはAPI/非同期/複数画面/複数ストア/イベント管理が複雑になりがち
- AngularやReactのようなSPAは「グローバルな状態管理・複数画面間のデータフロー・副作用制御」が不可欠
-
テスト・保守性を極限まで高めるための構造
- 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描画とイベント発火のみ
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はローディング状態を一切管理しない