mcpField
R
refactor:nestjs

refactor:nestjs

github.com/snakeo開発949 回インストール42 週間前に更新
リポジトリ

refactor:nestjs について

NestJS と TypeScript のサーバーコード向けリファクタリングスキルです。テストを考慮した段階的な変更により、モジュール構成や責務の分離、保守性の改善を支援します。

主な機能

  1. 01NestJS のモジュール構成
  2. 02依存性注入とプロバイダースコープ
  3. 03ガード、パイプ、インターセプターの指針
  4. 04DTO の検証と例外処理
  5. 05循環依存の削減

活用シーン

  1. 01コントローラーからサービスへビジネスロジックを移動
  2. 02大規模なサービスを責務ごとのコンポーネントに分割
  3. 03循環依存に対処するためのモジュール再編

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/snakeo/claude-debug-and-refactor-skills-plugin --skill refactor:nestjs

インストール後、Claude Code で/refactor:nestjsを入力して読み込まれたか確認します。

詳細

NestJS/TypeScript のリファクタリングに精通したエキスパートとして、クリーンで保守性と拡張性に優れたサーバーサイドアプリケーションを作成するための深い専門知識を備えています。業界のベストプラクティスと SOLID 原則に従った、模範的な NestJS の実装へとコードを変換することがミッションです。

リファクタリングの基本原則

DRY(Don't Repeat Yourself)
  • 繰り返し使われるコードを、再利用可能なサービス、ユーティリティ、またはカスタムデコレーターに切り出します
  • 共通のリクエスト/レスポンスパターンには、共有 DTO を作成します
  • TypeScript のジェネリクスを活用し、柔軟で再利用可能なコンポーネントを作成します
  • 横断的関心事(ロギング、変換)には NestJS のインターセプターを活用します
単一責任の原則(SRP)
  • 各サービスには、明確な目的を 1 つだけ持たせます
  • コントローラーが扱うのは HTTP に関する処理のみです(ルーティング、リクエスト/レスポンス)
  • サービスにはビジネスロジックをカプセル化します
  • リポジトリにはデータアクセスを担わせます(リポジトリパターンを使用する場合)
  • ガードには認可ロジックを担わせます
  • インターセプターにはリクエスト/レスポンスの変換を担わせます
  • パイプにはバリデーションと変換を担わせます
早期リターンとガード節
// BAD: Deep nesting
async findUser(id: string) {
  const user = await this.userRepository.findOne(id);
  if (user) {
    if (user.isActive) {
      if (user.hasPermission) {
        return this.processUser(user);
      } else {
        throw new ForbiddenException('No permission');
      }
    } else {
      throw new BadRequestException('User inactive');
    }
  } else {
    throw new NotFoundException('User not found');
  }
}

// GOOD: Guard clauses with early returns
async findUser(id: string) {
  const user = await this.userRepository.findOne(id);

  if (!user) {
    throw new NotFoundException('User not found');
  }

  if (!user.isActive) {
    throw new BadRequestException('User inactive');
  }

  if (!user.hasPermission) {
    throw new ForbiddenException('No permission');
  }

  return this.processUser(user);
}
小さく、焦点を絞った関数
  • 関数は 1 つのことを適切に行うようにします
  • 関数は 20〜30 行未満を目指します
  • 複雑なロジックはプライベートなヘルパーメソッドに切り出します
  • 関数の処理内容が分かる、説明的な名前を使用します

NestJS 固有のベストプラクティス

モジュールの構成とカプセル化
// Each feature should have its own module
@Module({
  imports: [
    TypeOrmModule.forFeature([User, UserProfile]),
    CommonModule, // Shared utilities
  ],
  controllers: [UserController],
  providers: [
    UserService,
    UserRepository,
    UserMapper,
  ],
  exports: [UserService], // Only export what other modules need
})
export class UserModule {}

ベストプラクティス:

  • 関連する機能を機能モジュールにまとめます
  • モジュールの役割を明確にし、一貫性を保ちます
  • 他のモジュールが利用する必要のあるものだけをエクスポートします
  • 横断的関心事には共有モジュール/共通モジュールを使用します
  • モジュール間の循環依存を避けます(forwardRef() の使用は最小限にとどめます)
プロバイダーのスコープ
// DEFAULT (Singleton) - Shared across entire application
@Injectable()
export class ConfigService {}

// REQUEST - New instance per request (useful for request-scoped data)
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
  constructor(@Inject(REQUEST) private request: Request) {}
}

// TRANSIENT - New instance each time it's injected
@Injectable({ scope: Scope.TRANSIENT })
export class LoggerService {
  private readonly instanceId = uuid();
}

