メインコンテンツに移動

Spring BootによるJava Webシステム開発で起こりやすい課題と改善策:企業システム向けに解説

Spring Bootは、企業向けJava Webシステム開発で広く使われるフレームワークです。Spring Bootは、スタンドアロンで本番利用を意識したSpringベースのアプリケーションを作りやすくするための仕組みを提供しており、最小限の設定で開発を始めやすい点が特徴です。公式ドキュメントでも、Spring Bootはproduction-gradeのSpringベースアプリケーションを作成できるものとして説明されています。

ただし、開発を始めやすいことと、企業システムとして長期運用できることは別です。Spring Boot 4.1.0ではJava 17以上が必要とされ、Spring Framework 7.0.8以上が要求されています。技術バージョン、保守期間、依存ライブラリ、セキュリティ更新を含めて、数年単位の運用を見据えた設計が必要です。

また、Spring Securityは認証、認可、一般的な攻撃への防御を提供するフレームワークであり、企業システムのログイン、権限管理、API保護では重要な役割を持ちます。Spring Data JPAはJakarta Persistence API向けのRepository支援を提供し、データアクセス実装を簡潔にできますが、DB設計やクエリ設計まで自動で解決するものではありません。

1. 要件定義が曖昧なまま開発を始めてしまう

Spring BootはすぐにAPIや画面を作れるため、要件定義が浅いまま実装に入ってしまうことがあります。しかし企業システムでは、業務ルール、承認フロー、権限、データ保持、外部連携、運用条件が曖昧なまま進めると、実装後に大きな手戻りが発生します。

1.1 業務フローを画面単位だけで考えてしまう

画面一覧だけで設計を始めると、実際の業務状態が見えなくなります。例えば「申請画面」「承認画面」「一覧画面」は作れても、申請中、承認済み、差し戻し、取消済みといった状態遷移が曖昧だと、Service層に条件分岐が増えます。

改善策は、画面ではなく業務状態を先に整理することです。状態ごとに許可される操作を明確にすれば、実装と仕様の対応が分かりやすくなります。

例:申請ステータスをenumで定義する

注記:ファイル名:RequestStatus.java / 使用言語:Java

 

public enum RequestStatus {
    DRAFT,
    SUBMITTED,
    APPROVED,
    REJECTED,
    CANCELLED;

    public boolean canApprove() {
        return this == SUBMITTED;
    }

    public boolean canEdit() {
        return this == DRAFT || this == REJECTED;
    }
}

 

1.2 API要件と画面要件を分けていない

画面で必要な項目とAPIの責務を混同すると、レスポンスが肥大化します。画面都合でEntityをそのまま返すと、内部項目や不要な関連情報が外部に出る危険があります。

改善策は、画面表示用のResponse DTOを作ることです。API契約を明確にし、Entityと外部レスポンスを分離します。

例:一覧画面用DTOを作る

注記:ファイル名:CustomerListResponse.java / 使用言語:Java

 

public record CustomerListResponse(
        Long id,
        String customerCode,
        String displayName,
        String statusLabel
) {}

 

1.3 非機能要件を後回しにしてしまう

機能だけを優先すると、性能、監視、ログ、バックアップ、セキュリティ、可用性が後回しになります。PoCでは動いても、本番運用ではアクセス集中、障害調査、監査対応で問題になります。

改善策は、初期段階で最低限の非機能要件を決めることです。Spring Boot Actuatorは本番運用向けのヘルスチェック、メトリクス、監視機能を提供します。

例:Actuatorの公開範囲を制御する

注記:ファイル名:application.yml / 使用言語:YAML

 

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  endpoint:
    health:
      show-details: when_authorized

 

1.4 権限要件が単純すぎる

「管理者」と「一般ユーザー」だけで始めると、後から承認者、閲覧専用、部門管理者、監査担当などが追加されたときに設計が破綻しやすくなります。

改善策は、ロールと具体的な操作権限を分けることです。Spring Securityは認可機能を提供しており、URL単位だけでなくメソッド単位の制御にも活用できます。

例:操作権限でAPIを制御する

注記:ファイル名:CustomerController.java / 使用言語:Java

 

@PreAuthorize("hasAuthority('CUSTOMER_READ')")
@GetMapping("/customers/{id}")
public CustomerDetailResponse getCustomer(@PathVariable Long id) {
    return customerQueryService.getDetail(id);
}

 

1.5 変更前提の要件管理ができていない

企業システムでは、開発中に業務ルールや外部連携仕様が変わることがあります。変更理由を残さずに実装すると、後からなぜその処理があるのか分からなくなります。

改善策は、要件ID、Issue、Pull Request、テストケースを紐づけることです。コードコメントにも業務判断の理由を残すと、保守時に追跡しやすくなります。

例:要件IDをコメントに残す

注記:ファイル名:RequestApplicationService.java / 使用言語:Java

 

// REQ-042: 承認済み申請は、締め日までは取消可能
public void cancelApprovedRequest(Long requestId, LocalDate today) {
    Request request = requestRepository.findByIdOrThrow(requestId);

    if (!request.isApproved()) {
        throw new BusinessException("承認済みの申請のみ取消できます。");
    }

    if (today.isAfter(request.getClosingDate())) {
        throw new BusinessException("締め日を過ぎているため取消できません。");
    }

    request.cancel();
}

 

2. レイヤー設計が崩れて保守しにくくなる

Spring BootではController、Service、Repositoryを簡単に作れますが、役割を明確にしないと責務が崩れます。Controllerに業務ロジックが入り、Serviceが肥大化し、Repositoryに業務判断が混ざると、短期的には動いても長期保守が難しくなります。

2.1 Controllerに業務ロジックを書いてしまう

ControllerはHTTPリクエストを受け取り、Serviceを呼び出し、レスポンスを返す層です。ここに状態チェックやDB更新、メール送信、外部API連携を書くと、Web層と業務層が密結合になります。

改善策は、Controllerを薄く保つことです。業務判断はApplication ServiceまたはDomain Serviceへ移動します。

例:薄いControllerにする

注記:ファイル名:OrderController.java / 使用言語:Java

 

@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {

    private final OrderApplicationService orderApplicationService;

    @PostMapping
    public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) {
        OrderResponse response = orderApplicationService.createOrder(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }
}

 

2.2 Serviceが巨大化する

CustomerServiceやOrderServiceに全ての処理を集めると、数千行の巨大クラスになります。会員登録、退会、メール送信、ポイント計算、外部CRM連携が一つに入ると、修正影響が読めなくなります。

改善策は、ユースケース単位でServiceを分けることです。RegisterCustomerUseCase、CancelOrderUseCaseのように目的を明確にします。

例:会員登録ユースケースを分ける

注記:ファイル名:RegisterCustomerUseCase.java / 使用言語:Java

 

@Service
@RequiredArgsConstructor
public class RegisterCustomerUseCase {

    private final CustomerRepository customerRepository;
    private final PasswordEncoder passwordEncoder;
    private final WelcomeMailSender welcomeMailSender;

    @Transactional
    public CustomerId register(RegisterCustomerCommand command) {
        Customer customer = Customer.register(
                command.email(),
                passwordEncoder.encode(command.rawPassword())
        );

        customerRepository.save(customer);
        welcomeMailSender.send(customer.getEmail());

        return customer.getId();
    }
}

 

2.3 Repositoryに業務判断を入れてしまう

Repositoryはデータアクセスを担当する層です。ここに「承認可能か」「キャンセル可能か」などの業務判断を入れると、業務ルールがSQLやRepositoryメソッドに散らばります。

改善策は、Repositoryでは検索と永続化に責務を限定し、業務判断はServiceやEntityで行うことです。Spring Data JPAはRepository実装を簡潔にできますが、業務責務の分離は設計側で決める必要があります。

例:Repositoryは検索条件に集中させる

注記:ファイル名:OrderRepository.java / 使用言語:Java

 

public interface OrderRepository extends JpaRepository<Order, Long> {

    List<Order> findByCustomerIdAndStatus(Long customerId, OrderStatus status);
}

 

2.4 DTOとEntityを混同する

EntityをそのままAPIレスポンスにすると、DB構造が外部契約になります。内部項目、パスワードハッシュ、削除フラグ、監査項目が外に出る危険もあります。

改善策は、Request DTO、Response DTO、Entityを分離することです。APIで返す項目を明示的に定義します。

例:EntityからResponse DTOへ変換する

注記:ファイル名:UserResponse.java / 使用言語:Java

 

public record UserResponse(
        Long id,
        String name,
        String email
) {
    public static UserResponse from(User user) {
        return new UserResponse(
                user.getId(),
                user.getName(),
                user.getEmail()
        );
    }
}

 

2.5 パッケージ構成が技術単位だけになる

controller、service、repository、entityだけで分けると、機能が増えたときに関連コードが散らばります。注文機能を直すために複数パッケージを横断する必要が出ます。

改善策は、業務機能単位のパッケージ設計を検討することです。customer、order、billing、authのように機能でまとめると、変更対象が分かりやすくなります。

例:機能単位のパッケージ構成

注記:ファイル名:project-structure.txt / 使用言語:Text

com.example.app
  ├── customer
  │   ├── CustomerController.java
  │   ├── CustomerApplicationService.java
  │   ├── CustomerRepository.java
  │   └── Customer.java
  ├── order
  │   ├── OrderController.java
  │   ├── OrderApplicationService.java
  │   ├── OrderRepository.java
  │   └── Order.java
  └── shared
      ├── exception
      └── security

 

3. 設定管理が環境ごとに乱れる

Spring Bootは外部設定に強く、properties、YAML、環境変数などから設定を読み込めます。公式ドキュメントでも外部設定の仕組みが整理されていますが、ルールを決めないと開発、検証、本番で設定差分が乱れます。

3.1 application.ymlが肥大化する

全ての設定を一つのapplication.ymlに入れると、DB、外部API、ログ、メール、業務設定が混在します。環境差分も分かりにくくなり、誤設定の原因になります。

改善策は、共通設定と環境別設定を分けることです。dev、stg、prodのプロファイルを使い、環境差分を明確にします。

例:環境別プロファイルを使う

注記:ファイル名:application.yml / 使用言語:YAML

 

spring:
  application:
    name: member-system

app:
  mail:
    from: [email protected]

---
spring:
  config:
    activate:
      on-profile: dev

app:
  external-api:
    base-url: https://dev-api.example.com

 

