注意書き
本記事で紹介するリポジトリは、アーキテクチャの実践例として公開しています。以下の点にご注意ください。
- 本記事は AI を活用して作成されましたが、内容の正確性を確認・検証した上で投稿しています
- 一部の機能は未実装または未テスト のため、アーキテクチャ面を参考にしてください
- CSRF 対策の実装が適切でない可能性 があります。本番環境への導入時は十分な検証をお願いします
はじめに
Angular + NestJS のモノレポ構成で RealWorld(Conduit) を実装しました。RealWorld は「Medium クローン」として知られるブログプラットフォームで、CRUD 操作、認証、ルーティング、ページネーションなど実践的な機能を含みます。
主な成果:
- Google Lighthouse 全カテゴリ(パフォーマンス、アクセシビリティ、ベストプラクティス、SEO)で 100点満点
- TypeScript フルスタック構成のリファレンス実装
リポジトリ: angular-nestjs-realworld-example-app
ホスティングサイト: realworld.motora-dev.com
本記事では、この実装を通じて得られたアーキテクチャ設計、開発体験、AI を用いた開発への適合などの知見を共有します。
目次
はじめに
アーキテクチャ
Client(Angular)
Server(NestJS)
認証・セキュリティ
パフォーマンス・SEO
開発・運用
おわりに
技術スタック概要
本プロジェクトは Turborepo + pnpm によるモノレポ構成を採用しています。
Client(Angular)
- Framework: Angular 21 + SSR + ISR
- State Management: NGXS 21
- Styling: Tailwind CSS 4 + CVA + tailwind-merge
- Reactive: RxAngular 20 + RxJS 7
- Testing: Vitest + @testing-library/angular + Storybook
Server(NestJS)
- Framework: NestJS 11
- ORM: Prisma 7
- Pattern: CQRS(@nestjs/cqrs)
- Build: esbuild + SWC
- Testing: Vitest
共有パッケージ
-
@monorepo/database: Prisma スキーマ・クライアント -
@monorepo/error-code: エラーコード定義 -
@monorepo/eslint-config: 共通 ESLint 設定 -
@monorepo/typescript-config: 基本 TypeScript 設定
なぜ Angular + NestJS を選んだか
React/Next.js との比較
Angular + NestJS を選んだ理由は「規約 > 設定」の思想にあります。
| 観点 | Angular / NestJS | React / Next.js |
|---|---|---|
| ディレクトリ構成 | フレームワークが推奨パターンを提示 | 自由度が高い(選定が必要) |
| DI(依存性注入) | 標準装備 | 外部ライブラリが必要 |
| フォーム | Reactive Forms が標準 | React Hook Form 等を選定 |
| HTTP クライアント | HttpClient が標準 | axios / fetch を選定 |
| 状態管理 | NGXS / NgRx が定番 | Redux / Zustand / Jotai など多数 |
React エコシステムは自由度が高い反面、「どのライブラリを使うか」「どういうディレクトリ構成にするか」を都度決める必要があります。これを「選定疲れ」と呼ぶ人もいます。
Angular と NestJS は、DI とデコレーターの思想が共通しており、フロントエンドとバックエンドで一貫した設計ができます。
クラスコンポーネント + DI の利点
Angular と NestJS はクラスベースのコンポーネント設計を採用しています。
// Angular コンポーネント
@Component({ ... })
export class ArticleListComponent {
private readonly facade = inject(ArticleListFacade);
}
// NestJS コントローラー
@Controller('articles')
export class ArticleController {
constructor(private readonly queryBus: QueryBus) {}
}
クラスコンポーネント + DI には以下の利点があります:
- ディレクトリ構成による分割が容易: クラス単位でファイルを分割でき、依存関係が明確
- TypeScript との高い親和性: クラス、デコレーター、インターフェースなど TypeScript の機能をフル活用
- テストの容易さ: DI により Mock の差し替えが容易
関数コンポーネントと比べて、クラス型の TypeScript と親和性が高いと感じています。
モノレポ戦略
コマンドの共通化
ルートディレクトリから全アプリケーションを操作できます。
# 全アプリケーションを起動
pnpm start
# 全テスト実行
pnpm test
# 全パッケージのビルド
pnpm build
# 全チェック(型チェック、フォーマット、リント、ビルド、テスト)
pnpm check-all
pnpm catalog によるバージョン一元管理
pnpm-workspace.yaml でパッケージバージョンを一元管理しています。
# pnpm-workspace.yaml
versions:
angular: &angular 21.0.0
nestjs: &nestjs 11.0.0
catalog:
'@angular/core': *angular
'@nestjs/core': *nestjs
// apps/client/package.json
{
"dependencies": {
"@angular/core": "catalog:"
}
}
メリット:
-
pnpm-workspace.yamlで使用パッケージのバージョンを即座に確認可能 - 1 箇所の変更で全体に反映(例: Angular 21.0.0、NestJS 11.0.0 を一元管理)
- バージョン不整合の防止
- パッケージアップグレードへの耐性向上
共有パッケージ構成
packages/
├── database/ # Prisma スキーマ・クライアント
├── error-code/ # エラーコード定義
├── eslint-config/ # 共通 ESLint 設定
└── typescript-config/ # 基本 TypeScript 設定
ディレクトリ構成
全体構成
angular-nestjs-realworld-example-app/
├── apps/
│ ├── client/ # Angular フロントエンド
│ └── server/ # NestJS バックエンド
├── packages/ # 共有パッケージ
│ ├── database/ # Prisma スキーマ・クライアント
│ ├── error-code/ # エラーコード定義
│ └── ...
├── pnpm-workspace.yaml
└── turbo.json
Client のディレクトリ構成(詳細)
src/
├── app/ # ページ(Vertical Slice)
│ └── {page}/
│ ├── {page}.ts # 親コンポーネント(Facade 提供)
│ ├── {page}.html # レイアウト
│ ├── {page}.routes.ts # ルーティング
│ └── components/ # ページ固有サブコンポーネント
│ └── {name}/
│ ├── {name}.ts
│ └── {name}.html
├── components/ # 複数ページで共有するコンポーネント
├── domains/ # ドメインロジック + 状態管理
│ └── {domain}/
│ ├── api/ # API 呼び出し(内部)
│ ├── model/ # State/UI 用モデル(公開)
│ ├── store/ # NGXS State + Actions(内部)
│ └── {domain}.facade.ts # Facade(公開)
├── modules/ # 共有モジュール
└── shared/ # UI プリミティブ + ユーティリティ
ポイント:
-
domains/のindex.tsでは model と facade のみをエクスポート -
api/とstore/は内部実装として隠蔽 - 外部から使うべきものが明確
Client のレイヤー構成
| ディレクトリ | レイヤー | 責務 |
|---|---|---|
app/ |
Presentation | ページ・ルーティング・UI表示 |
components/ |
Presentation (Shared) | 複数ページで共有するUIコンポーネント |
domains/ |
Domain + Application | エンティティ・状態管理(NGXS)・ビジネスロジック |
modules/ |
Application | app/components/domains間で共有するロジック |
shared/ |
Infrastructure | ユーティリティ・UIプリミティブ・アダプター |
依存関係のルール:
app/ ──→ components/ ──→ domains/ ──→ modules/ ──→ shared/
- 上位レイヤーは下位レイヤーに依存できる(右方向への依存のみ許可)
- 下位レイヤーは上位レイヤーに依存してはならない(左方向への依存は禁止)
Server のディレクトリ構成(詳細)
src/
├── domains/ # 各ドメイン(Vertical Slice)
│ └── {domain}/
│ ├── {domain}.controller.ts # エンドポイント
│ ├── {domain}.module.ts # モジュール
│ ├── commands/ # 書き込み系(CQRS)
│ │ └── {action}/
│ │ ├── {action}.command.ts
│ │ └── {action}.handler.ts
│ ├── queries/ # 読み取り系(CQRS)
│ │ └── {action}/
│ │ ├── {action}.query.ts
│ │ └── {action}.handler.ts
│ ├── contracts/ # DTO/Input/Model
│ ├── presenters/ # レスポンス整形
│ ├── repositories/ # データアクセス
│ └── services/ # ビジネスロジック
├── modules/ # 共有モジュール(auth 等)
└── shared/ # アダプター + ユーティリティ
Server のレイヤー構成
| ディレクトリ | レイヤー | 責務 |
|---|---|---|
domains/ |
Presentation + Domain | エンドポイント・ビジネスロジック・データアクセス |
modules/ |
Application | 複数ドメイン間で共有するモジュール |
shared/ |
Infrastructure | アダプター・ユーティリティ・共通処理 |
依存関係のルール:
domains/ ──→ modules/ ──→ shared/
ドメイン内部のレイヤー構成(Server)
各ドメイン(Vertical Slice)内部では、Layered Architecture を採用しています。
| ディレクトリ | レイヤー | 責務 |
|---|---|---|
controller |
Presentation | HTTPリクエスト/レスポンス処理 |
presenters |
Presentation | レスポンスデータの整形 |
queries/commands |
Application | ユースケースの調整(CQRS) |
services |
Application | ビジネスロジック |
repositories |
Data Access | データベースアクセス(Prisma) |
contracts |
Shared | DTO/Input/Model定義 |
データフロー:
Controller → QueryHandler/CommandHandler → Service → Repository → PrismaAdapter
Client / Server 間のドメイン命名統一
Client と Server で同じドメイン名を使用しています。
| ドメイン | 用途 | 認証 |
|---|---|---|
article-list |
記事一覧・フィードの取得 | 不要 |
article |
記事詳細の取得・表示 | 不要 |
article-edit |
記事の作成・編集・削除 | 必要 |
profile |
ユーザープロフィール | 不要 |
user |
ユーザー認証・設定 | 必要 |
認証要件の違いにより、article(閲覧用)と article-edit(編集用)を意図的に分離しています。これにより、認証ロジックがドメイン内に閉じ、責務が明確になります。
メリット:
- 認知負荷の軽減: フロントエンドとバックエンドで同じ名前を使うことで、どのAPIがどの画面に対応するか一目瞭然
- ドメイン境界の明確化: 機能ごとに独立したモジュールとなり、責務が明確
デメリット:
- コードの重複: DDD を重視しドメイン境界を明確化するため、似たようなコード(DTO、モデル、リポジトリ等)が複数ドメインに存在することがある
アーキテクチャの設計判断
Vertical Slice Architecture + Layered Architecture
本プロジェクトは厳密な Clean Architecture ではなく、Vertical Slice + Layered Architecture を採用しています。
Clean Architecture との違い
| 観点 | Clean Architecture | 本プロジェクト(Layered) |
|---|---|---|
| 依存の方向 | 外→内(円形、DIP 適用) | 上→下(一方向) |
| Repository | Interface + Implementation | 具象クラスのみ |
| 依存性逆転(DIP) | 必須 | 不採用 |
| 認知負荷 | 高い(指数的増加) | 低い(線形/一定) |
DIP を採用しない理由
- Vertical Slice によるスライス独立: 各ドメインが完全に独立しており、変更の影響がスライス内に閉じる
-
NestJS の DI で十分なテスタビリティ: テスト時に Mock を
providersで差し替え可能 - Prisma へのロックインを許容: DB を変更するシナリオがほぼない
-
依存方向の縛りで十分:
Controller → Handler → Service → Repository → Prisma
認知負荷の観点
- Clean Architecture: Interface + Implementation のペアが機能追加ごとに増加 → 関係性の把握が困難(指数的増加)
- Vertical Slice + Layered: 独立したスライスが増えるだけ → 各スライスは同じパターン(線形増加、または一定)
具体例: Repository の抽象化
// Clean Architecture の場合(4ファイル必要)
// 1. domain/repositories/article.repository.interface.ts
export interface IArticleRepository {
findById(id: number): Promise<Article | null>;
}
// 2. infrastructure/repositories/article.repository.impl.ts
export class ArticleRepositoryImpl implements IArticleRepository {
constructor(private readonly prisma: PrismaClient) {}
async findById(id: number): Promise<Article | null> {
return await this.prisma.article.findUnique({ where: { id } });
}
}
// 3. さらに Module での Provider 登録が必要
providers: [{ provide: 'IArticleRepository', useClass: ArticleRepositoryImpl }];
// 4. 使用側では @Inject('IArticleRepository') が必要
// Vertical Slice + Layered の場合(1ファイルで完結)
// domains/article/repositories/article.repository.ts
@Injectable()
export class ArticleRepository {
constructor(private readonly prisma: PrismaAdapter) {}
async findById(id: number): Promise<Article | null> {
return await this.prisma.article.findUnique({ where: { id } });
}
}
機能が10個あれば、Clean Architecture では40ファイル以上、Vertical Slice では10ファイルで済みます。
Prisma ロックインを許容する理由
「Repository を抽象化すれば、将来 DB を変更できる」という主張がありますが、現実的には:
- DB を変更するシナリオがほぼない: PostgreSQL から別の DB に移行するプロジェクトは稀
- 抽象化のコストが高い: Interface + Implementation のペアを維持するコスト
- Prisma の進化が速い: Prisma 自体が活発に開発されており、長期的なサポートが期待できる
したがって、本プロジェクトでは Prisma への依存を許容し、抽象化のオーバーヘッドを削減しています。
不必要な型共有を避ける設計
Client と Server で型を共有しません。
// ❌ 型共有(密結合)
import { Article } from '@monorepo/shared-types';
// ✅ 各層で独自に定義(疎結合)
// Server: contracts/article.dto.ts
// Client: model/article.model.ts
理由:
- API の変更が Client に直接影響しない
- 変換ロジックを Facade / Presenter に集約
- フレームワークのアップグレードへの耐性
また、Zod を使わずフレームワーク標準機能を使用しています:
- Client: Reactive Forms Validators
- Server: class-validator
外部ライブラリへの依存を減らすことで、パッケージアップグレードへの耐性を強化しています。
状態管理(NGXS)
ドメイン構造
domains/{domain}/
├── api/
│ ├── {domain}.api.ts # API呼び出し(内部)
│ ├── {domain}.response.ts # APIレスポンス型(内部)
│ └── index.ts
├── model/
│ ├── {domain}.model.ts # State/UI用モデル(公開)
│ └── index.ts
├── store/
│ ├── {domain}.state.ts # State定義 + Selector(内部)
│ ├── {domain}.actions.ts # Action定義(内部)
│ └── index.ts
├── {domain}.facade.ts # API呼び出し + Store操作(公開)
└── index.ts # model と facade のみエクスポート
Facade パターンによる責務分離
一般的な「State 内で API を呼び出す」パターンではなく、Facade 内で API 呼び出しと Store 操作を結合します。
// ❌ 非推奨: State内でAPI呼び出し(密結合)
@Action(LoadArticleList)
loadArticleList(ctx) {
return this.api.getArticleList().pipe(
tap((res) => ctx.dispatch(new LoadArticleListSuccess(res))),
catchError((err) => ctx.dispatch(new LoadArticleListFailure(err))),
);
}
// ✅ 推奨: Facade内でAPI呼び出し + Store操作
loadArticleList(): void {
this.api.getArticleList().subscribe((response) => {
const articles: Article[] = response.articleList.map((r) => ({
id: r.id,
title: r.title,
createdAt: new Date(r.createdAt),
}));
this.store.dispatch(new SetArticleList(articles));
});
}
メリット:
- State は純粋なデータ保持のみ(get/set)
- API と Store の密結合を解消
- Action 名が対照的で分かりやすい(
getArticleList/setArticleList)
公開範囲の制限
index.ts では model と facade のみをエクスポートし、api と store は内部実装として隠蔽します。
// domains/article-list/index.ts
export * from './model'; // ✅ 公開
export * from './article-list.facade'; // ✅ 公開
// api/ と store/ はエクスポートしない
エラーハンドリングの共通化
loading / error 状態は各ドメインでは実装せず、共通処理で対応します。
// ❌ 非推奨: 各ドメインで loading/error を管理しない
interface ArticleListStateModel {
articleList: Article[];
loading: boolean; // 不要
error: string; // 不要
}
// ✅ データのみ保持
interface ArticleListStateModel {
articleList: Article[];
}
状態更新パターン
NGXS Store の状態更新では、ctx.setState() と patch オペレーターを使用します。
@Action(SetArticle)
setArticle(ctx: StateContext<ArticleEditStateModel>, action: SetArticle) {
ctx.setState(
patch({
articleForm: patch({
model: patch({
...action.article,
}),
}),
}),
);
}
Angular の共通化パターン
Facade パターン
Store への直接アクセスを禁止し、Facade 経由でのみ操作します。
// domains/article-list/article-list.facade.ts
@Injectable()
export class ArticleListFacade {
readonly articleList$ = this.store.select(ArticleListState.articleList);
loadArticleList(): void {
this.api.getArticleList().subscribe((response) => {
this.store.dispatch(new SetArticleList(response.articles));
});
}
}
また、横断的な処理を担う Facade を用意しています:
- SpinnerFacade: API 呼び出し時のローディング表示
- SnackbarFacade: 成功/エラーメッセージ表示
- ErrorFacade: エラーダイアログ表示
SpinnerFacade
API 呼び出し時は SpinnerFacade.withSpinner() を使用して Spinner を表示します。
this.api
.getArticle(articleId)
.pipe(this.spinnerFacade.withSpinner())
.subscribe((response) => {
// 処理
});
SnackbarFacade
保存成功時は SnackbarFacade.showSnackbar() でメッセージを表示します。
this.facade.updateArticle(articleId, request).subscribe(() => {
this.snackbarFacade.showSnackbar('保存しました', 'success');
});
Interceptor による横断的処理
- HTTP エラーの集約
- CSRF トークンの付与
- 認証トークンの付与
リアクティブパターンの使い分け
| スコープ | 技術 | 用途 | 例 |
|---|---|---|---|
| ローカル状態 | Signal | コンポーネント内部 |
signal(), computed()
|
| グローバル状態 | NGXS + async パイプ |
domains連携、大規模データ |
facade.data$ + async
|
| フォーム | Reactive Forms + form-plugin | バリデーション + Store同期 | ngxsForm |
| テンプレート | AsyncPipe(SSR対応) | Observable の描画 | (data$ | async) ?? defaultValue |
AsyncPipe vs RxLet の選択基準
| シナリオ | 推奨 | 理由 |
|---|---|---|
| SSR + ハイドレーション(ページ) | AsyncPipe | RxLet は SSR 環境で CLS が発生する可能性がある |
| CSRのみ(ダイアログ、モーダル内) | RxLet | ハイドレーション不要で、パフォーマンス向上の恩恵を受けられる |
注意: RxLet は SSR 環境で
ngSkipHydrationを自動的に追加するため、SSR でレンダリングされた HTML がクライアントで完全に再レンダリングされ、レイアウトシフト(CLS)が発生する可能性があります。
フォーム管理
技術構成
| 技術 | 役割 |
|---|---|
| Reactive Forms | バリデーション(required, minLength 等) |
| @ngxs/form-plugin | Store との自動同期 |
| InputFieldComponent | エラー表示の共通化 |
InputFieldComponent の使用
<app-input-field label="ユーザー名" [control]="form.controls.username">
<input appInput formControlName="username" />
</app-input-field>
フォーム管理パターン(親子コンポーネント連携)
-
親コンポーネント: Facade の呼び出し、
isFormInvalid$とisFormDirty$を Facade 経由で取得 -
子コンポーネント:
FormGroupを定義し、ngxsFormディレクティブで NGXS Store と連携 - 保存アクション: 親コンポーネント側で定義
<!-- 子コンポーネントテンプレート -->
<form [formGroup]="form" ngxsForm="articleEdit.articleForm">
<input formControlName="title" />
</form>
バリデーション戦略(トップダウンバリデーション)
トップダウンバリデーションを採用しています:
- バリデーション対象: ユーザー入力(フォーム)のみ
- バリデーション対象外: APIレスポンス、Storeのデータ
// ✅ 推奨: フォームでバリデーション
readonly form = this.fb.nonNullable.group({
title: ['', [Validators.required, Validators.minLength(1)]],
});
// ❌ 非推奨: APIレスポンスやStoreのデータにはバリデーションをかけない
UI アーキテクチャ
Primitives vs Composed
| 種類 | 配置先 | 責務 | パッケージ化 |
|---|---|---|---|
| Primitives | shared/ui/ |
スタイリングのみ、状態なし | 可能 |
| Composed | components/ |
ロジック連携、状態あり | このリポジトリ固有 |
Primitives(shared/ui/):
-
ButtonDirective- ボタンスタイル -
InputDirective- 入力フィールドスタイル - 外部パッケージ化可能、依存なし
Composed(components/):
-
InputFieldComponent- Reactive Forms 連携、エラー表示 - このリポジトリ固有のロジックを含む
CQRS(@nestjs/cqrs)
CQRS(Command Query Responsibility Segregation)パターンにより、読み取りと書き込みの責務を分離します。
Query(読み取り)
データの取得のみを行い、状態を変更しません。
// queries/get-articles/get-articles.query.ts
import { Query } from '@nestjs/cqrs';
import type { MultipleArticlesDto } from '../../contracts';
export class GetArticlesQuery extends Query<MultipleArticlesDto> {
constructor(
public readonly params: GetArticlesQueryDto,
public readonly currentUserId?: number,
) {
super();
}
}
// queries/get-articles/get-articles.handler.ts
@QueryHandler(GetArticlesQuery)
export class GetArticlesHandler implements IQueryHandler<GetArticlesQuery> {
constructor(private readonly articleListService: ArticleListService) {}
async execute(query: GetArticlesQuery) {
// 戻り値型を明示しないことで、Query<MultipleArticlesDto> の型が変われば自動で追従
return await this.articleListService.getArticles(query.params, query.currentUserId);
}
}
Command(書き込み)
状態を変更する操作を行います。
// commands/create-article/create-article.command.ts
import { Command } from '@nestjs/cqrs';
import type { SingleArticleDto } from '../../contracts';
export class CreateArticleCommand extends Command<SingleArticleDto> {
constructor(
public readonly request: CreateArticleRequestDto,
public readonly currentUserId: number,
) {
super();
}
}
// commands/create-article/create-article.handler.ts
@CommandHandler(CreateArticleCommand)
export class CreateArticleHandler implements ICommandHandler<CreateArticleCommand> {
constructor(private readonly articleService: ArticleEditService) {}
async execute(command: CreateArticleCommand) {
// 戻り値型を明示しないことで、Command<SingleArticleDto> の型が変われば自動で追従
const article = await this.articleService.createArticle(command.request, command.currentUserId);
return { article };
}
}
型推論(Type Inference)
Query<T> または Command<T> を継承することで、QueryBus.execute() や CommandBus.execute() の戻り値の型が自動的に推論されます。
// Controller での使用(型推論が自動的に効く)
@Controller('auth')
export class AuthController {
constructor(private readonly queryBus: QueryBus) {}
@Get('check-session')
async checkSession(): Promise<CheckSessionResponse> {
// user は自動的に UserInfo 型として推論される
const user = await this.queryBus.execute(new GetAuthUserInfoQuery(payload));
return { authenticated: true, user };
}
}
メリット:
- 型アサーション(
as)が不要 - 型安全性が向上
- IDE の自動補完が効く
- コンパイル時に型エラーを検出可能
- Handler の戻り値型を明示しないことで、Query の型が変われば自動で追従(DRY原則)
データベースアクセス(Prisma)
Prisma ESModule 対応
Prisma 7.x 以降では、@prisma/client は ESModule として提供されています。
// esbuild.config.mjs
const config = {
format: 'esm',
banner: {
js: "import 'reflect-metadata';",
},
};
PrismaAdapter
Prisma Client を NestJS のライフサイクルに統合したアダプターを使用します。
// shared/adapters/prisma/prisma.adapter.ts
import { PrismaClient } from '@monorepo/database/client';
@Injectable()
export class PrismaAdapter extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor() {
const connectionString = process.env.DATABASE_URL;
const adapter = new PrismaPg({ connectionString });
super({ adapter });
}
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
Repository パターン
各ドメインで Repository を定義し、データアクセスを抽象化します。
// domains/article-list/repositories/article-list.repository.ts
@Injectable()
export class ArticleListRepository {
constructor(private readonly prisma: PrismaAdapter) {}
async getArticleList(): Promise<Article[]> {
return await this.prisma.article.findMany({
orderBy: {
createdAt: 'asc',
},
});
}
}
バリデーション・変換
class-validator によるバリデーション
入力データのバリデーションには class-validator のデコレーターを使用します。
// contracts/article.input.ts
import { IsString, IsNotEmpty, MaxLength, IsOptional, IsArray } from 'class-validator';
export class CreateArticleInput {
@IsString()
@IsNotEmpty()
@MaxLength(100)
title: string;
@IsString()
@IsNotEmpty()
description: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
tagList?: string[];
}
class-transformer による変換
レスポンス DTO の変換には class-transformer を使用します。
// contracts/article.dto.ts
import { Expose, Transform, Type } from 'class-transformer';
export class ArticleDto {
@Expose()
id: number;
@Expose()
@Type(() => Date)
createdAt: Date;
@Expose()
@Transform(({ obj }) => obj.author?.username)
authorUsername: string;
}
ValidationPipe の設定
main.ts でグローバルに ValidationPipe を設定しています。
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // DTOに定義されていないプロパティを除外
forbidNonWhitelisted: true, // 未定義プロパティがあればエラー
transform: true, // class-transformerによる自動変換を有効化
}),
);
メリット:
- デコレーターベースで宣言的にバリデーションを定義
- OpenAPI 仕様(Swagger)との連携が容易
- 型安全なデータ変換
エラーハンドリング
エラーコードの共有(@monorepo/error-code)
エラーコードは @monorepo/error-code パッケージで一元管理しています。
// packages/error-code/src/error-code.ts
export const ERROR_CODE = {
ARTICLE_NOT_FOUND: 'ARTICLE_NOT_FOUND',
UNAUTHORIZED: 'UNAUTHORIZED',
VALIDATION_ERROR: 'VALIDATION_ERROR',
// ...
} as const;
テストで、Server のエラーコードと Client の翻訳ファイルの同期を検証しています。
Server 側のエラーハンドリング
HTTP ステータスコードに対応した AppError 派生クラスを使用します。
// shared/errors/app-error.ts
// Base class for all application errors
export abstract class AppError extends Error {
constructor(
public readonly code: ErrorCode,
public readonly params?: ErrorParams,
) {
super(code);
this.name = 'AppError';
}
}
// Use for 400 Bad Request errors
export class BadRequestError extends AppError { ... }
// Use for 401 Unauthorized errors
export class UnauthorizedError extends AppError { ... }
// Use for 403 Forbidden errors
export class ForbiddenError extends AppError { ... }
// Use for 404 Not Found errors
export class NotFoundError extends AppError { ... }
// Use for 409 Conflict errors
export class ConflictError extends AppError { ... }
// Use for 422 Unprocessable Entity errors (validation errors)
export class UnprocessableEntityError extends AppError { ... }
// Use for 500 Internal Server errors
export class InternalServerError extends AppError { ... }
// 使用例
import { ERROR_CODE } from '@monorepo/error-code';
import { NotFoundError, UnauthorizedError } from '$errors';
throw new UnauthorizedError(ERROR_CODE.UNAUTHORIZED);
throw new NotFoundError(ERROR_CODE.ARTICLE_NOT_FOUND);
HttpExceptionFilter
すべての例外をキャッチし、統一されたレスポンス形式に変換します。
通常のエラー:
{
"errorCode": "NOT_FOUND",
"message": "Article not found",
"params": { "id": "123" }
}
バリデーションエラー (422):
{
"errorCode": "VALIDATION_ERROR",
"message": "Validation Failed",
"errors": [
{
"field": "email",
"code": "INVALID_EMAIL"
}
]
}
本番環境(NODE_ENV=production)では、5xx 系エラーの詳細(errorCode や params)を隠蔽してセキュリティを確保しています。
Client 側のエラーハンドリング
- HttpInterceptor: HTTP エラーを集約
-
ClientErrorHandler: 401/403/404 エラーでページ遷移(
/error/401等) - ErrorFacade: ダイアログ表示
// HttpInterceptorでエラーをキャッチする実装例
export const httpErrorInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
catchError((e: HttpErrorResponse) => {
// 401/403/404はスキップ → ErrorHandlerで処理
if ([401, 403, 404].includes(e.status)) {
return throwError(() => e);
}
// その他のエラーはダイアログ表示
errorFacade.showError(apiError);
return throwError(() => e);
}),
);
};
// ErrorHandlerでページ遷移する実装例
handleError(error: unknown): void {
if (error instanceof HttpErrorResponse) {
const route = PAGE_NAVIGATE_ROUTES[error.status];
if (route) {
this.router.navigate([route], { skipLocationChange: true });
}
}
}
API ドキュメント
OpenAPI / Redoc
NestJS の @nestjs/swagger を使用して OpenAPI 仕様を自動生成し、Redoc UI で公開しています。
| エンドポイント | 内容 |
|---|---|
/api/docs |
Redoc UI(インタラクティブドキュメント) |
/api/docs.json |
OpenAPI JSON 仕様 |
公開URL:
- Redoc UI: https://api.realworld.motora-dev.com/api/docs
- OpenAPI JSON: https://api.realworld.motora-dev.com/api/docs.json
// main.ts
const swaggerConfig = new DocumentBuilder()
.setTitle('RealWorld API')
.setDescription('Conduit API specification - RealWorld example app')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
// Redoc UI を提供
app.use('/api/docs', (_req, res) => {
res.send(`
<redoc spec-url='/api/docs.json'></redoc>
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
`);
});
class-validator のデコレーターは自動的に OpenAPI スキーマに反映されるため、ドキュメントと実装が常に同期します。
認証フロー
OAuth + JWT
Google OAuth による認証を採用しています。
| トークン | 有効期限 | 用途 | Cookie パス |
|---|---|---|---|
access-token |
短寿命 | API 認証(各リクエストで送信) | / |
refresh-token |
長寿命 | Access Token の更新 | /api/auth |
認証フロー概要
Pending Registration パターン
新規ユーザーの場合、OAuth 認証後に直接アカウントを作成せず、一時的な pending-registration トークンを発行します。
メリット:
- ユーザーが自分のユーザー名を選択できる
- OAuth プロバイダーから取得したメールアドレスを確認画面で表示できる
- 誤ったアカウント作成を防止
check-session エンドポイント
認証状態を確認するエンドポイントは 401 を返さない設計です。
// GET /api/auth/check-session
// 常に 200 OK を返し、authenticated: true/false で状態を通知
async checkSession(): Promise<{ authenticated: boolean; user?: User }> {
// 1. access-token が有効 → authenticated: true
// 2. refresh-token で更新可能 → authenticated: true
// 3. いずれも無効 → authenticated: false
}
これにより、ブラウザコンソールに 401 エラーが表示されることを回避しています。
CSRF 対策
Double Submit Cookie パターンを採用しています(csrf-csrf ライブラリ使用)。
// main.ts
import { doubleCsrf } from 'csrf-csrf';
const { doubleCsrfProtection, generateCsrfToken } = doubleCsrf({
getSecret: () => csrfSecret,
getSessionIdentifier: (req) => req.cookies?.['csrf-session-id'] || '',
cookieName: 'XSRF-TOKEN',
cookieOptions: {
httpOnly: false, // JavaScriptから読み取り可能
sameSite: 'lax',
secure: isProd,
path: '/',
},
ignoredMethods: ['GET', 'HEAD', 'OPTIONS'],
getCsrfTokenFromRequest: (req) => req.headers['x-xsrf-token'],
});
セッション識別(express-session不要)
csrf-session-id クッキーを使用してセッションを識別します。これにより express-session なしで CSRF 保護を実現しています。
// csrf-session-id がない場合は自動生成
app.use((req, res, next) => {
if (!req.cookies?.['csrf-session-id']) {
const csrfSessionId = randomUUID();
res.cookie('csrf-session-id', csrfSessionId, {
httpOnly: true,
sameSite: 'lax',
secure: isProd,
path: '/',
maxAge: 1000 * 60 * 60 * 24 * 365, // 1 year
});
}
next();
});
サブドメイン間での Cookie 共有
API サーバーとクライアントが異なるサブドメインで動作する場合、COOKIE_DOMAIN 環境変数を設定することで Cookie を共有できます。
注意書きに記載の通り、この実装は適切でない可能性があります。本番環境への導入時は十分な検証をお願いします。
Cookie Consent(Google Consent Mode v2)
Consent Mode v2 の仕組み
Google Consent Mode v2 では、ユーザーの同意状態に応じて Google Analytics や広告タグの動作を制御します。
| 状態 | 動作 |
|---|---|
denied |
Cookie を使用せず、匿名データのみ収集 |
granted |
Cookie を使用した完全なトラッキング |
デフォルト設定
ページ読み込み時はデフォルトで denied に設定し、ユーザーの同意を待ちます。
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
wait_for_update: 500,
});
</script>
同意後の更新
ユーザーが同意した後、ConsentService で状態を更新します。
@Injectable({ providedIn: 'root' })
export class ConsentService {
grantConsent(): void {
gtag('consent', 'update', {
ad_storage: 'granted',
ad_user_data: 'granted',
ad_personalization: 'granted',
analytics_storage: 'granted',
});
localStorage.setItem('cookie_consent', 'granted');
}
}
国際化(i18n)
なぜ @angular/localize か
| 比較項目 | @angular/localize | ngx-translate |
|---|---|---|
| 翻訳タイミング | ビルド時(コンパイル時) | ランタイム |
| バンドルサイズ | 言語ごとに最適化 | 全言語分のローダー含む |
| SSR パフォーマンス | ✅ 翻訳済み HTML を即座に返却 | ❌ クライアントで翻訳ロード |
| Layout Shift | ✅ なし | ⚠️ 翻訳ロード時に発生しうる |
SSR 環境でのパフォーマンス最適化のため @angular/localize を採用しています。
翻訳ソースファイルの構成
翻訳は JSON ファイルで管理し、ビルド時に XLIFF 形式に変換されます。
apps/client/
├── public/i18n/ # 翻訳ソース(手動編集)
│ ├── error/
│ │ ├── en.json
│ │ └── ja.json
│ └── ui/
│ ├── en.json
│ └── ja.json
└── locale/ # 生成されるXLIFF(.gitignore対象)
├── messages.xlf # ng extract-i18n で生成
└── messages.ja.xlf # merge-translations.ts で生成
テンプレートでの使用方法
<!-- i18n 属性(推奨) -->
<h1 i18n="@@home.title">Welcome to Conduit</h1>
<button i18n="@@common.save">Save</button>
// $localize タグ(TypeScript内)
const message = $localize`:@@error.unexpectedError:An unexpected error occurred`;
SSR でのロケール選択
Accept-Language ヘッダーに基づいて適切なロケールビルドを配信します。
// apps/client/src/server.ts
import acceptLanguage from 'accept-language';
acceptLanguage.languages(['en', 'ja']);
app.use((req, res, next) => {
const lang = acceptLanguage.get(req.headers['accept-language']) || 'en';
// lang に応じたビルド済み HTML を配信
});
結果: ユーザーのブラウザ言語設定に応じて、翻訳済みの HTML が即座に返却され、Layout Shift が発生しません。
ISR(Incremental Static Regeneration)
ISR の仕組み
ISR は、静的に生成されたページを増分的に再生成する仕組みです。@rx-angular/isr を使用しています。
// app.routes.ts
export const routes: Routes = [
{ path: '', component: HomeComponent, data: { revalidate: 60 } }, // 60秒
{ path: 'article/:id', component: ArticleComponent, data: { revalidate: 300 } }, // 5分
];
リクエスト時にキャッシュされた HTML を返しつつ、バックグラウンドで最新版を生成します。
キャッシュ無効化(未実装)
記事の更新や削除時に、IsrService を使用してキャッシュを手動で無効化できます(未実装)。
@Injectable({ providedIn: 'root' })
export class IsrService {
invalidateCache(urlsToInvalidate: string[]): Observable<void> {
return this.http.post<void>('/api/invalidate-cache', {
secret: environment.isrSecret,
urlsToInvalidate,
});
}
invalidateArticle(articleId: string): Observable<void> {
return this.invalidateCache([`/article/${articleId}`]);
}
}
SEO 対応
SeoService
SeoService は、Open Graph や Twitter Card などの meta タグを動的に設定します。
@Injectable({ providedIn: 'root' })
export class SeoService {
setArticleMeta(article: Article): void {
this.meta.updateTag({ property: 'og:title', content: article.title });
this.meta.updateTag({ property: 'og:description', content: article.description });
this.meta.updateTag({ property: 'og:image', content: article.ogImageUrl });
this.meta.updateTag({ property: 'og:type', content: 'article' });
this.meta.updateTag({ name: 'twitter:card', content: 'summary_large_image' });
}
}
動的 OG 画像生成(未実装)
OG 画像は NestJS API で動的に生成し、キャッシュされます(未実装)。
const ogImageUrl = `${environment.apiUrl}/api/og-image/article/${articleId}`;
Sitemap 動的生成
Sitemap は SSR サーバーで動的に生成されます。
Lighthouse 100点達成のために、SEO 対応を実装しています。
パフォーマンス
Lighthouse 全カテゴリ 100点
以下の最適化により、Lighthouse 全カテゴリで 100点を達成しています:
-
Zoneless 変更検知:
provideZonelessChangeDetection()により Zone.js を使用せず、効率的な変更検知を実現 - SSR + ISR: 高速な初期表示と SEO 最適化
- ビルド時 i18n: 翻訳済み HTML を即座に返却し、Layout Shift を防止
- SEO 対応: Sitemap 自動生成、meta タグの動的設定
ローカル開発環境のセットアップ
前提条件
-
Node.js / pnpm: Volta を利用して自動インストール(
package.jsonのvoltaフィールドで管理) - PostgreSQL: 16.x(Supabase、Prisma Postgres などの DBaaS 推奨)
本プロジェクトでは Volta を使用しているため、リポジトリをクローンすると Node.js と pnpm のバージョンが自動的に設定されます。
セットアップ手順
# 1. リポジトリのクローン
git clone https://github.com/motora-dev/angular-nestjs-realworld-example-app.git
cd angular-nestjs-realworld-example-app
# 2. 依存関係のインストール(Volta により Node.js / pnpm が自動設定)
pnpm install
# 3. 環境変数の設定
cp apps/server/.env.example apps/server/.env
# .env ファイルを編集して DATABASE_URL などを設定
# 4. データベースのセットアップ
cd packages/database
pnpm prisma migrate dev
cd ../..
# 5. 開発サーバーの起動
pnpm start
環境変数の設定例
各アプリケーション・パッケージごとに .env を設定します。
packages/database/.env
# Prisma 接続 URL(DBaaS の接続文字列を設定)
DATABASE_URL="postgresql://user:password@host:5432/realworld?sslmode=require"
DIRECT_URL="postgresql://user:password@host:5432/realworld?sslmode=require"
apps/server/.env
# Database(packages/database と同じ値)
DATABASE_URL="postgresql://user:password@host:5432/realworld?sslmode=require"
# Server
NODE_ENV=development
PORT=4000
# Client
CLIENT_URL=http://localhost:4200
# CORS / CSRF
CORS_ORIGINS=http://localhost:4200
CSRF_SECRET=your-local-csrf-secret
# JWT(RSA キーペア)
JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
# Google OAuth(ローカル開発用)
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GOOGLE_CALLBACK_URL=http://localhost:4000/api/auth/callback
apps/client/.env(SSR サーバー用)
# Server
NODE_ENV=development
PORT=4200
# Basic Auth(開発環境では不要)
BASIC_AUTH_ENABLED=false
# ISR キャッシュ無効化トークン
ISR_SECRET=your-isr-secret
テスト戦略
Vitest + Testing Library
Jest から Vitest に移行しました。
移行理由:
- ESModule のネイティブ対応
- 高速な起動
- SWC によるトランスパイル
@testing-library/angular でコンポーネントテストを実装しています。
SWC による高速トランスパイル
// From vitest.config.ts
const swcPlugin = swc.vite({
jsc: {
parser: { syntax: 'typescript', decorators: true },
transform: {
legacyDecorator: true,
decoratorMetadata: true,
},
},
});
Unit / E2E の分離
| テスト種別 | ファイルパターン | 配置場所 |
|---|---|---|
| Unit テスト | *.test.ts |
対象ファイルと同階層 |
| E2E テスト | *.spec.ts |
test/ ディレクトリ配下 |
src/domains/article-list/
├── article-list.facade.ts
├── article-list.facade.test.ts # 対象と同階層
テストのベストプラクティス
expect の期待値はリテラル(ベタ書き)で記述
// ✅ Good - 期待値がすぐにわかる
expect(error.name).toBe('BadRequestError');
expect(result.count).toBe(5);
// ❌ Bad - 期待値を追跡する必要がある
const expectedName = 'BadRequestError';
expect(error.name).toBe(expectedName);
エラーコード同期テスト
Server のエラーコードと Client の翻訳ファイルの同期を自動検証しています。
it('should have Japanese translations for all error codes', () => {
const missingCodes = errorCodes.filter((code) => !jaErrors[code]);
expect(missingCodes).toEqual([]);
});
it('should have English translations for all error codes', () => {
const missingCodes = errorCodes.filter((code) => !enErrors[code]);
expect(missingCodes).toEqual([]);
});
it('should not have unused translations', () => {
const unusedKeys = Object.keys(jaErrors).filter((key) => !errorCodes.includes(key));
expect(unusedKeys).toEqual([]);
});
ビルドシステム(esbuild + SWC)
なぜ esbuild を使うのか
従来の NestJS プロジェクトでは tsc(TypeScript コンパイラ)を使用していましたが、以下の問題がありました:
- ビルド速度が遅い - プロジェクトが大きくなるとビルドに数十秒かかる
-
ESModule 環境でのパス解決問題 -
$domains/*などのパスエイリアスが解決できない - Prisma クライアントの問題 - Prisma 7.x 以降の ESModule 形式クライアントを正しく処理できない
esbuild を使用することで、これらすべての問題を解決しています。
SWC プラグインによるデコレーターサポート
NestJS は TypeScript のデコレーターを多用しますが、esbuild 単体ではデコレーターメタデータ(emitDecoratorMetadata)をサポートしていません。
// esbuild.config.mjs
function swcPlugin() {
return {
name: 'swc-decorator',
setup(build) {
build.onLoad({ filter: /\.ts$/ }, async (args) => {
const source = await fs.promises.readFile(args.path, 'utf8');
const result = await swc.transform(source, {
jsc: {
parser: { syntax: 'typescript', decorators: true },
transform: {
legacyDecorator: true,
decoratorMetadata: true,
},
},
});
return { contents: result.code, loader: 'js' };
});
},
};
}
ホットリロード対応の開発サーバー
--watch フラグを付けてビルドすると、ファイル変更を検知して自動的にリビルド&サーバー再起動が行われます。
CI/CD パイプライン
GitHub Actions
| ワークフロー | トリガー | 内容 |
|---|---|---|
| Check | PR・Push | Format, Lint, Build, 型チェック |
| Test | PR・Push | 変更検知付きテスト + カバレッジ |
tj-actions/changed-files による変更検知により、該当パッケージに変更があった場合のみテストを実行します。
Test ワークフロー詳細
| ジョブ | 対象 | 内容 |
|---|---|---|
test-coverage-packages |
packages/ |
Unit テスト + カバレッジ |
test-coverage-client |
apps/client |
Unit テスト + カバレッジ |
test-e2e-client |
apps/client |
E2E テスト(翻訳同期等) |
test-coverage-server |
apps/server |
Unit テスト + カバレッジ |
test-e2e-server |
apps/server |
E2E テスト |
デプロイ
| 環境 | ブランチ | 用途 |
|---|---|---|
| develop | develop |
開発環境 |
| preview | PR → develop
|
PR プレビュー環境 |
| main | main |
本番環境 |
Workload Identity Federation によるキーレス認証で、サービスアカウントキーを使わずに GCP Cloud Run へ自動デプロイしています。
開発体験の向上
ビルド・テストの劇的な高速化
| 項目 | 従来(tsc + Jest) | 現在(esbuild + Vitest) | 改善率 |
|---|---|---|---|
| ビルド | 20〜30 秒 | 200〜500 ミリ秒 | 約 50〜100 倍 |
| テスト起動 | 5〜10 秒 | 500 ミリ秒〜1 秒 | 約 10〜20 倍 |
| ホットリロード | 非対応 | 対応 | - |
esbuild + SWC により、NestJS のビルドが劇的に高速化しました。
pnpm catalog + Turborepo
- pnpm catalog: 依存関係の一元管理
- Turborepo: タスクのキャッシュ活用
開発時の Tips・よくある落とし穴
ESModule 環境での import の注意点
本プロジェクトは "type": "module" を指定しているため、すべてのファイルが ESModule として扱われます。
// ❌ NG: CommonJS スタイルの require
const { PrismaClient } = require('@prisma/client');
841;
// ✅ OK: ESModule スタイルの import
import { PrismaClient } from '@prisma/client';
また、相対パスでのインポート時に .js 拡張子が必要な場合があります(ただし、esbuild がバンドルするため通常は不要)。
Prisma マイグレーション時の注意
開発中にスキーマを変更した場合、以下の順序で操作します:
# 1. スキーマ変更後、DB に反映
pnpm prisma migrate dev
# 2. Prisma Client を再生成
pnpm prisma generate
# 3. サーバーを再起動(ホットリロードが効かない場合)
# Ctrl+C で停止後、pnpm start
よくあるエラー:
-
PrismaClientInitializationError: DB 接続エラー →DATABASE_URLを確認 -
PrismaClientKnownRequestError: スキーマと DB の不一致 →prisma db pushを実行
Angular SSR でのハイドレーション注意点
SSR 環境では、サーバーとクライアントで同じ HTML を生成する必要があります。
// ❌ NG: ブラウザ API を直接使用
constructor() {
this.width = window.innerWidth; // SSR でエラー
}
// ✅ OK: isPlatformBrowser でガード
constructor(@Inject(PLATFORM_ID) private platformId: Object) {
if (isPlatformBrowser(this.platformId)) {
this.width = window.innerWidth;
}
}
また、RxLet は SSR 環境で ngSkipHydration を自動追加するため、ハイドレーション対象ページでは AsyncPipe を推奨します。
AI を用いた開発への適合
ソフトウェア開発における「品質」の三分類
ソフトウェア開発における「品質」は、以下の三つに分類できると考えています:
- プロジェクト品質: 納期、コスト、スコープ管理
- プロダクト品質: ユーザー体験、機能要件の充足
- ソースコード品質: 可読性、保守性、拡張性
AI の限界
本リポジトリを作成中に感じたことは、以下の点です。
アーキテクチャに拘ることで「ソースコード品質」は向上するが、独自のアーキテクチャパターンは AI の学習データにほぼ存在しない。そのため、サンプルを用意して計画を立てて実装を依頼しても、一発で求めるレベルのコードを生成してくれることはなかなかない。
AI(Cursor、GitHub Copilot 等)は一般的なパターンに強いですが、プロジェクト固有のアーキテクチャルールを完璧に守ることは難しいのが現状です。
人間によるレビュー・手修正を挟みながら開発
AI が生成したコードをそのまま使うのではなく、人間によるレビューと手修正を挟みながら開発することが前提となります。
本プロジェクトのアーキテクチャは、この運用に以下の点で適合しています:
- 一貫したディレクトリ構成とパターン: AI が生成したコードがアーキテクチャに沿っているか、レビューしやすい
- README にアーキテクチャルールを明記: AI がコンテキストとして参照でき、生成精度が向上
- 各レイヤーの責務が明確: AI への指示が簡潔になり、生成結果がブレにくい
- Vertical Slice により影響範囲が限定: AI の生成コードが他のドメインに影響しないため、レビュー範囲が明確
- 段階的な修正との相性: 一発で完璧を求めず、少しずつ修正を繰り返す運用がしやすい
ポイント:
- AI に任せっきりにはしない
- 生成されたコードは必ず人間がレビュー
- アーキテクチャに沿った修正を繰り返す
- 修正内容を次回の生成に活かす(学習ループ)
まとめ
本記事では、Angular + NestJS で RealWorld を実装する中で得られた知見を共有しました。
主なポイント:
- Vertical Slice + Layered Architecture: 認知負荷を抑えつつ、保守性を確保
- モノレポ戦略: pnpm catalog + Turborepo による一元管理
- CQRS + Facade パターン: 責務を明確に分離
- AI 開発への適合: 一貫したパターンにより人間によるレビュー・修正がしやすい
Angular + NestJS の組み合わせは、フレームワークが「こう書くべき」を提示してくれる点で、チーム開発やエンタープライズ向けに適していると感じています。
興味のある方は、ぜひリポジトリとホスティングサイトをご覧ください。
リポジトリ: angular-nestjs-realworld-example-app
ホスティングサイト: realworld.motora-dev.com
この記事が、Angular + NestJS でのアーキテクチャ設計の参考になれば幸いです。