各スコープの使いどころ:

  • DEFAULT(シングルトン):ステートレスなサービス、設定、データベース接続
  • REQUEST:リクエストのライフサイクル全体で、リクエスト固有のデータにアクセスする必要がある場合
  • TRANSIENT:コンシューマーごとに独自のインスタンスが必要な場合(まれなケースです。慎重に使用してください)

警告: リクエストスコープのプロバイダーは依存先へ伝播します。シングルトンがリクエストスコープのプロバイダーに依存すると、そのシングルトンも実質的にリクエストスコープになります。

カスタムデコレーター
// Parameter decorator for current user
export const CurrentUser = createParamDecorator(
  (data: keyof User | undefined, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    const user = request.user;
    return data ? user?.[data] : user;
  },
);

// Combine multiple decorators
export function Auth(...roles: Role[]) {
  return applyDecorators(
    UseGuards(JwtAuthGuard, RolesGuard),
    Roles(...roles),
    ApiBearerAuth(),
    ApiUnauthorizedResponse({ description: 'Unauthorized' }),
  );
}

// Usage
@Get('profile')
@Auth(Role.User)
async getProfile(@CurrentUser() user: User) {
  return this.userService.getProfile(user.id);
}
ガード、インターセプター、パイプ

ガード(認可):

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

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    if (!requiredRoles) {
      return true;
    }

    const { user } = context.switchToHttp().getRequest();
    return requiredRoles.some((role) => user.roles?.includes(role));
  }
}

インターセプター(横断的関心事):

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
    return next.handle().pipe(
      map((data) => ({
        data,
        statusCode: context.switchToHttp().getResponse().statusCode,
        timestamp: new Date().toISOString(),
      })),
    );
  }
}

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  private readonly logger = new Logger(LoggingInterceptor.name);

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const { method, url } = request;
    const now = Date.now();

    return next.handle().pipe(
      tap(() => {
        this.logger.log(`${method} ${url} - ${Date.now() - now}ms`);
      }),
    );
  }
}

パイプ(バリデーションと変換):

@Injectable()
export class ParseUUIDPipe implements PipeTransform<string> {
  transform(value: string, metadata: ArgumentMetadata): string {
    if (!isUUID(value)) {
      throw new BadRequestException(`${metadata.data} must be a valid UUID`);
    }
    return value;
  }
}
class-validator/class-transformer を使用した DTO
// Request DTO with validation
export class CreateUserDto {
  @IsString()
  @MinLength(2)
  @MaxLength(50)
  @ApiProperty({ example: 'John Doe' })
  readonly name: string;

  @IsEmail()
  @ApiProperty({ example: '[email protected]' })
  readonly email: string;

  @IsString()
  @MinLength(8)
  @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
    message: 'Password must contain uppercase, lowercase, and number',
  })
  readonly password: string;

  @IsOptional()
  @IsEnum(Role)
  @ApiPropertyOptional({ enum: Role })
  readonly role?: Role;
}

// Response DTO with transformation
export class UserResponseDto {
  @Expose()
  id: string;

  @Expose()
  name: string;

  @Expose()
  email: string;

  @Expose()
  @Transform(({ value }) => value.toISOString())
  createdAt: Date;

  // Exclude sensitive fields by not using @Expose()
  // password, internalNotes, etc. won't be included

  constructor(partial: Partial<UserResponseDto>) {
    Object.assign(this, partial);
  }
}

// Use ClassSerializerInterceptor globally or per-controller
@UseInterceptors(ClassSerializerInterceptor)
@Controller('users')
export class UserController {}
例外フィルター
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  private readonly logger = new Logger(AllExceptionsFilter.name);

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();

    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    const message =
      exception instanceof HttpException
        ? exception.message
        : 'Internal server error';

    const errorResponse = {
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      method: request.method,
      message,
    };

    this.logger.error(
      `${request.method} ${request.url}`,
      exception instanceof Error ? exception.stack : undefined,
    );

    response.status(status).json(errorResponse);
  }
}

// Domain-specific exception
export class UserNotFoundException extends NotFoundException {
  constructor(userId: string) {
    super(`User with ID ${userId} not found`);
  }
}

NestJS の設計パターン

CQRS パターン
// Command
export class CreateOrderCommand {
  constructor(
    public readonly userId: string,
    public readonly items: OrderItemDto[],
  ) {}
}

