DEV Community

Kaziu
Kaziu

Posted on

NestJSの基本アーキテクチャと実プロジェクトでの活用事例

※ 本記事に掲載しているコード例は実際のプロダクトをベースにしていますが、プロジェクト名・サービス名・変数名など、特定のプロダクトを識別できる情報は変更・汎用化して記載しています。

(0から全部自分で書いてなく、AI生成したのを修正しプロジェクトの辞書の様に使用する目的)

NestJSはTypeScriptで構築されたサーバーサイドフレームワークで、Angularに影響を受けたモジュール指向のアーキテクチャが特徴です。本記事ではNestJSの基本概念を解説しつつ、実際のSaaSプロダクト(GraphQL API / マルチテナント構成)でどのように活用しているかを紹介します。


目次

  1. Module(モジュール)
  2. Controller / Resolver
  3. Service / UseCase
  4. DI(依存性注入)
  5. Guard(認証・認可)
  6. Entity定義の多層構造
  7. Prisma連携(Repository + Mapper)
  8. 認証コンテキストの流れ
  9. まとめ:NestJS標準 vs 実プロジェクト

1. Module(モジュール)

NestJSの基本

NestJSのアプリケーションは Module を単位として構成されます。@Module() デコレータで providers(提供するサービス)、imports(依存するモジュール)、exports(外部に公開するサービス)を定義し、DIコンテナを構成します。

@Module({
  providers: [CatService],
  imports: [DatabaseModule],
  exports: [CatService],
})
export class CatModule {}
Enter fullscreen mode Exit fullscreen mode

実プロジェクトでの活用

本プロジェクトでは、レイヤードアーキテクチャに沿って3層のモジュールに分離しています。

app.module.ts
  ├─ ResolverModule群(interface/di/)  ← プレゼンテーション層
  ├─ UseCaseModule群(app/di/)         ← アプリケーション層
  └─ RepositoryModule群(infra/prisma/di/) ← インフラ層
Enter fullscreen mode Exit fullscreen mode

ディレクトリ構成は以下のようになっています。

packages/api/src/
├── app/                      # Application層
│   ├── di/                   # UseCaseモジュール定義
│   └── useCases/             # UseCase実装
├── domain/                   # Domain層
│   ├── entities/             # ドメインエンティティ・型定義
│   ├── restrictFilters/      # 権限フィルター
│   └── common/               # 共通型・ユーティリティ
├── interface/                # Interface層
│   ├── di/                   # Resolverモジュール定義
│   ├── resolvers/            # GraphQL Resolver
│   ├── schema/               # リクエスト/レスポンス型
│   └── auth/                 # 認証Guard
├── infra/                    # Infrastructure層
│   ├── prisma/               # Repository・Mapper
│   └── ...                   # 外部サービス連携
└── entities/                 # GraphQL ObjectType定義
Enter fullscreen mode Exit fullscreen mode

ResolverModuleは、UseCaseModuleを imports して依存を解決します。

@Module({
  providers: [CommentResolver],
  imports: [
    CommentUseCaseModule,
    UserUseCaseModule,
  ],
})
export class CommentResolverModule {}
Enter fullscreen mode Exit fullscreen mode

UseCaseModuleは、RepositoryModuleや他のServiceModuleを imports します。

@Module({
  providers: [CommentUseCase],
  exports: [CommentUseCase],
  imports: [
    PrismaCommentRepositoryModule,
    PrismaRoomRepositoryModule,
    NotificationServiceModule,
  ],
})
export class CommentUseCaseModule {}
Enter fullscreen mode Exit fullscreen mode

ポイント: NestJSのModule機構を活用して、クリーンアーキテクチャ的な依存の方向(interface → app → domain ← infra)を実現しています。


2. Controller / Resolver

NestJSの基本

REST APIの場合は @Controller()@Get() / @Post() などのデコレータを使います。