3.2 @Valueを多用して設定が散らばる

@Valueで設定値を個別に読むと、どのクラスがどの設定に依存しているか分かりにくくなります。設定名のタイポにも気づきにくいです。

改善策は、@ConfigurationPropertiesで設定を型付きクラスにまとめることです。関連設定を一つのクラスに集約できます。

例:決済設定を型付きで管理する

注記:ファイル名:PaymentProperties.java / 使用言語:Java

 

@ConfigurationProperties(prefix = "app.payment")
public record PaymentProperties(
        String provider,
        String apiBaseUrl,
        Duration timeout
) {}

 

例:対応するYAML設定

注記:ファイル名:application.yml / 使用言語:YAML

 

app:
  payment:
    provider: stripe
    api-base-url: https://api.example-payment.com
    timeout: 3s

 

3.3 秘密情報を設定ファイルに書いてしまう

DBパスワード、APIキー、JWT署名鍵をGit管理される設定ファイルに直接書くと危険です。履歴に残るため、後から削除するのも難しくなります。

改善策は、秘密情報を環境変数やSecret Managerで管理し、Spring Boot側ではプレースホルダーで受け取ることです。

例:秘密情報を環境変数から読む

注記:ファイル名:application-prod.yml / 使用言語:YAML

 

spring:
  datasource:
    url: ${DB_URL}
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}

jwt:
  secret: ${JWT_SECRET}

 

3.4 設定値の検証をしていない

外部API URLが空、メールポートが0、タイムアウトが未設定でも、特定機能を使うまで気づかないことがあります。本番で初めて問題になると対応が遅れます。

改善策は、設定クラスにバリデーションを付け、起動時に不正設定を検出することです。

例:設定値を起動時に検証する

注記:ファイル名:MailProperties.java / 使用言語:Java

 

@Validated
@ConfigurationProperties(prefix = "app.mail")
public record MailProperties(
        @NotBlank String from,
        @NotBlank String host,
        @Min(1) int port
) {}

 

3.5 本番設定の変更履歴が残らない

本番環境の設定を手作業で変更すると、いつ、誰が、なぜ変えたのか分からなくなります。障害時に原因追跡が難しくなります。

改善策は、設定もコードとして管理し、Pull RequestとCI/CDで変更することです。秘密値そのものはSecretに置き、設定構造はGit管理します。

例:設定変更の記録フォーマット

注記:ファイル名:config-change-log.md / 使用言語:Markdown

 

## 変更内容

- 外部API timeout を 2s から 5s に変更

## 理由

月末処理時に外部API応答が遅くなるため。

## 影響範囲

- 請求連携API
- 外部CRM同期バッチ

## 承認者

- システム責任者

 

4. REST API設計が場当たり的になる

Spring Bootでは@RestControllerで簡単にAPIを作れますが、URI、HTTPメソッド、ステータスコード、エラーレスポンス、ページング、バージョニングが統一されていないと、企業システムでは使いにくいAPIになります。

4.1 URI設計にルールがない

/users/list、/getUser、/user/detail?id=1のようなURIが混在すると、API利用者が理解しにくくなります。

改善策は、リソース中心のURI設計にすることです。ユーザー一覧はGET /users、詳細はGET /users/{id}、作成はPOST /usersのように統一します。

例:リソース中心のController

注記:ファイル名:UserController.java / 使用言語:Java

 

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    @GetMapping("/{id}")
    public UserResponse get(@PathVariable Long id) {
        return userQueryService.getById(id);
    }

    @PostMapping
    public UserResponse create(@Valid @RequestBody CreateUserRequest request) {
        return userCommandService.create(request);
    }
}

 

4.2 HTTPステータスコードを適切に使っていない

全て200 OKで返し、body内の独自codeで成功・失敗を表すと、フロントエンドや外部システムが扱いにくくなります。

改善策は、HTTPの意味に合わせてステータスコードを使うことです。作成成功は201、入力不正は400、認証失敗は401、権限不足は403、未存在は404を使います。

例:作成成功時に201 Createdを返す

注記:ファイル名:UserController.java / 使用言語:Java

 

@PostMapping
public ResponseEntity<CreateUserResponse> create(@Valid @RequestBody CreateUserRequest request) {
    CreateUserResponse response = userService.create(request);
    URI location = URI.create("/api/users/" + response.id());
    return ResponseEntity.created(location).body(response);
}

 

4.3 Request DTOのバリデーションが不足している

入力値チェックをService内のif文だけで行うと、必須、文字数、形式のチェックがばらつきます。

改善策は、Request DTOにBean Validationを付けることです。形式チェックはDTOで行い、業務チェックはServiceで行います。

例:Request DTOに入力制約を付ける

注記:ファイル名:CreateEmployeeRequest.java / 使用言語:Java

 

public record CreateEmployeeRequest(
        @NotBlank
        @Size(max = 100)
        String name,

        @Email
        @NotBlank
        String email,

        @NotNull
        Long departmentId
) {}

 

4.4 ページングとソートを後から追加する

一覧APIで全件取得すると、データが増えたときに性能問題が起こります。Spring Data JPAではPageableによるページングが利用できます。

改善策は、一覧APIでは初期からPageableを使うことです。

例:Pageableを使った一覧API

注記:ファイル名:CustomerController.java / 使用言語:Java

 

@GetMapping("/customers")
public Page<CustomerListResponse> search(Pageable pageable) {
    return customerRepository.findAll(pageable)
            .map(CustomerListResponse::from);
}

 

例:API呼び出し

注記:ファイル名:api-request-example.txt / 使用言語:Text

GET /api/customers?page=0&size=20&sort=createdAt,desc

 

4.5 APIバージョニングを考えていない

外部システムやモバイルアプリがAPIを使う場合、レスポンス形式の変更が大きな影響を与えます。バージョン管理がないと互換性を維持できません。

改善策は、初期から/api/v1のようにバージョンを明示することです。

例:v1 APIとしてControllerを定義する

注記:ファイル名:InvoiceV1Controller.java / 使用言語:Java

 

@RestController
@RequestMapping("/api/v1/invoices")
public class InvoiceV1Controller {

    @GetMapping("/{id}")
    public InvoiceResponseV1 get(@PathVariable Long id) {
        return invoiceService.getV1(id);
    }
}

 

5. 例外処理が統一されていない

APIごとにtry-catchを書くと、エラー形式、ログ、HTTPステータスがばらばらになります。企業システムでは、フロントエンド表示、外部連携、障害調査のために例外処理を統一する必要があります。

5.1 Controllerごとにtry-catchを書く

Controllerごとのtry-catchは重複と漏れを生みます。同じ例外をあるAPIでは400、別のAPIでは500で返すような不整合が起こります。

改善策は、@RestControllerAdviceで例外処理を集約することです。

例:共通例外ハンドラー

注記:ファイル名:ApiExceptionHandler.java / 使用言語:Java

 

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ApiErrorResponse> handleNotFound(ResourceNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(new ApiErrorResponse("NOT_FOUND", ex.getMessage()));
    }

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ApiErrorResponse> handleBusiness(BusinessException ex) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(new ApiErrorResponse("BUSINESS_ERROR", ex.getMessage()));
    }
}

 

5.2 業務例外とシステム例外を区別していない

在庫不足や締め日超過は業務例外ですが、DB接続失敗やNullPointerExceptionはシステム例外です。これらを同じ扱いにすると、ユーザー表示もログレベルも不適切になります。

改善策は、BusinessExceptionを定義し、ユーザーに見せてよいメッセージだけを持たせることです。

例:業務例外クラス

注記:ファイル名:BusinessException.java / 使用言語:Java

 

public class BusinessException extends RuntimeException {

    private final String errorCode;

    public BusinessException(String errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }

    public String getErrorCode() {
        return errorCode;
    }
}

 

5.3 エラーレスポンスに内部情報を出してしまう

例外メッセージをそのまま返すと、SQL、クラス名、ファイルパスなど内部情報が漏れる可能性があります。

改善策は、レスポンスには安全なメッセージを返し、詳細はログに残すことです。

例:システム例外の安全な返却

注記:ファイル名:ApiExceptionHandler.java / 使用言語:Java

 

@ExceptionHandler(Exception.class)
public ResponseEntity<ApiErrorResponse> handleUnexpected(Exception ex) {
    log.error("Unexpected system error occurred", ex);

    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ApiErrorResponse(
                    "INTERNAL_SERVER_ERROR",
                    "システムエラーが発生しました。時間をおいて再度お試しください。"
            ));
}

 

5.4 バリデーションエラーの形式が使いにくい

「Bad Request」だけでは、どの項目を修正すべきか分かりません。入力フォームでは項目別エラーが必要です。

改善策は、MethodArgumentNotValidExceptionを処理し、fieldごとのエラーを返すことです。

例:項目別バリデーションエラー

注記:ファイル名:ApiExceptionHandler.java / 使用言語:Java

 

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
    List<FieldErrorResponse> fields = ex.getBindingResult().getFieldErrors().stream()
            .map(error -> new FieldErrorResponse(error.getField(), error.getDefaultMessage()))
            .toList();

    return ResponseEntity.badRequest()
            .body(new ValidationErrorResponse("VALIDATION_ERROR", fields));
}

 

5.5 例外発生時のログが追跡できない

リクエストIDがないログでは、障害時にどのAPI呼び出しで起きたエラーなのか追跡しにくくなります。

改善策は、MDCでリクエストIDをログへ埋め込むことです。

例:リクエストIDを付与するFilter

注記:ファイル名:RequestIdFilter.java / 使用言語:Java

 

@Component
public class RequestIdFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {

        String requestId = UUID.randomUUID().toString();
        MDC.put("requestId", requestId);
        response.setHeader("X-Request-Id", requestId);

        try {
            filterChain.doFilter(request, response);
        } finally {
            MDC.remove("requestId");
        }
    }
}

 

6. JPA利用で性能問題が起こる

Spring Data JPAはRepository実装を簡潔にできますが、JPAの挙動を理解しないまま使うと、N+1問題、LazyInitializationException、不要な更新SQL、Entity設計の肥大化が起こります。Spring Data JPAはJPAベースのRepository開発を支援しますが、DB設計やクエリ設計はプロジェクト側で行う必要があります。

6.1 Entityが単なるDBテーブルの写しになる

Entityがgetter/setterだけになると、業務ルールがServiceへ散らばります。注文キャンセル、会員停止、請求確定などの判断が複数箇所に重複しやすくなります。

