0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【4万字・Lighthouse満点】Angular + NestJS 堅牢アーキテクチャの全貌 ─ AI時代に備える「開発の型」と「協働戦略」

Posted at

注意書き

本記事で紹介するリポジトリは、アーキテクチャの実践例として公開しています。以下の点にご注意ください。

  • 本記事は 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

開発・運用

おわりに


技術スタック概要

System Architecture

本プロジェクトは 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 には以下の利点があります:

  1. ディレクトリ構成による分割が容易: クラス単位でファイルを分割でき、依存関係が明確
  2. TypeScript との高い親和性: クラス、デコレーター、インターフェースなど TypeScript の機能をフル活用
  3. テストの容易さ: 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 を採用しない理由

  1. Vertical Slice によるスライス独立: 各ドメインが完全に独立しており、変更の影響がスライス内に閉じる
  2. NestJS の DI で十分なテスタビリティ: テスト時に Mock を providers で差し替え可能
  3. Prisma へのロックインを許容: DB を変更するシナリオがほぼない
  4. 依存方向の縛りで十分: 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 を変更できる」という主張がありますが、現実的には:

  1. DB を変更するシナリオがほぼない: PostgreSQL から別の DB に移行するプロジェクトは稀
  2. 抽象化のコストが高い: Interface + Implementation のペアを維持するコスト
  3. 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 のみをエクスポートし、apistore は内部実装として隠蔽します。

// 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 系エラーの詳細(errorCodeparams)を隠蔽してセキュリティを確保しています。

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:

// 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 Performance Report

以下の最適化により、Lighthouse 全カテゴリで 100点を達成しています:

  • Zoneless 変更検知: provideZonelessChangeDetection() により Zone.js を使用せず、効率的な変更検知を実現
  • SSR + ISR: 高速な初期表示と SEO 最適化
  • ビルド時 i18n: 翻訳済み HTML を即座に返却し、Layout Shift を防止
  • SEO 対応: Sitemap 自動生成、meta タグの動的設定

ローカル開発環境のセットアップ

前提条件

  • Node.js / pnpm: Volta を利用して自動インストール(package.jsonvolta フィールドで管理)
  • 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 コンパイラ)を使用していましたが、以下の問題がありました:

  1. ビルド速度が遅い - プロジェクトが大きくなるとビルドに数十秒かかる
  2. ESModule 環境でのパス解決問題 - $domains/* などのパスエイリアスが解決できない
  3. 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 を用いた開発への適合

ソフトウェア開発における「品質」の三分類

ソフトウェア開発における「品質」は、以下の三つに分類できると考えています:

  1. プロジェクト品質: 納期、コスト、スコープ管理
  2. プロダクト品質: ユーザー体験、機能要件の充足
  3. ソースコード品質: 可読性、保守性、拡張性

AI の限界

本リポジトリを作成中に感じたことは、以下の点です。

アーキテクチャに拘ることで「ソースコード品質」は向上するが、独自のアーキテクチャパターンは AI の学習データにほぼ存在しない。そのため、サンプルを用意して計画を立てて実装を依頼しても、一発で求めるレベルのコードを生成してくれることはなかなかない。

AI(Cursor、GitHub Copilot 等)は一般的なパターンに強いですが、プロジェクト固有のアーキテクチャルールを完璧に守ることは難しいのが現状です。

人間によるレビュー・手修正を挟みながら開発

AI が生成したコードをそのまま使うのではなく、人間によるレビューと手修正を挟みながら開発することが前提となります。

本プロジェクトのアーキテクチャは、この運用に以下の点で適合しています:

  1. 一貫したディレクトリ構成とパターン: AI が生成したコードがアーキテクチャに沿っているか、レビューしやすい
  2. README にアーキテクチャルールを明記: AI がコンテキストとして参照でき、生成精度が向上
  3. 各レイヤーの責務が明確: AI への指示が簡潔になり、生成結果がブレにくい
  4. Vertical Slice により影響範囲が限定: AI の生成コードが他のドメインに影響しないため、レビュー範囲が明確
  5. 段階的な修正との相性: 一発で完璧を求めず、少しずつ修正を繰り返す運用がしやすい

ポイント:

  • 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 でのアーキテクチャ設計の参考になれば幸いです。

0
1
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
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?