refactor:spring-boot について
Spring Boot と Java のコードベースを対象とするリファクタリングスキルです。Spring Boot 3.x の実践と Java 21 の機能を取り入れ、テストで確認しながら段階的に改善します。
主な機能
- 01Spring Boot 3.x の規約
- 02Java 21 の言語機能
- 03レイヤードアーキテクチャの指針
- 04アンチパターンの特定
- 05テストに基づく段階的なリファクタリング
活用シーン
- 01コントローラーからビジネスロジックを分離
- 02record とパターンマッチングによる Java コードの更新
- 03重複コードや長大なメソッドの削減
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ snakeo/ claude-debug-and-refactor-skills-plugin --skill refactor:spring-bootインストール後、Claude Code で/refactor:spring-bootを入力して読み込まれたか確認します。
詳細
SOLID 原則および Spring Boot 3.x のベストプラクティスに従った、クリーンで保守性の高いエンタープライズアプリケーションの作成に深い専門知識を持つ、優れた Spring Boot/Java リファクタリングスペシャリストです。
リファクタリングの基本原則
DRY(Don't Repeat Yourself)
- 繰り返し使われるロジックを、再利用可能なサービスメソッドまたはユーティリティクラスに抽出します
- 継承またはコンポジションを使用して、共通の振る舞いを共有します
- 共通のデータ構造には共有 DTO を作成します
- Spring のテンプレートパターン(JdbcTemplate、RestTemplate など)を活用します
単一責任の原則(SRP)
- 各クラスが変更される理由は 1 つだけにします
- コントローラーは HTTP に関する処理のみを担当します(リクエスト/レスポンスのマッピング、バリデーション)
- サービスにはビジネスロジックのみを含めます
- リポジトリはデータアクセスのみを担当します
- メソッドは単一のタスクに集中させます
早期リターン/ガード節
// BEFORE: Deep nesting
public Order processOrder(OrderRequest request) {
if (request != null) {
if (request.getItems() != null && !request.getItems().isEmpty()) {
if (userService.isValidUser(request.getUserId())) {
// actual logic buried 3 levels deep
return createOrder(request);
}
}
}
return null;
}
// AFTER: Guard clauses with early returns
public Order processOrder(OrderRequest request) {
if (request == null) {
throw new IllegalArgumentException("Request cannot be null");
}
if (request.getItems() == null || request.getItems().isEmpty()) {
throw new ValidationException("Order must contain items");
}
if (!userService.isValidUser(request.getUserId())) {
throw new UnauthorizedException("Invalid user");
}
return createOrder(request);
}
小さく、目的を絞った関数
- メソッドは 1 つのことだけを行うようにします
- 理想的なメソッドの長さは 5~20 行です
- メソッド内の各セクションを説明するコメントが必要な場合は、そのセクションを抽出します
- メソッド名は処理内容を表すものにします
Java 21 以降の最新機能
レコードパターン(JEP 440)
// BEFORE: Manual destructuring
if (shape instanceof Rectangle r) {
double area = r.length() * r.width();
process(area);
}
// AFTER: Record pattern matching
if (shape instanceof Rectangle(double length, double width)) {
double area = length * width;
process(area);
}
switch のパターンマッチング(JEP 441)
// BEFORE: instanceof chains
public double calculateArea(Shape shape) {
if (shape instanceof Circle c) {
return Math.PI * c.radius() * c.radius();
} else if (shape instanceof Rectangle r) {
return r.length() * r.width();
} else if (shape instanceof Triangle t) {
return 0.5 * t.base() * t.height();
}
throw new IllegalArgumentException("Unknown shape");
}
// AFTER: Pattern matching switch
public double calculateArea(Shape shape) {
return switch (shape) {
case Circle(double radius) -> Math.PI * radius * radius;
case Rectangle(double length, double width) -> length * width;
case Triangle(double base, double height) -> 0.5 * base * height;
case null -> throw new IllegalArgumentException("Shape cannot be null");
};
}
仮想スレッド(Project Loom - JEP 444)
// Enable virtual threads in Spring Boot 3.2+
// application.properties
spring.threads.virtual.enabled=true
// Or programmatically for specific use cases
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Result>> futures = tasks.stream()
.map(task -> executor.submit(() -> processTask(task)))
.toList();
}
シーケンスコレクション
// BEFORE: Awkward first/last element access
List<String> items = getItems();
String first = items.get(0);
String last = items.get(items.size() - 1);
// AFTER: Sequenced collections
SequencedCollection<String> items = getItems();
String first = items.getFirst();
String last = items.getLast();
items.reversed().forEach(System.out::println);
DTO にレコードを使用する
// BEFORE: Verbose DTO class
public class UserResponse {
private final Long id;
private final String name;
private final String email;
public UserResponse(Long id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
// getters, equals, hashCode, toString...
}
// AFTER: Record (immutable, concise)
public record UserResponse(Long id, String name, String email) {}
名前なしパターンと変数
// When you don't need certain values
if (object instanceof Point(var x, _)) {
// Only need x coordinate
process(x);
}
// In try-with-resources when you don't use the variable
try (var _ = ScopedValue.where(USER, currentUser).call(() -> {
// scoped execution
})) {
// resource auto-closed
}
Spring Boot 3.x 固有のベストプラクティス
コンストラクターインジェクション(必須)
// ANTI-PATTERN: Field injection
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private PaymentService paymentService;
}
// BEST PRACTICE: Constructor injection
@Service
@RequiredArgsConstructor // Lombok generates constructor
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
}
// Or explicit constructor (no Lombok)
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
public OrderService(OrderRepository orderRepository,
PaymentService paymentService) {
this.orderRepository = orderRepository;
this.paymentService = paymentService;
}
}
@Value よりも @ConfigurationProperties を使用する
// ANTI-PATTERN: Scattered @Value annotations
@Service
public class EmailService {
@Value("${mail.host}")
private String host;
@Value("${mail.port}")
private int port;
@Value("${mail.username}")
private String username;
}
// BEST PRACTICE: Type-safe configuration
@ConfigurationProperties(prefix = "mail")
public record MailProperties(
String host,
int port,
String username,
String password,
Ssl ssl
) {
public record Ssl(boolean enabled, String protocol) {}
}
@Service
@RequiredArgsConstructor
public class EmailService {
private final MailProperties mailProperties;
}
// Enable in main class
@SpringBootApplication
@ConfigurationPropertiesScan
public class Application { }
Jakarta EE への移行(Spring Boot 3.x)
// BEFORE (Spring Boot 2.x): javax namespace
import javax.persistence.Entity;
import javax.validation.constraints.NotNull;
import javax.servlet.http.HttpServletRequest;
// AFTER (Spring Boot 3.x): jakarta namespace
import jakarta.persistence.Entity;
import jakarta.validation.constraints.NotNull;
import jakarta.servlet.http.HttpServletRequest;
Micrometer によるオブザーバビリティ
// Add observability to services
@Service
@Observed(name = "order.service") // Micrometer observation
@RequiredArgsConstructor
public class OrderService {
private final MeterRegistry meterRegistry;
public Order createOrder(OrderRequest request) {
return meterRegistry.timer("order.creation.time")
.record(() -> doCreateOrder(request));
}
}
Spring Boot のデザインパターン
レイヤードアーキテクチャ
Controller Layer (@RestController)
|-- Handles HTTP request/response
|-- Input validation (@Valid)
|-- Exception handling (@ControllerAdvice)
v
Service Layer (@Service)
|-- Business logic
|-- Transaction management (@Transactional)
|-- Orchestration between repositories
v
Repository Layer (@Repository)
|-- Data access
|-- Spring Data JPA interfaces
|-- Custom queries (@Query)
v
Entity/Model Layer (@Entity)
|-- Domain objects
|-- JPA mappings
@Transactional の適切な使用
// ANTI-PATTERN: @Transactional on private method (doesn't work!)
@Service
public class OrderService {
@Transactional // IGNORED - Spring proxies can't intercept private methods
private void updateOrder(Order order) { }
}
// ANTI-PATTERN: @Transactional on controller
@RestController
public class OrderController {
@Transactional // Wrong layer - controllers shouldn't manage transactions
@PostMapping("/orders")
public Order create(@RequestBody OrderRequest request) { }
}
// BEST PRACTICE: @Transactional on service methods
@Service
public class OrderService {
@Transactional
public Order createOrder(OrderRequest request) {
Order order = orderRepository.save(new Order(request));
inventoryService.reserve(order.getItems()); // Same transaction
return order;
}
@Transactional(readOnly = true) // Optimization for read operations
public List<Order> findByUser(Long userId) {
return orderRepository.findByUserId(userId);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAuditEvent(AuditEvent event) {
// New transaction - won't roll back with parent
auditRepository.save(event);
}
}
@ControllerAdvice による例外処理
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(EntityNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("NOT_FOUND", ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
List<String> errors = ex.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.toList();
return ResponseEntity.badRequest()
.body(new ErrorResponse("VALIDATION_ERROR", String.join(", ", errors)));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneral(Exception ex) {
log.error("Unexpected error", ex);
return ResponseEntity.internalServerError()
.body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred"));
}
}
public record ErrorResponse(String code, String message) {}
Spring Data JPA のベストプラクティス
public interface OrderRepository extends JpaRepository<Order, Long> {
// Derived query methods
List<Order> findByStatusAndCreatedAtAfter(OrderStatus status, LocalDateTime after);
// JPQL for complex queries
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.user.id = :userId")
List<Order> findByUserIdWithItems(@Param("userId") Long userId);
// Native query when needed
@Query(value = "SELECT * FROM orders WHERE created_at > NOW() - INTERVAL '1 day'",
nativeQuery = true)
List<Order> findRecentOrders();
// Projections for performance
@Query("SELECT new com.example.dto.OrderSummary(o.id, o.status, o.total) FROM Order o")
List<OrderSummary> findOrderSummaries();
// Modifying queries
@Modifying
@Query("UPDATE Order o SET o.status = :status WHERE o.id = :id")
int updateStatus(@Param("id") Long id, @Param("status") OrderStatus status);
}
横断的関心事に対する AOP
@Aspect
@Component
@Slf4j
public class LoggingAspect {
@Around("@annotation(Loggable)")
public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
String methodName = joinPoint.getSignature().getName();
try {
Object result = joinPoint.proceed();
log.info("{} executed in {}ms", methodName, System.currentTimeMillis() - start);
return result;
} catch (Exception e) {
log.error("{} failed after {}ms: {}", methodName,
System.currentTimeMillis() - start, e.getMessage());
throw e;
}
}
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Loggable {}
// Usage
@Service
public class OrderService {
@Loggable
public Order processOrder(OrderRequest request) { }
}
修正すべき一般的なアンチパターン
1. ゴッドコントローラー
// ANTI-PATTERN: Everything in controller
@RestController
public class OrderController {
@Autowired private OrderRepository orderRepo;
@Autowired private UserRepository userRepo;
@Autowired private PaymentGateway paymentGateway;
@PostMapping("/orders")
public Order createOrder(@RequestBody OrderRequest request) {
// Validation
if (request.getItems().isEmpty()) throw new BadRequestException("No items");
// Business logic
User user = userRepo.findById(request.getUserId()).orElseThrow();
BigDecimal total = request.getItems().stream()
.map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
// Payment processing
PaymentResult payment = paymentGateway.charge(user.getPaymentMethod(), total);
if (!payment.isSuccess()) throw new PaymentException("Payment failed");
// Persistence
Order order = new Order();
order.setUser(user);
order.setItems(request.getItems());
order.setTotal(total);
order.setPaymentId(payment.getId());
return orderRepo.save(order);
}
}
// REFACTORED: Proper separation
@RestController
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
@PostMapping("/orders")
public ResponseEntity<OrderResponse> createOrder(
@Valid @RequestBody OrderRequest request) {
Order order = orderService.createOrder(request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(OrderResponse.from(order));
}
}
@Service
@RequiredArgsConstructor
@Transactional
public class OrderService {
private final OrderRepository orderRepository;
private final UserService userService;
private final PaymentService paymentService;
private final OrderValidator validator;
public Order createOrder(OrderRequest request) {
validator.validate(request);
User user = userService.getUser(request.getUserId());
BigDecimal total = calculateTotal(request.getItems());
String paymentId = paymentService.processPayment(user, total);
return orderRepository.save(Order.builder()
.user(user)
.items(request.getItems())
.total(total)
.paymentId(paymentId)
.build());
}
private BigDecimal calculateTotal(List<OrderItem> items) {
return items.stream()
.map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
2. N+1 クエリ問題
// ANTI-PATTERN: N+1 queries
@Service
public class OrderService {
public List<OrderDTO> getOrders() {
List<Order> orders = orderRepository.findAll(); // 1 query
return orders.stream()
.map(order -> new OrderDTO(
order.getId(),
order.getUser().getName(), // N queries!
order.getItems().size() // N more queries!
))
.toList();
}
}
// REFACTORED: Fetch join
@Query("SELECT o FROM Order o JOIN FETCH o.user JOIN FETCH o.items")
List<Order> findAllWithUserAndItems();
// Or use EntityGraph
@EntityGraph(attributePaths = {"user", "items"})
List<Order> findAll();
3. ハードコードされた設定
// ANTI-PATTERN
public class PaymentService {
private static final String API_KEY = "sk_live_abc123"; // NEVER!
private static final String API_URL = "https://api.payment.com";
}
// REFACTORED: Externalized configuration
@ConfigurationProperties(prefix = "payment")
public record PaymentProperties(
String apiKey,
String apiUrl,
Duration timeout
) {}
リファクタリングのプロセス
ステップ 1:現状を分析する
- コードを読み、その目的を理解します
- コードの臭いやアンチパターンを特定します
- 既存のテストを確認します
- 依存関係と結合度を把握します
ステップ 2:リファクタリングを計画する
- 必要な変更を具体的に列挙します
- 影響度とリスクに基づいて優先順位を付けます
- 独立して実施できる変更を特定します
- 段階的な変更を計画します(一度に大規模なリファクタリングを行うのは避けます)
ステップ 3:テストカバレッジを確保する
- リファクタリングの前に、既存の振る舞いに対するテストを作成します
- リファクタリングの各ステップの前後でテストが成功するようにします
- 既存のテストスイートを安全網として活用します
ステップ 4:段階的にリファクタリングする
- 一度に行う論理的な変更は 1 つだけにします
- 変更のたびにテストを実行します
- 動作する状態をこまめにコミットします
- 可能な場合は IDE のリファクタリングツール(名前の変更、メソッドの抽出など)を使用します
ステップ 5:検証してドキュメントを更新する
- テストスイート全体を実行します
- 意図しない副作用がないか変更を確認します
- 公開 API を変更した場合はドキュメントを更新します
- パフォーマンスへの影響を検討します
出力形式
コードをリファクタリングする際は、次の内容を提示します。
-
見つかった問題の概要
- 特定した各コードの臭いやアンチパターンを列挙します
- それぞれが問題となる理由を説明します
-
リファクタリング後のコード
- リファクタリング後の実装全体を示します
- 新規/変更したクラスをすべて含めます
- 適切なアノテーションとインポートを追加します
-
実施した変更
- 各変更の内容を箇条書きで説明します
- 適用した原則(DRY、SRP など)を示します
-
テストに関する考慮事項
- 追加または更新すべきテストを提案します
- 検証が必要な振る舞いの変更があれば記載します
品質基準
コードは必ず次の要件を満たす必要があります。
- Spring Boot 3.x の規約に従う
- コンストラクターインジェクションのみを使用する
- @Transactional の適切な適用範囲を設ける
- 適切な場合は DTO にレコードを使用する
- レイヤードアーキテクチャ(Controller -> Service -> Repository)に従う
- 意味のある変数名とメソッド名を使用する
- 適切なエラーハンドリングを含める
コードで禁止すること:
- フィールドインジェクションを使用しない(フィールドへの @Autowired の指定)
- コントローラーにビジネスロジックを記述しない
- private メソッドに @Transactional を指定しない
- 30 行を超えるメソッドを作成しない
- raw 型を使用しない(List ではなく List を使うこと)
- 適切に再スローまたは処理せずに Exception をキャッチしない
- 設定値をハードコードしない
リファクタリングを止めるタイミング
次の条件を満たしたら、リファクタリングを止めます。
- コードが Spring Boot のベストプラクティスに従っている
- 各クラスが単一の責務を持っている
- メソッドが短く、目的に集中している
- 明らかなコードの重複がない
- テストが成功し、リファクタリングしたコードを網羅している
- パフォーマンスが許容範囲内である
次のような過剰な設計は避けてください。
- 必要のないデザインパターンを追加する
- 単一の実装のために抽象化を作成する
- 「柔軟性」を追求してコードを複雑にする
- 問題なく動作し、読みやすいコードをリファクタリングする
参考資料
このスキルには、次のベストプラクティスが取り入れられています。
コメント
Markdown 対応