@Controller('cats')
export class CatController {
  @Get()
  findAll() { return 'All cats'; }

  @Post()
  create(@Body() dto: CreateCatDto) { ... }
}
Enter fullscreen mode Exit fullscreen mode

GraphQLの場合は @Resolver()@Query() / @Mutation() / @ResolveField() を使います。

実プロジェクトでの活用

本プロジェクトではGraphQLを採用し、Code-Firstアプローチでスキーマを自動生成しています。

// app.module.ts
GraphQLModule.forRoot<ApolloDriverConfig>({
  driver: ApolloDriver,
  autoSchemaFile: join(process.cwd(), 'src/schema.gql'), // TypeScriptから自動生成
  fieldResolverEnhancers: ['guards'],
}),
Enter fullscreen mode Exit fullscreen mode

Resolverの実装例:

@Resolver(() => GqlComment)
export class CommentResolver {
  constructor(private readonly commentUseCase: CommentUseCase) {}

  // Query: データ取得
  @UseGuards(AuthGuard)
  @Query(() => [GqlComment])
  async commentList(
    @Args('input') input: GetCommentListInput,
    @Context() context: OperationContext,
  ) {
    return this.commentUseCase.getCommentList(input, context);
  }

  // Mutation: データ変更
  @UseGuards(AuthGuard)
  @Mutation(() => GqlComment)
  async createComment(
    @Args('input') input: CreateCommentInput,
    @Context() context: OperationContext,
  ) {
    return this.commentUseCase.createComment(input, context);
  }

  // ResolveField: ネストされたフィールドの解決
  @ResolveField('author', () => GqlUser)
  async getAuthor(@Parent() comment: Comment) {
    return this.userUseCase.findOne({ id: comment.userId });
  }
}
Enter fullscreen mode Exit fullscreen mode

主なデコレータ

デコレータ 用途
@Resolver(() => Type) クラスをResolver宣言
@Query(() => Type) GraphQL Query定義
@Mutation(() => Type) GraphQL Mutation定義
@ResolveField('name', () => Type) ネストフィールドの解決
@Args('input') 入力引数のバインド
@Parent() 親オブジェクトへのアクセス
@Context() GraphQL Context(認証情報等)の取得

InputType(リクエスト型)

GraphQLの入力型もデコレータで定義します。

@InputType()
export class CreateCommentInput {
  @Field(() => GraphQLJSON)
  content: Prisma.JsonObject;

  @Field()
  roomId: string;

  @Field(() => [Int], { nullable: true })
  imageIds?: number[];
}
Enter fullscreen mode Exit fullscreen mode

これにより、以下のGraphQLスキーマが自動生成されます。

input CreateCommentInput {
  content: JSON!
  roomId: ID!
  imageIds: [Int!]
}
Enter fullscreen mode Exit fullscreen mode

3. Service / UseCase

NestJSの基本

@Injectable() デコレータを付けたクラスをServiceとして使い、ビジネスロジックを集約します。

@Injectable()
export class CatService {
  constructor(private readonly catRepository: CatRepository) {}

  findAll() {
    return this.catRepository.findAll();
  }
}
Enter fullscreen mode Exit fullscreen mode

実プロジェクトでの活用

本プロジェクトでは「Service」ではなく 「UseCase」 と命名し、ユースケース単位でクラスを分割しています。

@Injectable()
export class CommentUseCase {
  constructor(
    private readonly commentRepository: PrismaCommentRepository,
    private readonly roomRepository: PrismaRoomRepository,
    private readonly notificationService: NotificationService,
  ) {}

  async createComment(input, context: OperationContext) {
    // 1. ドメイン検証
    const room = CoreAssert.isNotEmpty(
      await this.roomRepository.findOneById({ id: input.roomId }, context),
    );

    // 2. ドメインエンティティ作成
    const comment = createCommentSeed({
      ...input,
      userId: context.executor.userId,
    });

    // 3. DB保存
    const created = await this.commentRepository.create(comment);

    // 4. 通知・外部連携
    await this.notificationService.send({ ... });

    return created;
  }
}
Enter fullscreen mode Exit fullscreen mode