改善策は、Entityに業務上の振る舞いを持たせることです。

例:注文キャンセルルールをEntityに持たせる

注記:ファイル名:Order.java / 使用言語:Java

 

@Entity
public class Order {

    @Id
    private Long id;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    public void cancel() {
        if (status == OrderStatus.SHIPPED) {
            throw new BusinessException("ORDER_ALREADY_SHIPPED", "出荷済みの注文はキャンセルできません。");
        }
        this.status = OrderStatus.CANCELLED;
    }
}

 

6.2 N+1問題に気づかない

一覧画面で注文を取得し、ループ内で顧客情報を参照すると、注文件数分の追加SQLが発生する場合があります。

改善策は、JOIN FETCHやDTO Projectionで必要なデータをまとめて取得することです。

例:JOIN FETCHで関連Entityを取得する

注記:ファイル名:OrderRepository.java / 使用言語:Java

 

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("""
        select o from Order o
        join fetch o.customer
        where o.status = :status
    """)
    List<Order> findWithCustomerByStatus(@Param("status") OrderStatus status);
}

 

6.3 LazyInitializationExceptionが発生する

Serviceのトランザクション外で遅延ロードされた関連Entityへアクセスすると、LazyInitializationExceptionが起こります。

改善策は、Service内のトランザクション境界で必要データを取得し、DTOへ変換して返すことです。

例:詳細取得時に必要な関連情報を明示的に取得する

注記:ファイル名:OrderQueryService.java / 使用言語:Java

 

@Service
@RequiredArgsConstructor
public class OrderQueryService {

    private final OrderRepository orderRepository;

    @Transactional(readOnly = true)
    public OrderDetailResponse getDetail(Long id) {
        Order order = orderRepository.findDetailById(id)
                .orElseThrow(() -> new ResourceNotFoundException("注文が見つかりません。"));

        return OrderDetailResponse.from(order);
    }
}

 

6.4 EntityをそのままJSON化する

双方向関連を持つEntityをそのまま返すと、JSON無限ループや不要情報の露出が起こります。

改善策は、Response DTOに変換してから返すことです。

例:注文詳細DTOへ変換する

注記:ファイル名:OrderDetailResponse.java / 使用言語:Java

 

public record OrderDetailResponse(
        Long id,
        String customerName,
        List<OrderItemResponse> items
) {
    public static OrderDetailResponse from(Order order) {
        return new OrderDetailResponse(
                order.getId(),
                order.getCustomer().getName(),
                order.getItems().stream()
                        .map(OrderItemResponse::from)
                        .toList()
        );
    }
}

 

6.5 DBマイグレーションを手作業で行う

開発者が手作業でDDLを流すと、環境ごとの差分が発生します。検証環境では動くが本番ではカラムがない、という問題が起こります。

改善策は、FlywayやLiquibaseでDB変更をバージョン管理することです。

例:FlywayマイグレーションSQL

注記:ファイル名:V1__create_customer_table.sql / 使用言語:SQL

 

CREATE TABLE customers (
    id BIGINT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(255) NOT NULL UNIQUE,
    created_at TIMESTAMP NOT NULL
);

 

7. トランザクション境界が曖昧になる

@Transactionalは便利ですが、付ける場所を誤るとロック時間が長くなったり、ロールバックされなかったり、自己呼び出しで効かなかったりします。

7.1 Controllerに@Transactionalを付ける

Controllerに@Transactionalを付けると、HTTP処理全体がトランザクションになり、外部API呼び出しやレスポンス生成までDBトランザクション内に入る可能性があります。

改善策は、Service層に@Transactionalを置くことです。

例:Serviceにトランザクションを置く

注記:ファイル名:PaymentApplicationService.java / 使用言語:Java

 

@Service
@RequiredArgsConstructor
public class PaymentApplicationService {

    private final PaymentRepository paymentRepository;

    @Transactional
    public void confirmPayment(Long paymentId) {
        Payment payment = paymentRepository.findByIdOrThrow(paymentId);
        payment.confirm();
    }
}

 

7.2 readOnlyを使っていない

参照処理にも通常の@Transactionalを使うと、意図が不明確になります。参照専用処理はreadOnlyを付けると読みやすくなります。

改善策は、Query Serviceに@Transactional(readOnly = true)を付けることです。

例:参照処理にreadOnlyを付ける

注記:ファイル名:CustomerQueryService.java / 使用言語:Java

 

@Service
@RequiredArgsConstructor
public class CustomerQueryService {

    private final CustomerRepository customerRepository;

    @Transactional(readOnly = true)
    public CustomerDetailResponse getDetail(Long id) {
        Customer customer = customerRepository.findByIdOrThrow(id);
        return CustomerDetailResponse.from(customer);
    }
}

 

7.3 外部API呼び出しをトランザクション内で行う

DB更新中に外部APIを呼ぶと、外部APIの遅延でDBロックが長くなります。決済、CRM、メール連携で問題になりやすいです。

改善策は、DBコミット後にイベントで外部連携することです。

例:コミット後に外部連携イベントを処理する

注記:ファイル名:CustomerRegisteredListener.java / 使用言語:Java

 

@Component
@RequiredArgsConstructor
public class CustomerRegisteredListener {

    private final CrmClient crmClient;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handle(CustomerRegisteredEvent event) {
        crmClient.createCustomer(event.customerId());
    }
}

 

7.4 ロールバック対象を理解していない

@Transactionalは例外の種類によってロールバック挙動が変わります。チェック例外でロールバックされないケースを理解していないと、データが中途半端に保存される可能性があります。

改善策は、業務例外設計とrollbackForの利用方針を決めることです。

例:rollbackForを明示する

注記:ファイル名:BillingApplicationService.java / 使用言語:Java

 

@Transactional(rollbackFor = ExternalServiceException.class)
public void executeBilling(Long customerId) throws ExternalServiceException {
    Billing billing = billingRepository.findPending(customerId);
    billing.markProcessing();

    paymentGateway.charge(billing.getAmount());

    billing.markCompleted();
}

 

7.5 自己呼び出しで@Transactionalが効かない

同じクラス内でthis.someMethod()を呼ぶと、Spring AOPのプロキシを通らず@Transactionalが期待通りに効かない場合があります。

改善策は、トランザクション境界を別Beanに分けることです。

例:自己呼び出しを避ける

注記:ファイル名:ReportBatchService.java / 使用言語:Java

 

@Service
@RequiredArgsConstructor
public class ReportBatchService {

    private final ReportUnitService reportUnitService;

    public void generateAll() {
        reportUnitService.generateOne();
    }
}

@Service
public class ReportUnitService {

    @Transactional
    public void generateOne() {
        // DB更新
    }
}

 

8. 認証・認可設計が不足する

Spring Securityを導入していても、認証と認可の設計が不十分だと不正アクセスや権限漏れが起こります。Spring Securityは認証、認可、一般的な攻撃への防御を提供しますが、業務権限の設計はプロジェクト側で行う必要があります。

8.1 ログインできれば全機能を使える設計になる

ログイン済みであれば全APIを呼べる設計は危険です。画面でボタンを隠しても、APIを直接呼ばれれば操作される可能性があります。

改善策は、APIやServiceメソッドごとに認可を入れることです。

例:承認権限を持つユーザーだけ許可する

注記:ファイル名:InvoiceController.java / 使用言語:Java

 

@PreAuthorize("hasAuthority('INVOICE_APPROVE')")
@PostMapping("/invoices/{id}/approve")
public void approve(@PathVariable Long id) {
    invoiceService.approve(id);
}

 

8.2 URL認可だけでデータ権限を見ていない