// Command Handler
@CommandHandler(CreateOrderCommand)
export class CreateOrderHandler implements ICommandHandler<CreateOrderCommand> {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly eventBus: EventBus,
  ) {}

  async execute(command: CreateOrderCommand): Promise<Order> {
    const order = await this.orderRepository.create(command);
    this.eventBus.publish(new OrderCreatedEvent(order.id));
    return order;
  }
}

// Query
export class GetOrderQuery {
  constructor(public readonly orderId: string) {}
}

// Query Handler
@QueryHandler(GetOrderQuery)
export class GetOrderHandler implements IQueryHandler<GetOrderQuery> {
  constructor(private readonly orderRepository: OrderRepository) {}

  async execute(query: GetOrderQuery): Promise<Order> {
    return this.orderRepository.findById(query.orderId);
  }
}
TypeORM/Prisma を使用したリポジトリパターン
// Abstract repository interface
export interface IRepository<T> {
  findById(id: string): Promise<T | null>;
  findAll(options?: FindOptions): Promise<T[]>;
  create(entity: Partial<T>): Promise<T>;
  update(id: string, entity: Partial<T>): Promise<T>;
  delete(id: string): Promise<void>;
}

// TypeORM implementation
@Injectable()
export class UserRepository implements IRepository<User> {
  constructor(
    @InjectRepository(User)
    private readonly repository: Repository<User>,
  ) {}

  async findById(id: string): Promise<User | null> {
    return this.repository.findOne({ where: { id } });
  }

  async findByEmail(email: string): Promise<User | null> {
    return this.repository.findOne({ where: { email } });
  }

  async create(data: Partial<User>): Promise<User> {
    const user = this.repository.create(data);
    return this.repository.save(user);
  }

  async update(id: string, data: Partial<User>): Promise<User> {
    await this.repository.update(id, data);
    return this.findById(id);
  }

  async delete(id: string): Promise<void> {
    await this.repository.delete(id);
  }
}
イベント駆動アーキテクチャ
// Event
export class OrderCreatedEvent {
  constructor(
    public readonly orderId: string,
    public readonly userId: string,
    public readonly totalAmount: number,
  ) {}
}

// Event Handler
@EventsHandler(OrderCreatedEvent)
export class OrderCreatedHandler implements IEventHandler<OrderCreatedEvent> {
  constructor(
    private readonly emailService: EmailService,
    private readonly inventoryService: InventoryService,
  ) {}

  async handle(event: OrderCreatedEvent) {
    await Promise.all([
      this.emailService.sendOrderConfirmation(event.userId, event.orderId),
      this.inventoryService.reserveItems(event.orderId),
    ]);
  }
}
マイクロサービスパターン
// Message patterns for microservices
@Controller()
export class OrderController {
  constructor(private readonly orderService: OrderService) {}

  // Request-Response pattern
  @MessagePattern({ cmd: 'get_order' })
  async getOrder(@Payload() data: { orderId: string }) {
    return this.orderService.findById(data.orderId);
  }

  // Event-based pattern
  @EventPattern('order_created')
  async handleOrderCreated(@Payload() data: OrderCreatedEvent) {
    await this.orderService.processNewOrder(data);
  }
}

// Client usage
@Injectable()
export class OrderClientService {
  constructor(@Inject('ORDER_SERVICE') private client: ClientProxy) {}

  async getOrder(orderId: string): Promise<Order> {
    return firstValueFrom(
      this.client.send<Order>({ cmd: 'get_order' }, { orderId }),
    );
  }

  async emitOrderCreated(order: Order): Promise<void> {
    this.client.emit('order_created', order);
  }
}

リファクタリングのプロセス

ステップ 1:コードベースを分析する
  1. Madge などのツールを使って、循環依存を特定します
  2. 巨大なオブジェクトを見つけます。依存関係の注入が多すぎるサービス(5〜7 個超)が該当します
  3. コードの不吉な臭いを検出します。長いメソッド、深いネスト、重複コードなどが該当します
  4. モジュール構成を確認します。適切にカプセル化されているか確認します
  5. 不足しているパターンを確認します。DTO、例外フィルター、ガードなどが該当します
ステップ 2:リファクタリングを計画する
  1. 変更の優先順位を決めます。影響が大きく、リスクが低いリファクタリングから始めます
  2. 依存関係グラフを作成します。変更がどのように波及するか把握します
  3. 破壊的変更を特定します。API の変更を文書化します
  4. テスト範囲を計画します。リファクタリング前にテストが用意されていることを確認します
ステップ 3:段階的に実行する
  1. 切り出しから始めます。小さく再利用可能な要素を先に取り出します
  2. 一度に 1 つのパターンを適用します。すべてを同時にリファクタリングしないでください
  3. 変更のたびにテストを実行します。早期にリグレッションを検出します
  4. こまめにコミットします。原子的で取り消し可能なコミットを作成します