ポイント: UseCase内でドメイン検証 → DB保存 → 副作用(通知・外部連携)の順で処理を組み立てています。Repositoryや外部サービスは全てコンストラクタインジェクションで注入されるため、テスタビリティが高いです。


4. DI(依存性注入)

NestJSの基本

NestJSのDIは、TypeScriptの型情報からプロバイダーを自動解決します。@Injectable() クラスをModuleの providers に登録し、コンストラクタの型で自動注入されます。

実プロジェクトでの活用

層をまたぐ依存は imports / exports で制御しています。

Resolver → constructor(useCase: CommentUseCase)     // 型で自動解決
UseCase  → constructor(repo: PrismaCommentRepository) // 型で自動解決
Enter fullscreen mode Exit fullscreen mode

Module定義側:

// UseCaseModule: CommentUseCaseを外部に公開
@Module({
  providers: [CommentUseCase],
  exports: [CommentUseCase],          // 他のモジュールから利用可能に
  imports: [PrismaCommentRepositoryModule],
})
export class CommentUseCaseModule {}

// ResolverModule: UseCaseModuleをimportして利用
@Module({
  providers: [CommentResolver],
  imports: [CommentUseCaseModule],    // CommentUseCaseが利用可能に
})
export class CommentResolverModule {}
Enter fullscreen mode Exit fullscreen mode

循環依存が発生する場合は forwardRef() で回避します。

@Module({
  imports: [forwardRef(() => RelatedModule)],
})
export class SomeModule {}
Enter fullscreen mode Exit fullscreen mode

5. Guard(認証・認可)

NestJSの基本

CanActivate インターフェースを実装したGuardクラスを作成し、@UseGuards() デコレータで適用します。

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    return validateToken(request.headers.authorization);
  }
}
Enter fullscreen mode Exit fullscreen mode

実プロジェクトでの活用

本プロジェクトはマルチユーザー型SaaSのため、ユーザータイプ(管理者 / ゲスト)に応じて異なる認証Guardを振り分けるパターンを採用しています。

@Injectable()
export class MultiUserAuthGuard implements CanActivate {
  constructor(
    private readonly adminAuthGuard: AdminAuthGuard,
    private readonly guestAuthGuard: GuestAuthGuard,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const ctx = GqlExecutionContext.create(context);
    const request = ctx.getContext().req;
    const userType = request.headers['x-user-type'];

    if (userType === 'Admin') {
      // JWT検証(外部認証サービス)
      return this.adminAuthGuard.canActivate(context);
    } else if (userType === 'Guest') {
      // セッショントークン検証
      return this.guestAuthGuard.canActivate(context);
    }

    throw new UnauthorizedException();
  }
}
Enter fullscreen mode Exit fullscreen mode

さらに @ResolveField レベルでの細かい権限制御も実装しています。

@ResolveField('author', () => GqlUser)
@UseGuards(UserTypesGuard)
@RequiredUserTypes('ADMIN')  // カスタムデコレータでメタデータ指定
async getAuthor(@Parent() comment: Comment) {
  return this.userUseCase.findOne({ id: comment.userId });
}
Enter fullscreen mode Exit fullscreen mode

UserTypesGuardReflector を使ってメタデータを読み取り、権限チェックを行います。

@Injectable()
export class UserTypesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredTypes = this.reflector.get<string[]>(
      'requiredUserTypes',
      context.getHandler(),
    );
    if (!requiredTypes) return true;

    const gqlContext = GqlExecutionContext.create(context);
    const user = gqlContext.getContext().req.user;
    return requiredTypes.includes(user.executor.userType);
  }
}
Enter fullscreen mode Exit fullscreen mode

6. Entity定義の多層構造

NestJSの基本