/admin/**を管理者だけに制限しても、営業担当Aが営業担当Bの顧客を見られる問題は防げません。データ単位の認可が必要です。

改善策は、Service内でログインユーザーと対象データの関係を確認することです。

例:部門単位で顧客閲覧を制御する

注記:ファイル名:CustomerQueryService.java / 使用言語:Java

 

public CustomerDetailResponse getCustomerDetail(Long customerId, LoginUser loginUser) {
    Customer customer = customerRepository.findByIdOrThrow(customerId);

    if (!customer.belongsToDepartment(loginUser.departmentId())) {
        throw new AccessDeniedException("この顧客情報を閲覧する権限がありません。");
    }

    return CustomerDetailResponse.from(customer);
}

 

8.3 SecurityConfigが複雑化する

SecurityConfigに認証方式、CORS、CSRF、URL認可、例外処理、セッション管理を全部詰め込むと、設定意図が分かりにくくなります。

改善策は、対象範囲を分け、API用、管理画面用などに整理することです。

例:API専用のSecurityFilterChain

注記:ファイル名:SecurityConfig.java / 使用言語:Java

 

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
        return http
                .securityMatcher("/api/**")
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/public/**").permitAll()
                        .anyRequest().authenticated()
                )
                .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
                .build();
    }
}

 

8.4 CSRF対策を理解せず無効化する

Spring Securityは安全でないHTTPメソッドに対してCSRF保護を提供します。API構成によっては無効化する場合もありますが、理由なく無効化するのは危険です。

改善策は、認証方式に応じてCSRFを判断することです。Cookieとセッションを使う画面アプリでは慎重に扱います。

例:一部パスだけCSRF対象外にする

注記:ファイル名:SecurityConfig.java / 使用言語:Java

 

@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
    return http
            .csrf(csrf -> csrf
                    .ignoringRequestMatchers("/api/webhook/**")
            )
            .authorizeHttpRequests(auth -> auth
                    .anyRequest().authenticated()
            )
            .build();
}

 

8.5 セッション管理を設計していない

ログイン状態をどのくらい保持するか、同時ログインを許可するか、セッション切れ時にどうするかを決めないと、セキュリティとUXの両方で問題になります。

改善策は、セッション有効期限、同時ログイン数、ログアウト時のCookie削除を設計することです。

例:同時ログイン数を制御する

注記:ファイル名:SecurityConfig.java / 使用言語:Java

 

@Bean
SecurityFilterChain webSecurity(HttpSecurity http) throws Exception {
    return http
            .sessionManagement(session -> session
                    .maximumSessions(1)
                    .maxSessionsPreventsLogin(false)
            )
            .logout(logout -> logout
                    .deleteCookies("JSESSIONID")
                    .logoutSuccessUrl("/login?logout")
            )
            .build();
}

 

9. 入力バリデーションと業務チェックが混在する

形式チェックと業務チェックを混同すると、コードが読みにくくなります。メール形式や文字数はDTOで確認できますが、既存会員かどうか、締め日を過ぎているかは業務チェックです。

9.1 DTOで行うべきチェックがServiceに散らばる

必須、文字数、メール形式のチェックをServiceで書くと重複します。

改善策は、Request DTOで形式チェックを行うことです。

例:プロフィール更新Request

注記:ファイル名:UpdateProfileRequest.java / 使用言語:Java

 

public record UpdateProfileRequest(
        @NotBlank(message = "氏名は必須です。")
        @Size(max = 80, message = "氏名は80文字以内で入力してください。")
        String name,

        @Email(message = "メールアドレスの形式が正しくありません。")
        String email
) {}

 

9.2 業務チェックをDTOだけで済ませようとする

退会済みユーザーは注文できない、締め日後は編集できない、といった判断はDTOではできません。

改善策は、ServiceやDomainで業務状態を確認することです。

例:有効会員だけ注文可能にする

注記:ファイル名:OrderApplicationService.java / 使用言語:Java

 

@Transactional
public void createOrder(CreateOrderRequest request, Long customerId) {
    Customer customer = customerRepository.findByIdOrThrow(customerId);

    if (!customer.isActive()) {
        throw new BusinessException("CUSTOMER_INACTIVE", "有効な会員のみ注文できます。");
    }

    Order order = Order.create(customer, request.items());
    orderRepository.save(order);
}

 

9.3 カスタムバリデーションを使っていない

開始日と終了日の関係、パスワード確認一致など、複数項目のチェックは標準アノテーションだけでは表現しにくいです。

改善策は、クラスレベルのカスタムバリデーションを作ることです。

例:日付範囲のカスタムアノテーション

注記:ファイル名:ValidDateRange.java / 使用言語:Java

 

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DateRangeValidator.class)
public @interface ValidDateRange {
    String message() default "開始日は終了日以前にしてください。";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

 

9.4 バリデーションメッセージを多言語化していない

コード内に日本語メッセージを直接書くと、多言語対応や文言変更が難しくなります。

改善策は、messages.propertiesに外部化することです。

例:メッセージキーを使う

注記:ファイル名:CreateUserRequest.java / 使用言語:Java

 

public record CreateUserRequest(
        @NotBlank(message = "{user.name.required}")
        String name,

        @Email(message = "{user.email.invalid}")
        String email
) {}

 

例:日本語メッセージ

注記:ファイル名:messages_ja.properties / 使用言語:Properties

 

user.name.required=氏名は必須です。
user.email.invalid=メールアドレスの形式が正しくありません。

 

9.5 APIと画面でバリデーションルールがずれる

フロントエンドは80文字まで入力可能なのに、APIでは50文字でエラーになるようなズレが起こりやすいです。

改善策は、API仕様としてバリデーション制約を明示することです。

例:OpenAPI用の説明を付ける

注記:ファイル名:RegisterRequest.java / 使用言語:Java

 

@Schema(description = "会員登録リクエスト")
public record RegisterRequest(
        @Schema(description = "氏名", maxLength = 80, example = "山田 太郎")
        @NotBlank
        @Size(max = 80)
        String name,

        @Schema(description = "メールアドレス", example = "[email protected]")
        @Email
        String email
) {}

 

10. ログ設計が不足する

ログは障害調査、監査、性能分析に必要ですが、何を残すかを決めないと使えません。多すぎても少なすぎても問題です。特に個人情報や秘密情報をログに出さない設計が重要です。

10.1 System.out.printlnでログを出す

System.out.printlnではログレベル、出力先、フォーマット、検索性を制御できません。

改善策は、SLF4JのLoggerを使うことです。

例:Slf4jでログを出す

注記:ファイル名:OrderService.java / 使用言語:Java

 

@Slf4j
@Service
public class OrderService {

    public void cancel(Long orderId) {
        log.info("Cancel order requested. orderId={}", orderId);

        try {
            // cancel logic
        } catch (Exception e) {
            log.error("Cancel order failed. orderId={}", orderId, e);
            throw e;
        }
    }
}

 

10.2 個人情報をログに出してしまう

メールアドレス、電話番号、住所、トークン、パスワードをログに出すと、ログ基盤経由で情報漏えいする可能性があります。

改善策は、ID中心で記録し、必要な場合はマスキングすることです。

例:メールアドレスをマスキングする

注記:ファイル名:MaskingUtils.java / 使用言語:Java

 

public class MaskingUtils {

    public static String maskEmail(String email) {
        if (email == null || !email.contains("@")) {
            return "***";
        }
        String[] parts = email.split("@");
        return parts[0].charAt(0) + "***@" + parts[1];
    }
}

 

10.3 ログレベルの使い分けができていない

全てINFOや全てERRORにすると、重要なログが埋もれます。

改善策は、INFO、WARN、ERRORの基準を決めることです。

例:ログレベルを使い分ける

注記:ファイル名:InvoiceService.java / 使用言語:Java

 

log.info("Invoice approved. invoiceId={}, approvedBy={}", invoiceId, userId);

if (retryCount > 0) {
    log.warn("External API retry succeeded. invoiceId={}, retryCount={}", invoiceId, retryCount);
}

catch (ExternalApiException e) {
    log.error("External API failed. invoiceId={}", invoiceId, e);
}

 

10.4 業務監査ログがない

会員情報変更、権限変更、CSV出力、承認、削除などは、通常ログだけでなく監査ログとして残すべきです。

改善策は、AuditLogテーブルを作り、重要操作を保存することです。

例:監査ログEntity

注記:ファイル名:AuditLog.java / 使用言語:Java

 

@Entity
public class AuditLog {

    @Id
    @GeneratedValue
    private Long id;

    private Long actorUserId;
    private String action;
    private String targetType;
    private String targetId;
    private LocalDateTime occurredAt;
}

 

10.5 ログとメトリクスを分けていない

ログは個別イベント、メトリクスは傾向把握に向いています。Spring Boot ActuatorはMicrometerの自動設定やメトリクス収集を支援します。

改善策は、ログ、メトリクス、トレースの役割を分けることです。

例:業務メトリクスを記録する

注記:ファイル名:OrderMetrics.java / 使用言語:Java

 

@Component
@RequiredArgsConstructor
public class OrderMetrics {

    private final MeterRegistry meterRegistry;

    public void incrementOrderCreated() {
        meterRegistry.counter("orders.created").increment();
    }
}

 

11. テストが遅く不安定になる

Spring Bootでは@SpringBootTestで統合テストを書けますが、全テストでアプリ全体を起動すると遅くなります。Spring Bootには@WebMvcTestや@DataJpaTestなど、テスト対象を絞る仕組みがあります。

11.1 全て@SpringBootTestで書く

全テストでSpring全体を起動すると、CIが遅くなります。

改善策は、単体テスト、スライステスト、統合テストを分けることです。

例:Springを起動しない単体テスト

注記:ファイル名:PriceCalculatorTest.java / 使用言語:Java

 

@ExtendWith(MockitoExtension.class)
class PriceCalculatorTest {

    private final PriceCalculator calculator = new PriceCalculator();

    @Test
    void discountIsAppliedForPremiumCustomer() {
        Money result = calculator.calculate(1000, CustomerRank.PREMIUM);
        assertThat(result.amount()).isEqualTo(900);
    }
}

 

11.2 ControllerテストでDBまで起動している

Controllerの入出力を確認したいだけなのにDBまで起動すると、テストが重くなります。

改善策は、@WebMvcTestでWeb層だけを確認することです。

例:Controllerだけをテストする

注記:ファイル名:UserControllerTest.java / 使用言語:Java

 

@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockBean
    UserQueryService userQueryService;

    @Test
    void getUserReturnsOk() throws Exception {
        given(userQueryService.getById(1L))
                .willReturn(new UserResponse(1L, "Taro", "[email protected]"));

        mockMvc.perform(get("/api/users/1"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.name").value("Taro"));
    }
}

 

11.3 Repositoryテストが本番DBと違いすぎる

H2で動いても、PostgreSQLやMySQL本番環境でSQL方言や型の違いにより失敗する場合があります。

改善策は、Testcontainersで本番に近いDBを使うことです。

例:PostgreSQLでRepositoryテストを行う

注記:ファイル名:CustomerRepositoryTest.java / 使用言語:Java

 

@DataJpaTest
@Testcontainers
class CustomerRepositoryTest {

    @Container
    static PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:16");

    @DynamicPropertySource
    static void configure(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }
}

 

11.4 外部APIテストが不安定になる

テストで実際の外部APIを呼ぶと、ネットワークや外部サービスの状態で失敗します。

改善策は、Mock ServerやWireMockで外部APIを置き換えることです。

例:外部決済APIをMockする

注記:ファイル名:PaymentClientTest.java / 使用言語:Java

 

@Test
void paymentApiReturnsSuccess() {
    stubFor(post("/payments")
            .willReturn(okJson("""
                { "paymentId": "pay_123", "status": "SUCCESS" }
            """)));

    PaymentResult result = paymentClient.charge(new PaymentRequest(1000));

    assertThat(result.status()).isEqualTo("SUCCESS");
}

 

11.5 テストデータ作成が重複する

各テストで同じEntity作成コードを書くと、項目追加時に多くのテスト修正が必要になります。

改善策は、Test Data Builderを作ることです。

例:テストデータBuilder

注記:ファイル名:CustomerTestDataBuilder.java / 使用言語:Java

 

public class CustomerTestDataBuilder {

    private String name = "テスト太郎";
    private String email = "[email protected]";
    private CustomerStatus status = CustomerStatus.ACTIVE;

    public CustomerTestDataBuilder inactive() {
        this.status = CustomerStatus.INACTIVE;
        return this;
    }

    public Customer build() {
        return new Customer(name, email, status);
    }
}

 

12. 性能問題をリリース直前まで見つけられない

性能問題は、DBクエリ、キャッシュ、外部API、JSON変換、ログ、JVM設定など複数要因で起こります。開発初期から測定できる仕組みを入れることが重要です。

12.1 一覧画面で大量データを全件取得する

findAll()で全件取得すると、データが増えたときにメモリと応答時間が悪化します。

改善策は、一覧APIでは必ずページングを入れることです。

例:検索APIにPageableを使う

注記:ファイル名:OrderController.java / 使用言語:Java

 

@GetMapping("/orders")
public Page<OrderListResponse> searchOrders(
        @RequestParam(required = false) String keyword,
        Pageable pageable
) {
    return orderQueryService.search(keyword, pageable);
}

 

12.2 DBインデックスを考えていない

検索条件に使うカラムにインデックスがないと、データ量が増えたときに遅くなります。

改善策は、実際の検索条件とORDER BYをもとにインデックスを設計することです。

例:検索用インデックス

注記:ファイル名:V12__add_customer_index.sql / 使用言語:SQL

 

CREATE INDEX idx_customers_status_created_at
ON customers (status, created_at DESC);

 

12.3 キャッシュの使い方を誤る

マスターデータはキャッシュに向いていますが、会員個別情報や権限情報のキャッシュは注意が必要です。

改善策は、キャッシュ対象と失効ルールを決めることです。

例:プラン情報をキャッシュする

注記:ファイル名:PlanService.java / 使用言語:Java

 

@Service
@RequiredArgsConstructor
public class PlanService {

    private final PlanRepository planRepository;

    @Cacheable("plans")
    public List<PlanResponse> getActivePlans() {
        return planRepository.findByActiveTrue().stream()
                .map(PlanResponse::from)
                .toList();
    }
}

 

12.4 外部APIの遅延が全体性能を悪化させる

外部APIにタイムアウトがないと、応答待ちでスレッドが詰まります。

改善策は、HTTPクライアントにタイムアウトを設定することです。

例:RestClientにタイムアウトを設定する

注記:ファイル名:RestClientConfig.java / 使用言語:Java

 

@Bean
RestClient paymentRestClient(RestClient.Builder builder) {
    JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory();
    factory.setReadTimeout(Duration.ofSeconds(3));

    return builder
            .baseUrl("https://payment.example.com")
            .requestFactory(factory)
            .build();
}

 

12.5 性能測定を本番直前まで行わない

直前に性能問題が見つかると、設計変更が間に合いません。

改善策は、開発中からAPI応答時間を測定することです。ActuatorとMicrometerを使うと、メトリクス管理がしやすくなります。

例:処理時間をメトリクス化する

注記:ファイル名:OrderController.java / 使用言語:Java

 

@Timed(value = "orders.search", description = "注文検索APIの処理時間")
@GetMapping("/orders")
public Page<OrderListResponse> search(Pageable pageable) {
    return orderQueryService.search(pageable);
}

 

13. 非同期処理とバッチ処理の設計が弱い

企業システムでは、メール送信、CSV出力、外部連携、請求処理、期限切れ処理など、非同期処理やバッチ処理が必要になります。Spring Frameworkはスケジューリングや非同期実行の仕組みを提供しています。

13.1 重い処理をHTTPリクエスト内で実行する

CSV生成や大量メール送信を同期処理にすると、タイムアウトやUX悪化が起きます。

改善策は、ジョブ化して非同期実行することです。

例:CSV出力ジョブを登録する

注記:ファイル名:ExportController.java / 使用言語:Java

 

@PostMapping("/exports/customers")
public ExportJobResponse requestExport(@AuthenticationPrincipal LoginUser user) {
    Long jobId = exportJobService.createCustomerExportJob(user.id());
    return new ExportJobResponse(jobId, "ACCEPTED");
}

 

13.2 @Asyncのスレッドプールを設定していない

@Asyncをデフォルトで使うと、スレッド数やキューが制御されず、負荷時に不安定になります。

改善策は、用途別TaskExecutorを定義することです。

例:メール送信用Executor

注記:ファイル名:AsyncConfig.java / 使用言語:Java

 

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "mailTaskExecutor")
    public Executor mailTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("mail-");
        executor.initialize();
        return executor;
    }
}

 

13.3 @Scheduledが複数台構成で重複実行される

複数インスタンスで動くと、@Scheduledが全台で実行され、請求処理やメール送信が重複する可能性があります。

改善策は、分散ロックやジョブ管理基盤を使うことです。

例:ジョブロックを使う

注記:ファイル名:DailyBillingScheduler.java / 使用言語:Java

 

@Scheduled(cron = "0 0 2 * * *")
public void runDailyBilling() {
    if (!jobLockService.tryLock("daily-billing")) {
        return;
    }

    try {
        billingService.executeDailyBilling();
    } finally {
        jobLockService.unlock("daily-billing");
    }
}

 

13.4 バッチ処理のリトライ設計がない

外部APIやメール送信は失敗します。失敗時に再実行できないと、手動対応が増えます。

改善策は、ジョブステータスとリトライ回数をDBで管理することです。

例:ジョブステータス

注記:ファイル名:JobStatus.java / 使用言語:Java

 

public enum JobStatus {
    PENDING,
    RUNNING,
    SUCCESS,
    FAILED,
    RETRY_WAITING
}

 

13.5 バッチ処理の監視がない

バッチが失敗しても誰も気づかない状態は危険です。

改善策は、ジョブ成功時刻、失敗件数、リトライ数を監視することです。

例:ジョブ失敗メトリクス

注記:ファイル名:JobMetrics.java / 使用言語:Java

 

@Component
@RequiredArgsConstructor
public class JobMetrics {

    private final MeterRegistry meterRegistry;

    public void recordJobFailure(String jobName) {
        meterRegistry.counter("batch.job.failure", "job", jobName).increment();
    }
}

 

14. 外部連携で障害が広がる

企業システムでは、CRM、決済、メール、会計、認証基盤など多くの外部システムと連携します。外部連携は自社で制御できないため、失敗、遅延、仕様変更を前提に設計する必要があります。

14.1 外部API失敗時の代替処理がない

CRM連携が失敗しただけで会員登録全体を失敗にするべきかは、業務によって異なります。

改善策は、同期必須の連携と後追い可能な連携を分けることです。

例:CRM同期をジョブ化する

注記:ファイル名:CustomerRegistrationService.java / 使用言語:Java

 

@Transactional
public Long registerCustomer(RegisterCustomerCommand command) {
    Customer customer = customerRepository.save(Customer.register(command.email()));

    crmSyncJobRepository.save(CrmSyncJob.pending(customer.getId()));

    return customer.getId();
}

 

14.2 タイムアウト設定がない

外部APIが応答しないと、自システムのスレッドが詰まります。

改善策は、接続タイムアウトと読み取りタイムアウトを設定することです。

例:WebClientにタイムアウトを設定する

注記:ファイル名:WebClientConfig.java / 使用言語:Java

 

@Bean
WebClient webClient(WebClient.Builder builder) {
    HttpClient httpClient = HttpClient.create()
            .responseTimeout(Duration.ofSeconds(5));

    return builder
            .clientConnector(new ReactorClientHttpConnector(httpClient))
            .baseUrl("https://api.partner.example.com")
            .build();
}

 

14.3 リトライで二重処理が発生する

決済APIなどを単純にリトライすると、二重課金のような事故が起こります。

改善策は、冪等性キーを使うことです。

例:決済に冪等性キーを付ける

注記:ファイル名:PaymentClient.java / 使用言語:Java

 

public PaymentResult charge(Long orderId, BigDecimal amount) {
    String idempotencyKey = "order-" + orderId;

    return paymentClient.charge(new PaymentRequest(
            amount,
            idempotencyKey
    ));
}

 

14.4 外部APIの仕様変更に弱い

外部APIのレスポンス項目が増えただけで失敗する実装は保守しにくいです。

改善策は、外部API DTOと内部モデルを分離し、未知項目を許容することです。

例:未知項目を無視するDTO

注記:ファイル名:PartnerCustomerResponse.java / 使用言語:Java

 

@JsonIgnoreProperties(ignoreUnknown = true)
public record PartnerCustomerResponse(
        String customerId,
        String status
) {}

 

14.5 連携ログが不足している

外部API障害時に、いつ、どの業務IDで、どのレスポンスコードだったか分からないと調査できません。

改善策は、外部連携専用ログを設計することです。

例:外部API連携ログ

注記:ファイル名:CrmClient.java / 使用言語:Java

 

log.info("Partner API called. system={}, endpoint={}, status={}, durationMs={}, businessId={}",
        "CRM",
        "/customers",
        responseStatus,
        durationMs,
        customerId
);

 

15. セキュリティ対策が後付けになる

Spring Boot開発では、セキュリティを公開直前にまとめて対応しようとすると手戻りが大きくなります。認証、認可、入力検証、CSRF、CORS、依存関係管理、管理画面保護を初期から設計する必要があります。

15.1 認証だけで安全だと思ってしまう

ログインできるかどうかだけでは安全ではありません。重要なのは、ログイン後に何をしてよいかを制御する認可です。

改善策は、データ単位の認可チェックを行うことです。

例:プロジェクト閲覧権限をBeanで確認する

注記:ファイル名:ProjectController.java / 使用言語:Java

 

@PreAuthorize("@projectSecurity.canReadProject(#projectId, authentication)")
@GetMapping("/projects/{projectId}")
public ProjectResponse getProject(@PathVariable Long projectId) {
    return projectService.getProject(projectId);
}

 

15.2 CORS設定を広く開けすぎる

allowedOrigins("*")を本番で使うと、意図しないOriginからAPIを呼ばれる可能性があります。

改善策は、本番ドメインだけを明示的に許可することです。

例:CORSの許可Originを限定する

注記:ファイル名:CorsConfig.java / 使用言語:Java

 

@Bean
CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("https://app.example.com"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
    config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
    config.setAllowCredentials(true);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    return source;
}

 

15.3 ファイルアップロードの検査が甘い

拡張子だけでファイルを判断すると危険です。巨大ファイル、実行可能ファイル、ウイルス、パストラバーサルを考慮する必要があります。

改善策は、サイズ制限、MIMEチェック、保存名再生成、保存先分離を行うことです。

例:アップロードサイズをチェックする

注記:ファイル名:FileUploadController.java / 使用言語:Java

 

@PostMapping("/files")
public FileUploadResponse upload(@RequestParam MultipartFile file) {
    if (file.isEmpty()) {
        throw new BusinessException("FILE_EMPTY", "ファイルが空です。");
    }

    if (file.getSize() > 10 * 1024 * 1024) {
        throw new BusinessException("FILE_TOO_LARGE", "10MB以下のファイルを選択してください。");
    }

    String storedName = UUID.randomUUID() + ".bin";
    return fileStorageService.store(file, storedName);
}

 

15.4 依存ライブラリの脆弱性管理をしていない

Spring Boot、Spring Security、JDBCドライバなどの依存関係は継続的に更新されます。放置すると脆弱性対応が遅れます。

改善策は、依存関係更新をCIや定期保守に組み込むことです。

例:Dependabot設定

注記:ファイル名:.github/dependabot.yml / 使用言語:YAML

 

version: 2
updates:
  - package-ecosystem: "maven"
    directory: "/"
    schedule:
      interval: "weekly"

 

15.5 管理画面の防御が弱い

管理画面は一般画面より影響が大きいため、強い認証、権限制御、操作ログが必要です。

改善策は、管理画面専用のSecurityFilterChainや操作監査ログを設計することです。

例:管理画面をADMINだけに限定する

注記:ファイル名:AdminSecurityConfig.java / 使用言語:Java

 

@Bean
SecurityFilterChain adminSecurity(HttpSecurity http) throws Exception {
    return http
            .securityMatcher("/admin/**")
            .authorizeHttpRequests(auth -> auth
                    .anyRequest().hasRole("ADMIN")
            )
            .formLogin(Customizer.withDefaults())
            .build();
}

 

16. フロントエンド連携で認識ズレが起こる

Spring BootをAPIバックエンドとして使う場合、React、Vue、Angular、モバイルアプリとの連携が重要になります。API仕様、エラー形式、認証方式、日付形式、CORS、Cookie設定が曖昧だと、結合時に手戻りが増えます。

16.1 API仕様書が実装とずれる

手作業のAPI仕様書だけでは、実装変更に追従しにくくなります。

改善策は、OpenAPIを使い、実装と近い形でAPI仕様を管理することです。

例:API説明アノテーションを付ける

注記:ファイル名:MemberController.java / 使用言語:Java

 

@Operation(summary = "会員詳細を取得する")
@GetMapping("/api/v1/members/{id}")
public MemberResponse getMember(
        @Parameter(description = "会員ID")
        @PathVariable Long id
) {
    return memberQueryService.getMember(id);
}

 

16.2 日付とタイムゾーンの扱いが曖昧

日付形式がAPIごとに違うと、フロントエンドの変換処理が複雑になります。

改善策は、LocalDate、OffsetDateTime、Instantの使い分けを決めることです。

例:日付と日時を分けて返す

注記:ファイル名:ReservationResponse.java / 使用言語:Java

 

public record ReservationResponse(
        Long id,
        LocalDate reservedDate,
        OffsetDateTime createdAt
) {}

 

16.3 エラー表示に必要な情報が足りない

フロントエンドでエラー表示するには、エラーコード、メッセージ、フィールド名が必要です。

改善策は、エラーレスポンス形式を統一することです。

例:共通エラーレスポンス

注記:ファイル名:ApiErrorResponse.java / 使用言語:Java

 

public record ApiErrorResponse(
        String code,
        String message,
        List<FieldErrorResponse> fields
) {}

 

16.4 認証トークンの更新フローがない

アクセストークン期限切れ時の更新フローがないと、ユーザーが突然ログアウトされます。

改善策は、refresh APIと再ログイン条件を設計することです。

例:トークン更新API

注記:ファイル名:AuthController.java / 使用言語:Java

 

@PostMapping("/auth/refresh")
public TokenResponse refresh(@RequestBody RefreshTokenRequest request) {
    return authService.refresh(request.refreshToken());
}

 

16.5 CORSとCookie設定で詰まる

別ドメイン構成では、SameSite、Secure、CORS credentialsの設定が重要になります。

改善策は、Cookie属性とCORS設定をセットで確認することです。

例:Cookie属性を明示する

注記:ファイル名:CookieService.java / 使用言語:Java

 

ResponseCookie cookie = ResponseCookie.from("SESSION", sessionId)
        .httpOnly(true)
        .secure(true)
        .sameSite("None")
        .path("/")
        .maxAge(Duration.ofHours(1))
        .build();

response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());

 

17. メール通知と通知設計で問題が起こる

会員登録、パスワード再設定、承認、請求、問い合わせ返信など、企業システムではメール通知が多く発生します。同期送信、誤送信、テンプレート管理、送信ログを設計しないと運用で問題になります。

17.1 メール送信を同期処理で行う

登録API内でメール送信を同期実行すると、メールサーバー遅延でAPI応答が遅くなります。

改善策は、メール送信をジョブ化することです。

例:メール送信依頼をキューに保存する

注記:ファイル名:RegisterService.java / 使用言語:Java

 

@Transactional
public void register(RegisterRequest request) {
    Customer customer = customerRepository.save(Customer.register(request.email()));

    mailQueueRepository.save(MailQueue.welcome(customer.getId(), customer.getEmail()));
}

 

17.2 メールテンプレートをコードに埋め込む

本文をJavaコードに直接書くと、文言修正や多言語対応が難しくなります。

改善策は、テンプレートファイルを使うことです。

例:Thymeleafメールテンプレート

注記:ファイル名:templates/mail/welcome.html / 使用言語:HTML

 

<p th:text="${name} + '様'"></p>
<p>会員登録ありがとうございます。</p>
<a th:href="${loginUrl}">ログインはこちら</a>

 

17.3 誤送信防止の仕組みがない

検証環境から本番ユーザーにメールを送ってしまう事故があります。

改善策は、検証環境では宛先を固定アドレスへ差し替えることです。

例:宛先Override設定

注記:ファイル名:application-stg.yml / 使用言語:YAML

 

app:
  mail:
    override-to: [email protected]

 

17.4 メール送信ログが不足している

メール未着問い合わせに対応するには、送信履歴が必要です。

改善策は、送信ログをDBに保存することです。

例:メール送信ログEntity

注記:ファイル名:MailSendLog.java / 使用言語:Java

 

@Entity
public class MailSendLog {

    @Id
    private Long id;

    private String toAddress;
    private String templateName;
    private String status;
    private LocalDateTime sentAt;
    private String errorMessage;
}

 

17.5 通知チャネルがメールだけに依存する

メールだけでは、迷惑メールや受信拒否で届かない場合があります。

改善策は、サイト内通知や管理画面通知も設計することです。

例:通知イベントを抽象化する

注記:ファイル名:NotificationEvent.java / 使用言語:Java

 

public record NotificationEvent(
        Long userId,
        String type,
        String title,
        String message
) {}

 

18. ファイル処理と帳票出力で負荷が高まる

企業システムでは、CSV出力、Excel取込、PDF帳票、画像アップロード、請求書ダウンロードがよくあります。ファイル処理はメモリ、セキュリティ、権限、監査ログを考慮する必要があります。

18.1 大量CSVをメモリに一括生成する

大量データをListで全件取得し、StringBuilderでCSV生成するとメモリ不足になります。

改善策は、ストリーミング出力や非同期ファイル生成を使うことです。

例:CSVをストリーミング出力する

注記:ファイル名:CustomerExportController.java / 使用言語:Java

 

@GetMapping("/exports/customers.csv")
public void exportCustomers(HttpServletResponse response) throws IOException {
    response.setContentType("text/csv; charset=UTF-8");
    response.setHeader("Content-Disposition", "attachment; filename=customers.csv");

    try (PrintWriter writer = response.getWriter()) {
        writer.println("id,name,email");
        customerExportService.writeCsv(writer);
    }
}

 

18.2 CSV取込でバリデーションが不足する

列数不足、文字コード違い、日付形式不正、重複などが起こります。

改善策は、取込前に全行バリデーションし、エラー一覧を返すことです。

例:CSV行の検証

注記:ファイル名:CustomerCsvValidator.java / 使用言語:Java

 

public ImportResult validate(List<CsvCustomerRow> rows) {
    List<ImportError> errors = new ArrayList<>();

    for (int i = 0; i < rows.size(); i++) {
        CsvCustomerRow row = rows.get(i);

        if (row.email() == null || !row.email().contains("@")) {
            errors.add(new ImportError(i + 1, "email", "メールアドレス形式が不正です。"));
        }
    }

    return new ImportResult(errors.isEmpty(), errors);
}

 

18.3 PDF帳票を同期生成して遅くなる

請求書PDFなどを毎回同期生成すると、アクセス集中時に遅くなります。

改善策は、PDF生成をジョブ化し、生成済みファイルを保存することです。

例:PDF生成ジョブを登録する

注記:ファイル名:InvoicePdfController.java / 使用言語:Java

 

@PostMapping("/invoices/{id}/pdf")
public PdfJobResponse requestInvoicePdf(@PathVariable Long id) {
    Long jobId = pdfJobService.createInvoicePdfJob(id);
    return new PdfJobResponse(jobId, "PROCESSING");
}

 

18.4 ファイル保存先がローカルディスク固定になる

複数台構成やコンテナ環境では、ローカルディスク保存は問題になりやすいです。

改善策は、FileStorageインターフェースで保存先を抽象化することです。

例:ファイル保存インターフェース

注記:ファイル名:FileStorage.java / 使用言語:Java

 

public interface FileStorage {
    String save(byte[] content, String filename);
    byte[] load(String path);
}

 

18.5 ダウンロード権限を確認していない

URLを知っていれば誰でもファイルを取得できる設計は危険です。

改善策は、ダウンロードAPIで認証・認可を行うことです。

例:ファイルダウンロード権限を確認する

注記:ファイル名:FileDownloadController.java / 使用言語:Java

 

@GetMapping("/files/{fileId}/download")
public ResponseEntity<Resource> download(@PathVariable Long fileId,
                                         @AuthenticationPrincipal LoginUser user) {
    StoredFile file = fileService.getFile(fileId);

    if (!fileService.canDownload(file, user.id())) {
        throw new AccessDeniedException("ファイルをダウンロードする権限がありません。");
    }

    return fileResponseBuilder.build(file);
}

 

19. CI/CDとリリース管理が整っていない

手元ビルド、手作業デプロイ、テストスキップ、DB変更の手動適用は、本番事故の原因になります。企業システムでは、ビルド、テスト、脆弱性チェック、DBマイグレーション、ロールバックを標準化する必要があります。

19.1 手元ビルドを本番に出す

開発者PCで作ったjarを本番に配置すると、ビルド環境差分が起こります。

改善策は、CI上でビルドし、成果物を管理することです。

例:GitHub ActionsでMavenビルド

注記:ファイル名:.github/workflows/java-ci.yml / 使用言語:YAML

 

name: Java CI

on:
  push:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '21'
      - run: ./mvnw clean verify

 

19.2 テストをスキップしてデプロイする

テストをスキップする運用が続くと、本番不具合が増えます。

改善策は、CIでテスト失敗時にデプロイできないようにすることです。

例:テストを必須化する

注記:ファイル名:.github/workflows/java-ci.yml / 使用言語:YAML

 

- name: Run tests
  run: ./mvnw test

- name: Build package
  run: ./mvnw package -DskipTests=false

 

19.3 DBマイグレーションとアプリリリースが分離していない

アプリは新カラムを前提にしているのにDBが未更新だと本番エラーになります。

改善策は、DB変更をリリース手順に組み込むことです。

例:後方互換を意識したカラム追加

注記:ファイル名:V12__add_customer_status.sql / 使用言語:SQL

 

ALTER TABLE customers ADD COLUMN status VARCHAR(20) DEFAULT 'ACTIVE' NOT NULL;

 

19.4 ロールバック手順がない

障害時に前バージョンへ戻す手順がないと復旧が遅れます。

改善策は、アプリ、設定、DB変更のロールバック方針を事前に決めることです。

例:Kubernetesでロールバックする

注記:ファイル名:rollback-command.sh / 使用言語:Shell

 

kubectl rollout undo deployment/member-api

 

19.5 環境差分を手作業で管理する

開発、検証、本番で環境が違いすぎると、本番だけで起こる障害が増えます。

改善策は、DockerやIaCで環境をコード化することです。

例:Spring BootアプリのDockerfile

注記:ファイル名:Dockerfile / 使用言語:Dockerfile

 

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY target/member-api.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

 

20. 監視と運用設計が後回しになる

Spring Boot Actuatorは、ヘルスチェック、メトリクス、監査、管理エンドポイントなど本番運用向け機能を提供します。ただし、何を監視し、誰が対応するかを決めなければ運用にはつながりません。

20.1 healthエンドポイントだけ見ている

/actuator/healthがUPでも、外部API、メール、バッチ、業務処理が失敗している場合があります。

改善策は、カスタムHealthIndicatorで重要な依存先も確認することです。

例:CRM接続ヘルスチェック

注記:ファイル名:CrmHealthIndicator.java / 使用言語:Java

 

@Component
public class CrmHealthIndicator implements HealthIndicator {

    private final CrmClient crmClient;

    public CrmHealthIndicator(CrmClient crmClient) {
        this.crmClient = crmClient;
    }

    @Override
    public Health health() {
        boolean ok = crmClient.ping();

        if (ok) {
            return Health.up().withDetail("crm", "available").build();
        }

        return Health.down().withDetail("crm", "unavailable").build();
    }
}

 

20.2 業務メトリクスがない

CPUやメモリだけでは、ログイン失敗、決済失敗、注文失敗などの業務異常に気づけません。

改善策は、業務イベントもメトリクス化することです。

例:ログイン失敗を記録する

注記:ファイル名:LoginMetrics.java / 使用言語:Java

 

@Component
@RequiredArgsConstructor
public class LoginMetrics {

    private final MeterRegistry meterRegistry;

    public void recordLoginFailure(String reason) {
        meterRegistry.counter("login.failure", "reason", reason).increment();
    }
}

 

20.3 アラートが多すぎて無視される

重要でないアラートが多いと、運用担当者が通知を見なくなります。

改善策は、Critical、Warning、Infoで通知基準を分けることです。

例:アラート設計メモ

注記:ファイル名:alert-policy.md / 使用言語:Markdown

 

## Critical

- API 5xx rate > 5% for 5 minutes
- DB connection unavailable
- payment.failed increased suddenly

## Warning

- response time p95 > 2s for 10 minutes
- batch retry count > 10

## Info

- daily export completed

 

20.4 トレースがなく遅延箇所を特定できない

APIが遅いとき、Controller、Service、DB、外部APIのどこが遅いか分からないと調査に時間がかかります。

改善策は、ObservabilityとTracingを導入することです。Spring BootはMicrometerや観測性機能を通じてログ、メトリクス、トレースを扱えるようにしています。

例:Observedを付ける

注記:ファイル名:OrderApplicationService.java / 使用言語:Java

 

@Observed(name = "order.create")
public OrderResponse createOrder(CreateOrderRequest request) {
    return orderApplicationService.create(request);
}

 

20.5 運用手順書がない

アラートが出ても、何を確認し、誰に連絡し、どの操作を行うかが決まっていないと対応が遅れます。

改善策は、主要障害ごとにRunbookを作ることです。

例:DB接続エラー時のRunbook

注記:ファイル名:runbook-db-connection-error.md / 使用言語:Markdown

 

# DB接続エラー対応

1. /actuator/health を確認
2. DB監視ダッシュボードで接続数を確認
3. アプリログで CannotGetJdbcConnectionException を検索
4. コネクションプール枯渇の場合は直近リリースを確認
5. 15分以内に復旧しない場合はDB担当へエスカレーション

 

21. ドキュメントとナレッジ管理が不足する

コードだけでは、設計判断、業務例外、運用手順、障害対応の背景までは伝わりません。長期保守やチーム交代を前提に、ナレッジを残す仕組みが必要です。

21.1 設計判断の理由が残らない

なぜ非同期にしたのか、なぜその権限設計にしたのかが残らないと、後任者が判断を変えてしまう可能性があります。

改善策は、ADRを残すことです。

例:ADRで設計判断を残す

注記:ファイル名:ADR-005-crm-async-sync.md / 使用言語:Markdown

 

# ADR-005: CRM連携を非同期ジョブにする

## Status

Accepted

## Context

会員登録時にCRM APIを同期呼び出しすると、CRM障害時に登録処理が失敗する。

## Decision

会員登録後に crm_sync_jobs テーブルへジョブを登録し、非同期でCRM連携する。

## Consequences

登録完了直後はCRMに反映されない可能性があるため、管理画面で同期状態を表示する。

 

21.2 APIドキュメントが更新されない

API仕様が実装とずれると、フロントエンドや外部連携で不具合が起こります。

改善策は、OpenAPIドキュメントを実装に近い場所で管理することです。

例:APIレスポンス説明を付ける

注記:ファイル名:OrderController.java / 使用言語:Java

 

@Operation(summary = "注文を作成する", description = "ログインユーザーの注文を作成します。")
@ApiResponses({
        @ApiResponse(responseCode = "201", description = "作成成功"),
        @ApiResponse(responseCode = "400", description = "入力エラー")
})
@PostMapping("/api/v1/orders")
public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) {
    OrderResponse response = orderService.create(request);
    return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

 

21.3 運用ナレッジが口頭共有になる

障害対応、リリース手順、バッチ再実行、ログ調査が口頭共有だけだと属人化します。

改善策は、FAQやRunbookとして蓄積することです。

例:メール未着対応FAQ

注記:ファイル名:faq-mail-not-received.md / 使用言語:Markdown

 

# 会員がパスワード再設定メールを受信できない場合

1. mail_send_logs で対象メールアドレスを検索
2. status が FAILED の場合、error_message を確認
3. override-to 設定が有効な環境でないか確認
4. メールアドレス変更履歴を確認
5. 必要に応じて管理画面から再送

 

21.4 コードコメントが不足または過剰になる

コードを見れば分かるコメントは不要ですが、業務上の理由は残すべきです。

改善策は、「なぜそうしているか」をコメントに残すことです。

例:業務制約の理由を残す

注記:ファイル名:InvoiceService.java / 使用言語:Java

 

// 請求締め後の取消は会計システムと不整合になるため禁止する
if (today.isAfter(invoice.getClosingDate())) {
    throw new BusinessException("INVOICE_CLOSED", "締め後の請求は取消できません。");
}

 

21.5 新メンバーのオンボーディング資料がない

環境構築、起動方法、テスト方法が分からないと、新メンバーの立ち上がりが遅れます。

改善策は、READMEを整備することです。

例:開発環境セットアップ

注記:ファイル名:README.md / 使用言語:Markdown

 

# 開発環境セットアップ

1. JDK 21 をインストール
2. Docker を起動
3. ./mvnw clean test を実行
4. docker compose up -d db redis
5. ./mvnw spring-boot:run -Dspring-boot.run.profiles=local
6. http://localhost:8080/actuator/health を確認

 

22. 保守性を考えない実装が増える

短期的に動くコードを積み重ねると、将来の機能追加や障害対応が難しくなります。企業システムでは、可読性、責務分離、テスト容易性、変更容易性を意識する必要があります。

22.1 if文が増え続ける

会員ランク、契約プラン、キャンペーン、地域、支払い状態によって処理が変わる場合、if文が増え続けます。

改善策は、Strategyパターンでルールを分けることです。

例:割引ルールインターフェース

注記:ファイル名:DiscountPolicy.java / 使用言語:Java

 

public interface DiscountPolicy {
    boolean supports(Customer customer);
    BigDecimal apply(BigDecimal amount);
}

 

22.2 共通処理をコピペする

ログインユーザー取得、日付変換、権限チェックを複数箇所でコピペすると、修正漏れが起こります。

改善策は、共通コンポーネントとして切り出すことです。

例:ログインユーザー取得を共通化する

注記:ファイル名:CurrentUserProvider.java / 使用言語:Java

 

@Component
public class CurrentUserProvider {

    public LoginUser getCurrentUser() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser user)) {
            throw new AccessDeniedException("ログイン情報が取得できません。");
        }

        return user;
    }
}

 

22.3 命名が曖昧になる

Manager、Util、Helperが増えると、責務が分からなくなります。

改善策は、業務上の役割が分かる名前にすることです。

例:請求番号生成の責務を明確にする

注記:ファイル名:InvoiceNumberGenerator.java / 使用言語:Java

 

@Service
public class InvoiceNumberGenerator {

    public String generate(LocalDate billingDate, long sequence) {
        return "INV-" + billingDate.format(DateTimeFormatter.BASIC_ISO_DATE)
                + "-" + String.format("%06d", sequence);
    }
}

 

22.4 ドメイン知識がコードに表れていない

仕様書では仮会員、本会員、休眠会員と呼ぶのに、コードではstatus=1のように扱うと分かりにくくなります。

改善策は、業務用語をenumやクラス名に反映することです。

例:会員ステータスをenumにする

注記:ファイル名:MemberStatus.java / 使用言語:Java

 

public enum MemberStatus {
    TEMPORARY,
    ACTIVE,
    SUSPENDED,
    WITHDRAWN
}

 

22.5 リファクタリングの時間を確保していない

機能追加だけを優先すると、技術的負債が増え続けます。

改善策は、スプリントや月次保守に改善枠を入れることです。

例:改善枠の管理メモ

注記:ファイル名:refactoring-plan.md / 使用言語:Markdown

 

# スプリントごとの改善枠

- 複雑度の高いServiceを分割
- 重複バリデーションを共通化
- Repositoryクエリ名を整理
- 重要ユースケースに単体テスト追加

 

23. チーム開発ルールが不足する

複数人でSpring Boot開発を行う場合、コーディング規約、レビュー観点、ブランチ戦略、テスト基準が必要です。個人の書き方に依存すると品質がばらつきます。

23.1 コーディング規約がない

フォーマットや命名が人によって違うと、レビューが非効率になります。

改善策は、SpotlessやCheckstyleで自動化することです。

例:Spotless設定

注記:ファイル名:pom.xml / 使用言語:XML

 

<plugin>
    <groupId>com.diffplug.spotless</groupId>
    <artifactId>spotless-maven-plugin</artifactId>
    <version>2.43.0</version>
    <configuration>
        <java>
            <googleJavaFormat/>
        </java>
    </configuration>
</plugin>

 

23.2 コードレビュー観点が人によって違う

レビュー観点が属人化すると、セキュリティやトランザクションの確認が漏れます。

改善策は、Pull Requestテンプレートを作ることです。

例:Pull Requestチェックリスト

注記:ファイル名:.github/pull_request_template.md / 使用言語:Markdown

 

## 確認項目

- [ ] Controllerに業務ロジックを書いていない
- [ ] Request DTOに必要なバリデーションがある
- [ ] 認可チェックがある
- [ ] トランザクション境界が適切
- [ ] EntityをAPIレスポンスに直接返していない
- [ ] テストを追加または更新した
- [ ] ログに個人情報を出していない

 

23.3 ブランチ戦略が曖昧

mainに直接コミットしたり、長期ブランチが増えたりすると、リリース管理が難しくなります。

改善策は、シンプルなブランチ戦略を決めることです。

例:ブランチルール

注記:ファイル名:branch-strategy.md / 使用言語:Markdown

 

# ブランチ戦略

- main: 常にリリース可能な状態
- feature/*: 機能開発
- release/*: リリース準備
- hotfix/*: 本番障害対応

 

23.4 開発環境が人によって違う

JDK、DB、Redis、環境変数が人によって違うと、動作差分が出ます。

改善策は、Docker Composeで共通環境を用意することです。

例:ローカル開発環境

注記:ファイル名:docker-compose.yml / 使用言語:YAML

 

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_DB: member
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
    ports:
      - "5432:5432"

  redis:
    image: redis:7
    ports:
      - "6379:6379"

 

23.5 Definition of Doneがない

「動いたら完了」では、テスト、ログ、ドキュメント、監視が漏れます。

改善策は、完了基準を明文化することです。

例:Definition of Done

注記:ファイル名:definition-of-done.md / 使用言語:Markdown

 

# Definition of Done

- 要件に対する実装が完了している
- 単体テストまたはスライステストが追加されている
- API仕様が更新されている
- 権限チェックが確認済み
- 例外処理とエラーレスポンスが統一されている
- 必要なログが出力されている
- レビュー承認済み

 

24. レガシーJavaシステムからの移行で失敗する

古いJavaシステムからSpring Bootへ移行する場合、新規開発とは違う難しさがあります。既存DB、既存バッチ、暗黙仕様、認証移行、並行稼働を考慮する必要があります。

24.1 一括リプレイスを狙いすぎる

全機能を一度に移行しようとすると、範囲が大きすぎて失敗しやすくなります。

改善策は、機能単位で段階移行することです。

例:段階移行計画

注記:ファイル名:migration-plan.md / 使用言語:Markdown

 

# 移行フェーズ

1. 既存DBを参照するSpring Boot APIを追加
2. 新しい画面だけSpring Boot APIを利用
3. 更新系機能を段階的に移行
4. 旧画面を閉鎖
5. 旧システムを停止

 

24.2 既存DBをそのまま使って設計が歪む

既存DBの数値コードや古い命名に新コードが引きずられると、保守性が下がります。

改善策は、既存コードを意味のあるenumやモデルへ変換することです。

例:旧ステータスコードをenumへ変換する

注記:ファイル名:LegacyMemberStatus.java / 使用言語:Java

 

public enum LegacyMemberStatus {
    ACTIVE,
    SUSPENDED,
    WITHDRAWN;

    public static LegacyMemberStatus fromCode(String code) {
        return switch (code) {
            case "1" -> ACTIVE;
            case "2" -> SUSPENDED;
            case "9" -> WITHDRAWN;
            default -> throw new IllegalArgumentException("Unknown status: " + code);
        };
    }
}

 

24.3 既存仕様がコードに埋もれている

レガシーシステムでは、仕様書より実装が正になっている場合があります。

改善策は、現行仕様をテストケースとして固定することです。

例:既存割引仕様をテスト化する

注記:ファイル名:DiscountServiceTest.java / 使用言語:Java

 

@Test
void goldMemberGetsCampaignDiscount() {
    DiscountResult result = discountService.calculate(
            CustomerRank.GOLD,
            new BigDecimal("10000"),
            campaignPeriod
    );

    assertThat(result.discountRate()).isEqualByComparingTo("0.15");
}

 

24.4 認証基盤の移行を軽視する

既存パスワードハッシュやセッション方式をどう移行するかを決めないと、ユーザーがログインできなくなります。

改善策は、初回ログイン時に新ハッシュへ移行する方式を検討することです。

例:初回ログイン時にハッシュ移行する

注記:ファイル名:LegacyPasswordMigrationService.java / 使用言語:Java

 

public boolean matches(String rawPassword, LegacyUser user) {
    if (legacyPasswordEncoder.matches(rawPassword, user.getPasswordHash())) {
        String newHash = newPasswordEncoder.encode(rawPassword);
        user.updatePasswordHash(newHash);
        return true;
    }
    return false;
}

 

24.5 新旧システムの並行稼働を考えていない

移行期間中は新旧システムが同時に動くため、データ同期や主従関係を決める必要があります。

改善策は、どちらのシステムを正とするかを明確にすることです。

例:並行稼働ルール

注記:ファイル名:parallel-run-rules.md / 使用言語:Markdown

 

# 並行稼働ルール

- 会員基本情報の更新は旧システムのみ
- 新Spring Bootシステムは参照のみ
- 注文履歴は新システムへ移行済み
- 1日1回、旧DBから新DBへ差分同期

 

25. 継続改善の仕組みがない

Spring BootによるJava Webシステムは、リリースして終わりではありません。業務変更、法令変更、ユーザー増加、外部API変更、Spring BootやJavaの更新に対応し続ける必要があります。

25.1 保守バックログがない

運用中の改善要望や技術的負債をその場対応だけで処理すると、優先順位が分からなくなります。

改善策は、保守バックログを作り、定期的に棚卸しすることです。

例:保守バックログ

注記:ファイル名:maintenance-backlog.md / 使用言語:Markdown

 

# 保守バックログ

- CustomerServiceの責務分割
- CSV出力を非同期化
- 管理画面に操作ログ検索を追加
- Spring Boot minor version update
- 古いAPI v1の廃止計画

 

25.2 定期的な依存関係更新がない

Spring Bootやライブラリ更新を放置すると、脆弱性対応や将来のアップグレードが重くなります。

改善策は、月次または四半期ごとに依存関係を確認することです。

例:依存関係更新確認

注記:ファイル名:dependency-check-command.sh / 使用言語:Shell

 

./mvnw versions:display-dependency-updates

 

25.3 利用状況を見て改善していない

どの機能が使われているか、どのAPIが遅いか、どのエラーが多いかを見ないと、改善優先度を判断できません。

改善策は、利用ログと業務メトリクスを定期確認することです。

例:機能利用メトリクス

注記:ファイル名:FeatureUsageListener.java / 使用言語:Java

 

@EventListener
public void handleFeatureUsed(FeatureUsedEvent event) {
    meterRegistry.counter("feature.used", "feature", event.featureName()).increment();
}

 

25.4 技術的負債を可視化していない

Service肥大化、テスト不足、遅いSQL、古い依存関係を可視化しないと、改善の必要性を説明できません。

改善策は、技術的負債を業務リスクとして記録することです。

例:技術的負債管理メモ

注記:ファイル名:technical-debt-log.md / 使用言語:Markdown

 

# 技術的負債: OrderServiceが2,000行を超えている

## 影響

- 注文キャンセル修正時の影響範囲が読みにくい
- 単体テスト追加が難しい
- 新メンバーの理解に時間がかかる

## 改善案

- 注文作成、キャンセル、請求連携をUseCase単位に分割
- 重要業務ルールに単体テストを追加

 

25.5 改善をリリース計画に組み込んでいない

改善作業を空き時間に任せると進みません。新機能が常に優先され、保守性が下がります。

改善策は、改善枠をリリース計画に組み込むことです。

例:四半期改善テーマ

注記:ファイル名:quarterly-improvement-plan.md / 使用言語:Markdown

 

# 四半期改善テーマ

## Q1

- テスト高速化

## Q2

- Spring Boot minor update

## Q3

- 管理画面の監査ログ強化

## Q4

- CSV出力の非同期化

 

おわりに

Spring BootによるJava Webシステム開発では、最初に動くものを作るだけなら比較的早く進められます。しかし企業システムとして長期運用するには、要件定義、レイヤー設計、設定管理、REST API設計、例外処理、JPA、トランザクション、認証認可、バリデーション、ログ、テスト、性能、非同期処理、外部連携、セキュリティ、CI/CD、監視、ドキュメント、チーム開発、レガシー移行、継続改善まで含めて考える必要があります。

重要なのは、Spring Bootの便利さに頼り切るのではなく、企業システムに必要な設計と運用ルールを最初から組み込むことです。Controllerを薄く保ち、Serviceの責務を分け、DTOとEntityを分離し、権限とログを明確にし、テストと監視を継続的に回すことで、システムは長期的に改善しやすくなります。短期的な開発速度だけでなく、数年後も安全に変更できる構造を作ることが、Spring BootによるJava Webシステム開発を成功させるための基本です。

LINE Chat