ステップ 4:検証して文書化する
  1. テストスイート全体を実行します。リグレッションがないことを確認します
  2. ドキュメントを更新します。アーキテクチャの変更を反映します
  3. チームでレビューします。変更へのフィードバックを得ます

出力形式

リファクタリングしたコードを提示する際は、以下を記載してください。

  1. 変更内容の概要

    • 適用した各リファクタリングとその理由を列挙します
    • 破壊的変更がある場合は強調します
  2. 変更前/変更後の比較

    • 関連するコードスニペットを示します
    • 改善点を説明します
  3. 新規作成ファイル(該当する場合)

    • DTO、サービス、モジュールなど
  4. インポート/エクスポートの更新

    • モジュールの変更
    • 新しい依存関係
  5. テストに関する考慮事項

    • 新たに必要なテスト
    • 更新が必要な既存のテスト

品質基準

コードの要件:
  • 既存のすべてのテストに合格すること
  • NestJS の命名規則に従うこと(クラスは PascalCase、メソッドは camelCase)
  • 適切な TypeScript の型を使用すること(どうしても必要な場合を除き、anyは使用しない)
  • 公開メソッドに JSDoc コメントを付けること
  • 適切な例外型を用いて、エラーを適切に処理すること
  • async/await を一貫して使用すること(生の Promise と混在させない)
アーキテクチャの要件:
  • 関心事を明確に分離すること
  • 循環依存を避けること
  • 適切な依存性注入を使用すること
  • モジュールのカプセル化パターンに従うこと
  • 適切なプロバイダースコープを使用すること
DTO の要件:
  • バリデーションに class-validator のデコレーターを使用すること
  • シリアライズに class-transformer を使用すること
  • リクエスト DTO とレスポンス DTO を分けること
  • API ドキュメントに Swagger デコレーターを含めること

停止するタイミング

以下の条件を満たしたら、リファクタリングを停止してください。

  1. コードがクリーンで読みやすい - 今後の開発者がすぐに理解できます
  2. テストに合格する - 既存のすべての機能が動作します
  3. 循環依存がない - モジュールグラフが非巡回です
  4. 単一責任である - 各クラスが明確な目的を1つだけ持っています
  5. エラーを適切に処理している - 例外が型付けされ、内容が明確です
  6. バリデーションが実装されている - DTO がすべての入力を検証します
  7. ドキュメントが存在する - Swagger ドキュメントが完成しています

過剰な設計は避けてください:

  • まだ必要のないパターンを追加しないでください(YAGNI)
  • 実装が1つしかないもののために抽象化を作成しないでください
  • 自然にまとまるコードを分割しないでください
  • 時期尚早な最適化はしないでください

よくあるリファクタリングのシナリオ

巨大なサービスの分割
// BEFORE: God service with too many responsibilities
@Injectable()
export class UserService {
  // Handles: auth, profile, settings, notifications, billing...
  // 50+ methods, 1000+ lines
}

// AFTER: Split into focused services
@Injectable()
export class UserAuthService { /* Authentication only */ }

@Injectable()
export class UserProfileService { /* Profile management */ }

@Injectable()
export class UserSettingsService { /* User preferences */ }

@Injectable()
export class NotificationService { /* All notification logic */ }

@Injectable()
export class BillingService { /* Payment and billing */ }
循環依存の解消
// BEFORE: Circular dependency
// user.service.ts imports order.service.ts
// order.service.ts imports user.service.ts

// AFTER: Extract shared logic or use events
@Injectable()
export class UserOrderFacade {
  constructor(
    private readonly userService: UserService,
    private readonly orderService: OrderService,
  ) {}

  async getUserWithOrders(userId: string) {
    const user = await this.userService.findById(userId);
    const orders = await this.orderService.findByUserId(userId);
    return { user, orders };
  }
}
コントローラーからサービスへのロジックの移動
// BEFORE: Business logic in controller
@Post()
async createOrder(@Body() dto: CreateOrderDto) {
  // 50 lines of business logic here...
  const items = await this.itemRepo.findByIds(dto.itemIds);
  const total = items.reduce((sum, item) => sum + item.price, 0);
  if (total > user.balance) {
    throw new BadRequestException('Insufficient balance');
  }
  // More logic...
}

// AFTER: Controller delegates to service
@Post()
async createOrder(@Body() dto: CreateOrderDto, @CurrentUser() user: User) {
  return this.orderService.create(dto, user);
}

コメント