一般的なNestJSアプリケーションでは、1つのEntityクラスがDB定義とAPI定義を兼ねることが多いです。

実プロジェクトでの活用

本プロジェクトでは3層のEntity定義を使い分けています。

ファイル例 用途
GraphQL Entity entities/Comment.entity.ts @ObjectType() でAPI出力型を定義
Domain Entity domain/entities/Comment/index.ts Brand型による型安全なビジネスモデル
Prisma Model prisma/schema.prisma DBスキーマ定義

GraphQL Entity(API層)

@ObjectType()
export class Comment {
  @Field(() => Int)
  id: number;

  @Field(() => GraphQLJSON)
  content: Prisma.JsonObject;

  @Field()
  createdAt: Date;

  @Field(() => [CommentImage])
  commentImages: CommentImage[];  // ネストフィールド
}
Enter fullscreen mode Exit fullscreen mode

Domain Entity(ドメイン層)

Brand型を使って、同じ number 型でもIDの種類を区別します。

// Brand型の定義
type Brand<T, U> = T & { __brand: U };

// IDを型レベルで区別
export type CommentId = Brand<number, 'CommentId'>;
export type UserId = Brand<number, 'UserId'>;

// 型エラー: CommentId と UserId は互換性がない
const commentId: CommentId = 1 as CommentId;
const userId: UserId = commentId; // コンパイルエラー!
Enter fullscreen mode Exit fullscreen mode

ドメインエンティティの定義:

export type Comment = {
  id: CommentId;
  content: Prisma.JsonObject;
  roomId: RoomId;
  userId: UserId;
  tenantId: TenantId;
  createdAt: Date;
  updatedAt: Date;
};

// ファクトリ関数
export const createCommentSeed = (payload: CommentSeed): CommentSeed => {
  return { ...payload };
};
Enter fullscreen mode Exit fullscreen mode

Prisma Model(DB層)

model Comment {
  id        Int       @id @default(autoincrement())
  content   Json?
  roomId    String
  Room      Room      @relation(fields: [roomId], references: [id], onDelete: Cascade)
  userId    Int
  createdAt DateTime  @default(now())
  updatedAt DateTime  @default(now()) @updatedAt
}
Enter fullscreen mode Exit fullscreen mode

ポイント: 3層に分けることで、API仕様の変更がドメインロジックに影響しない、DBスキーマの変更がAPI層に漏れないという利点があります。


7. Prisma連携(Repository + Mapper)

NestJSの基本

NestJSではTypeORMやPrismaを直接サービスから利用するのが一般的です。

実プロジェクトでの活用

本プロジェクトではRepository + Mapperパターンを採用し、Prismaの型をドメイン型に変換しています。

Repository

@Injectable()
export class PrismaCommentRepository {
  async create(input: CommentSeed, db = client): Promise<Comment> {
    const prismaComment = await db.comment.create({
      data: {
        contentJson: input.content,
        roomId: input.roomId,
      },
      include: { Room: true },
    });

    // Prisma型 → Domain型への変換
    return PrismaCommentMapper.toDomainComment(prismaComment);
  }

  async findList(input, context, db = client): Promise<Comment[]> {
    const results = await db.comment.findMany({
      where: {
        roomId: input.roomId,
        Room: { tenantId: context.tenantId }, // テナント隔離
      },
      orderBy: { createdAt: 'asc' },
    });
    return results.map(PrismaCommentMapper.toDomainComment);
  }
}
Enter fullscreen mode Exit fullscreen mode

Mapper

