Angular のクリーンなアーキテクチャ: スケーラブルで保守可能な構造
La クリーンなアーキテクチャRobert C. Martin (Uncle Bob) によって提案され、モデルを定義します。 ビジネス ルールが実装の詳細から分離されたコード構成。 このアーキテクチャを Angular に適用すると、時の試練に耐えるアプリケーションを構築できます。 テストが容易になり、フレームワーク、ライブラリ、インフラストラクチャを置き換えることができます。 ドメインロジックを書き換えます。
この記事では、クリーン原則に従って Angular プロジェクトを構築する方法を説明します。 個別のレイヤーを備えたアーキテクチャ ドメイン, 応用, インフラストラクチャー e プレゼンテーション。依存関係ルールについて説明します。 依存関係の逆転と、ユースケース、リポジトリ、マッパーなどの具体的なパターン。
何を学ぶか
- クリーン アーキテクチャの基本原則
- Angular プロジェクトでフォルダーを整理する方法
- ユースケース/インタラクターパターン
- データアクセスのリポジトリパターン
- ドメインモデルとDTOの間のマッピング
- 依存関係の注入と依存関係の反転
- SOLID原則の実践
基本原則
クリーン アーキテクチャは、次の中心的な概念に基づいています。 依存症は常に指摘する 内側へ。外部レイヤー (UI、データベース、API) は内部レイヤーに依存します (ビジネス ロジック、ドメイン エンティティ)、その逆は決してありません。として知られるこの原理は、 依存関係ルール、アプリケーションのコアが独立した状態を維持することを保証します。 特定のテクノロジーによって。
4つの層
| レイヤー | 責任 | Angular の例 |
|---|---|---|
| ドメイン | エンティティ、値オブジェクト、リポジトリインターフェイス | 純粋な TypeScript クラス、Angular 依存関係なし |
| 応用 | ユースケース、ロジックオーケストレーション | ドメインの運用を調整するサービス |
| インフラストラクチャー | 具体的な実装、データアクセス、API | HttpClient、localStorage、Firebase、アダプター |
| プレゼンテーション | UI、コンポーネント、視覚的な状態管理 | Angular コンポーネント、テンプレート、パイプ |
フォルダー構造
フォルダー構造はアーキテクチャー層を直接反映します。あらゆる機能 アプリケーションのプロジェクトは同じスキームに従って編成され、プロジェクトが作成されます。 後からチームに参加する開発者にとっても、予測可能でナビゲートしやすくなります。
src/
app/
core/ # Servizi singleton, guard, interceptor
interceptors/
guards/
features/
orders/ # Feature: Gestione Ordini
domain/
models/
order.model.ts # Entità di dominio
order-item.model.ts # Value Object
repositories/
order.repository.ts # Interfaccia (porta)
application/
use-cases/
create-order.use-case.ts
get-orders.use-case.ts
cancel-order.use-case.ts
dto/
create-order.dto.ts
order-response.dto.ts
mappers/
order.mapper.ts
infrastructure/
repositories/
order-http.repository.ts # Implementazione HTTP
order-mock.repository.ts # Implementazione mock
adapters/
order-api.adapter.ts
presentation/
pages/
order-list/
order-detail/
components/
order-card/
order-form/
state/
orders.store.ts
shared/ # Componenti e utility condivisi
ui/
pipes/
directives/
レイヤードメイン
ドメイン層には、 事業体 そして インターフェース データへのアクセスに関する契約を定義します。この層には依存関係がありません Angular、RxJS、または任意の外部ライブラリから。これらは純粋な TypeScript クラスとインターフェイスです。
ドメインエンティティ
エンティティはビジネスの中心的な概念を表します。検証ルールが含まれています そしてドメインの組み込みロジック。単純なデータコンテナ (貧血モデル) ではありません。 しかし、オブジェクトは動作が豊富です。
// domain/models/order.model.ts
export type OrderStatus = 'draft' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';
export class Order {
constructor(
public readonly id: string,
public readonly customerId: string,
private _items: OrderItem[],
private _status: OrderStatus = 'draft',
public readonly createdAt: Date = new Date()
) {
if (_items.length === 0) {
throw new Error('Un ordine deve contenere almeno un articolo');
}
}
get status(): OrderStatus {
return this._status;
}
get items(): ReadonlyArray<OrderItem> {
return [...this._items];
}
get total(): number {
return this._items.reduce(
(sum, item) => sum + item.subtotal, 0
);
}
confirm(): void {
if (this._status !== 'draft') {
throw new Error('Solo gli ordini in bozza possono essere confermati');
}
this._status = 'confirmed';
}
cancel(): void {
if (this._status === 'shipped' || this._status === 'delivered') {
throw new Error('Non è possibile cancellare un ordine già spedito');
}
this._status = 'cancelled';
}
addItem(item: OrderItem): void {
const existing = this._items.find(i => i.productId === item.productId);
if (existing) {
throw new Error('Prodotto già presente. Aggiorna la quantità.');
}
this._items.push(item);
}
}
値オブジェクト
値オブジェクトは、ID ではなく、その値によって定義される不変オブジェクトです。 同じ属性を持つ 2 つの値オブジェクトは等しいとみなされます。に最適です。 価格、住所、数量などの概念を表します。
// domain/models/order-item.model.ts
export class OrderItem {
public readonly subtotal: number;
constructor(
public readonly productId: string,
public readonly productName: string,
public readonly quantity: number,
public readonly unitPrice: number
) {
if (quantity <= 0) {
throw new Error('La quantità deve essere maggiore di zero');
}
if (unitPrice < 0) {
throw new Error('Il prezzo unitario non può essere negativo');
}
this.subtotal = quantity * unitPrice;
}
equals(other: OrderItem): boolean {
return this.productId === other.productId
&& this.quantity === other.quantity
&& this.unitPrice === other.unitPrice;
}
}
リポジトリインターフェース(ポート)
リポジトリ インターフェイスは、 契約 データにアクセスするため。 これは、ドメインが必要とするものを表すため、ドメイン層に存在します。 データの取得方法を指定します。これが依存関係逆転の本質です。
// domain/repositories/order.repository.ts
import { Observable } from 'rxjs';
import { Order } from '../models/order.model';
export abstract class OrderRepository {
abstract getAll(): Observable<Order[]>;
abstract getById(id: string): Observable<Order>;
abstract getByCustomer(customerId: string): Observable<Order[]>;
abstract save(order: Order): Observable<Order>;
abstract delete(id: string): Observable<void>;
}
なぜインターフェイスではなく抽象クラスなのか?
Angular では、依存性注入システムには トークン 実行時。 TypeScript インターフェイスはコンパイル時に削除されるため、削除できません。 DIトークンとして使用されます。一方、抽象クラスは実行時に存在し、両方の役割を果たします。 インジェクション用のトークンを与えるコントラクト。
アプリケーション層: ユースケース
アプリケーション層には、 ユースケース (またはインタラクター)、オーケストレーションを行う ドメイン エンティティとリポジトリを調整することによるビジネス ロジック。それぞれのユースケースが表すのは、 アプリケーションが実行できる単一の操作。この層はドメインに依存します しかし、インフラストラクチャやプレゼンテーションの詳細はわかりません。
ユースケースの構造
// application/use-cases/create-order.use-case.ts
import { Injectable } from '@angular/core';
import { Observable, map } from 'rxjs';
import { Order } from '../../domain/models/order.model';
import { OrderItem } from '../../domain/models/order-item.model';
import { OrderRepository } from '../../domain/repositories/order.repository';
import { CreateOrderDto } from '../dto/create-order.dto';
import { OrderResponseDto } from '../dto/order-response.dto';
import { OrderMapper } from '../mappers/order.mapper';
@Injectable({ providedIn: 'root' })
export class CreateOrderUseCase {
constructor(private readonly orderRepo: OrderRepository) {}
execute(dto: CreateOrderDto): Observable<OrderResponseDto> {
const items = dto.items.map(
i => new OrderItem(i.productId, i.productName, i.quantity, i.unitPrice)
);
const order = new Order(
crypto.randomUUID(),
dto.customerId,
items
);
return this.orderRepo.save(order).pipe(
map(saved => OrderMapper.toResponse(saved))
);
}
}
DTO とマッパー
I データ転送オブジェクト (DTO) 受信データの形式を定義します。 アプリケーション層から出てきます。ザ マッパー ドメインエンティティ間で変換します と DTO により、2 つの世界が分離されます。
// application/dto/create-order.dto.ts
export interface CreateOrderDto {
customerId: string;
items: {
productId: string;
productName: string;
quantity: number;
unitPrice: number;
}[];
}
// application/dto/order-response.dto.ts
export interface OrderResponseDto {
id: string;
customerId: string;
status: string;
items: {
productId: string;
productName: string;
quantity: number;
unitPrice: number;
subtotal: number;
}[];
total: number;
createdAt: string;
}
// application/mappers/order.mapper.ts
import { Order } from '../../domain/models/order.model';
import { OrderResponseDto } from '../dto/order-response.dto';
export class OrderMapper {
static toResponse(order: Order): OrderResponseDto {
return {
id: order.id,
customerId: order.customerId,
status: order.status,
items: order.items.map(item => ({
productId: item.productId,
productName: item.productName,
quantity: item.quantity,
unitPrice: item.unitPrice,
subtotal: item.subtotal,
})),
total: order.total,
createdAt: order.createdAt.toISOString(),
};
}
}
インフラストラクチャ層
インフラストラクチャ層は、 具体的な実装 インターフェースの ドメインで定義されています。ここには HTTP 呼び出しが存在し、localStorage に永続化されます。 Firebase との統合やその他の技術的な詳細。この層は依存します ドメイン (インターフェースを実装するため) と外部ライブラリ (HttpClient、Firebase SDK) の両方から。
// infrastructure/repositories/order-http.repository.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, map } from 'rxjs';
import { Order } from '../../domain/models/order.model';
import { OrderItem } from '../../domain/models/order-item.model';
import { OrderRepository } from '../../domain/repositories/order.repository';
interface OrderApiResponse {
id: string;
customer_id: string;
status: string;
items: {
product_id: string;
product_name: string;
qty: number;
price: number;
}[];
created_at: string;
}
@Injectable()
export class OrderHttpRepository extends OrderRepository {
private readonly baseUrl = '/api/orders';
constructor(private readonly http: HttpClient) {
super();
}
getAll(): Observable<Order[]> {
return this.http.get<OrderApiResponse[]>(this.baseUrl).pipe(
map(responses => responses.map(r => this.toDomain(r)))
);
}
getById(id: string): Observable<Order> {
return this.http.get<OrderApiResponse>(
`${this.baseUrl}/${id}`
).pipe(
map(r => this.toDomain(r))
);
}
getByCustomer(customerId: string): Observable<Order[]> {
return this.http.get<OrderApiResponse[]>(
`${this.baseUrl}?customer=${customerId}`
).pipe(
map(responses => responses.map(r => this.toDomain(r)))
);
}
save(order: Order): Observable<Order> {
const payload = this.toApi(order);
return this.http.post<OrderApiResponse>(this.baseUrl, payload).pipe(
map(r => this.toDomain(r))
);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
private toDomain(response: OrderApiResponse): Order {
const items = response.items.map(
i => new OrderItem(i.product_id, i.product_name, i.qty, i.price)
);
return new Order(
response.id,
response.customer_id,
items,
response.status as any,
new Date(response.created_at)
);
}
private toApi(order: Order): object {
return {
customer_id: order.customerId,
status: order.status,
items: order.items.map(i => ({
product_id: i.productId,
product_name: i.productName,
qty: i.quantity,
price: i.unitPrice,
})),
};
}
}
依存関係の挿入の構成
インターフェイスと実装の間の接続は、Angular の DI システムを介して行われます。 プロバイダー構成では、どの具体的な実装を指定しますか リポジトリ抽象クラスが要求される場合は、これを使用する必要があります。
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { OrderRepository } from './features/orders/domain/repositories/order.repository';
import { OrderHttpRepository } from './features/orders/infrastructure/repositories/order-http.repository';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
{
provide: OrderRepository,
useClass: OrderHttpRepository,
},
],
};
利点: 交換可能
構成を 1 行変更するだけで、次のいずれかを切り替えることができます。 OrderHttpRepository
a OrderMockRepository テスト用、または OrderFirebaseRepository
バックエンドを移行することにした場合。アプリケーションの残りの部分は変更されません。
プレゼンテーション層
プレゼンテーション層には、Angular コンポーネント、テンプレート、状態管理が含まれます ビジュアル。コンポーネントは i と排他的に対話します。 使用例 層の ドメインやインフラストラクチャの詳細が分からないアプリケーション。
// presentation/pages/order-list/order-list.component.ts
import { Component, OnInit, signal } from '@angular/core';
import { GetOrdersUseCase } from '../../../application/use-cases/get-orders.use-case';
import { OrderResponseDto } from '../../../application/dto/order-response.dto';
@Component({
selector: 'app-order-list',
standalone: true,
templateUrl: './order-list.component.html',
styleUrls: ['./order-list.component.css'],
})
export class OrderListComponent implements OnInit {
orders = signal<OrderResponseDto[]>([]);
loading = signal(true);
error = signal<string | null>(null);
constructor(private readonly getOrders: GetOrdersUseCase) {}
ngOnInit(): void {
this.getOrders.execute().subscribe({
next: (data) => {
this.orders.set(data);
this.loading.set(false);
},
error: (err) => {
this.error.set('Errore nel caricamento degli ordini');
this.loading.set(false);
},
});
}
}
堅実な原則の実践
クリーン アーキテクチャは、プロジェクトに SOLID 原則を適用するための自然な手段です 角張った。定義した構造の中で各原則がどのように現れるかを見てみましょう。
| 原理 | クリーン アーキテクチャでのアプリケーション |
|---|---|
| 単一の責任 | 各ユースケースには 1 つの責任しかありません。各層には明確な役割があります。 |
| オープン/クローズ | 既存のコードを変更せずにリポジトリ (Firebase など) を新しい実装します。 |
| リスコフの交代 | リポジトリの実装は副作用なしに現在のものを置き換えることができます。 |
| インターフェースの分離 | DTO は必要なデータのみを公開します。リポジトリ インターフェイスは機能固有です。 |
| 依存関係の逆転 | 上位層 (ユースケース) は、具体的な実装ではなく、抽象化 (抽象リポジトリ) に依存します。 |
クリーンなアーキテクチャでのテスト
クリーン アーキテクチャの主な利点の 1 つは、 テスト容易性。 依存関係が分離されているため、各層を分離してテストできます。
// application/use-cases/create-order.use-case.spec.ts
import { of } from 'rxjs';
import { CreateOrderUseCase } from './create-order.use-case';
import { OrderRepository } from '../../domain/repositories/order.repository';
import { Order } from '../../domain/models/order.model';
import { OrderItem } from '../../domain/models/order-item.model';
describe('CreateOrderUseCase', () => {
let useCase: CreateOrderUseCase;
let mockRepo: jasmine.SpyObj<OrderRepository>;
beforeEach(() => {
mockRepo = jasmine.createSpyObj('OrderRepository', ['save']);
useCase = new CreateOrderUseCase(mockRepo);
});
it('should create an order and return a response DTO', (done) => {
const dto = {
customerId: 'cust-1',
items: [{
productId: 'prod-1',
productName: 'Widget',
quantity: 2,
unitPrice: 25.00,
}],
};
const savedOrder = new Order(
'order-123', 'cust-1',
[new OrderItem('prod-1', 'Widget', 2, 25.00)]
);
mockRepo.save.and.returnValue(of(savedOrder));
useCase.execute(dto).subscribe(result => {
expect(result.id).toBe('order-123');
expect(result.total).toBe(50);
expect(result.items.length).toBe(1);
expect(mockRepo.save).toHaveBeenCalledTimes(1);
done();
});
});
});
避けるべきよくある間違い
クリーン アーキテクチャの採用には規律が必要です。最も頻繁に発生するエラーは次のとおりです これにより、アーキテクチャの利点が損なわれます。
| 間違い | 結果 | 解決 |
|---|---|---|
| HttpClient をコンポーネント内で直接使用する | UIとインフラストラクチャの結合 | 常にユースケースとリポジトリを確認する |
| 貧血ドメイン エンティティ (データのみ、ロジックなし) | ビジネス ロジックは最終的にサービスになり、テストが困難 | 検証と動作でエンティティを強化する |
| エンティティと 1:1 で一致する DTO | ドメインの変更は API に直接影響します | DTO とモデルを独立させ、Mapper を使用する |
| レイヤー間の循環依存関係 | 依存関係ルール違反、脆弱なコード | 依存関係が常にドメインを指していることを確認します。 |
| 単純なアプリケーション向けのオーバーエンジニアリング | 不当な複雑さ、開発の遅れ | クリーン アーキテクチャを採用する前にプロジェクトの複雑さを評価する |
クリーン アーキテクチャを採用する場合
クリーン アーキテクチャがすべての問題を解決するわけではありません。導入にはコストがかかる 初期の複雑さとコードの量の点で。ここに実践的なガイドがあります それをいつ使用するのが理にかなっているかを決定します。
クイック評価
| シナリオ | クリーンな建築? | 代替 |
|---|---|---|
| プロトタイプ/MVP | No | シンプルな機能ベースの構造 |
| シンプルなCRUDアプリケーション | おそらくそうではありません | 階層化アーキテクチャ (MVC) |
| 複雑なロジックを備えたエンタープライズ アプリケーション | Si | - |
| 素晴らしいチーム、長期にわたるメンテナンスが予想される | Si | - |
| バックエンドまたはテクノロジーの変更の可能性 | Si | - |
結論
クリーン アーキテクチャを Angular に適用すると、データの構造が変わります フロントエンドアプリケーション。ドメインをテクノロジーから分離することで、より多くのコードを取得できます テスト可能、保守可能、および変更に対する耐性があります。そこには 依存関係ルール フレームワークを変更してもアプリケーションのコアが安定した状態を維持できるようにします。 ライブラリや外部サービス。
ボイラープレートに関する初期コストは、メンテナンスの容易さによって補われます。 長期的には、構造の明確さと人々を働かせる可能性によって 独立したレイヤー上の異なるチーム。他のアーキテクチャ上の選択と同様に、それを調整する必要があります。 プロジェクトの実際の複雑さ: ビジネス ロジックを備えたエンタープライズ アプリケーションの場合 重要なことは、クリーン アーキテクチャは時間の経過とともに利益が得られる投資であるということです。