export class PrismaCommentMapper {
  static toDomainComment(prismaComment): Comment {
    return {
      id: prismaComment.id as CommentId,
      content: prismaComment.contentJson as Prisma.JsonObject,
      roomId: prismaComment.roomId as RoomId,
      userId: prismaComment.userId as UserId,
      tenantId: prismaComment.Room.tenantId as TenantId,
      createdAt: prismaComment.createdAt,
      updatedAt: prismaComment.updatedAt,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

トランザクション

複数テーブルにまたがる操作は prisma.$transaction() で原子性を保証します。

const result = await prisma.$transaction(async (db) => {
  const room = await this.roomRepository.create(roomData, context, db);
  await this.memberRepository.create(memberData, db);
  return room;
});
Enter fullscreen mode Exit fullscreen mode

ポイント: Repositoryメソッドはオプショナルな db 引数を受け取り、トランザクション内でもスタンドアロンでも動作するよう設計されています。


8. 認証コンテキストの流れ

マルチテナントSaaSにおける認証情報の伝搬フローです。

1. GraphQLリクエスト
   ├─ Header: Authorization: "Bearer <JWT>"
   └─ Header: x-user-type: "Admin" or "Guest"

2. Guard(認証処理)
   ├─ Admin → JWT検証 → OperationContext生成
   └─ Guest → セッション検証 → OperationContext生成
   └─ request.user = OperationContext にセット

3. Resolver
   └─ @Context() context: OperationContext で取得

4. UseCase / Repository
   └─ contextを利用した権限制御・テナント隔離
Enter fullscreen mode Exit fullscreen mode

OperationContext はZodスキーマで型安全に定義されています。

export const operationContextSchema = z.object({
  tenantId: tenantIdSchema,
  executor: z.union([
    z.object({
      userType: z.literal('ADMIN'),
      userId: userIdSchema,
      role: roleSchema,
    }),
    z.object({
      userType: z.literal('GUEST'),
      userId: userIdSchema,
    }),
  ]),
});

export type OperationContext = z.infer<typeof operationContextSchema>;
Enter fullscreen mode Exit fullscreen mode

カスタムデコレータ @Context() でResolverに注入します。

export const Context = createParamDecorator(
  (data: unknown, context: ExecutionContext) => {
    const ctx = GqlExecutionContext.create(context);
    return ctx.getContext().req.user as OperationContext;
  },
);
Enter fullscreen mode Exit fullscreen mode

権限フィルターでDB操作時のテナント隔離を行います。

export const commentRestrictFilter = (
  context: OperationContext,
): Prisma.CommentWhereInput[] => {
  return [{ Room: { tenantId: context.tenantId } }];
};

// 使用例
const comments = await prisma.comment.findMany({
  where: { AND: commentRestrictFilter(context) },
});
Enter fullscreen mode Exit fullscreen mode

9. まとめ:NestJS標準 vs 実プロジェクト

観点 NestJS標準 実プロジェクト
API方式 REST (@Controller) GraphQL Code-First (@Resolver)
ビジネスロジック Service UseCase(ユースケース単位で分割)
データアクセス ORM直接利用 Repository + Mapper パターン
認証 単一Guard MultiUser振り分けGuard
型安全 TypeScript標準 Brand型 + Zod バリデーション
テナント隔離 なし OperationContext + RestrictFilter
Entity 単一定義 3層(GraphQL / Domain / Prisma)
Module構成 機能単位 レイヤー単位(Resolver / UseCase / Repository)

おわりに

NestJSはそのままでも十分に強力なフレームワークですが、プロジェクトの規模や要件に応じてアーキテクチャをカスタマイズできる柔軟性も持っています。

本プロジェクトでは、NestJSのDI機構を活かしつつ、以下のような工夫を加えています。

  • レイヤードアーキテクチャ: Module単位でレイヤーを分離し、依存の方向を制御
  • Code-First GraphQL: TypeScriptのデコレータからスキーマを自動生成
  • Brand型: IDの取り違えをコンパイル時に検出
  • RestrictFilter: マルチテナントのデータ隔離をパターン化

NestJSの基本概念(Module / DI / Guard / Decorator)を理解しておけば、プロジェクトの要件に合わせた拡張も容易です。ぜひ参考にしてみてください。

Top comments (0)