404 Not Found

404 Not Found


nginx

フェーズ2総合練習:OrderFlowのコアビジネス機能の完成

統合は理解の試金石です。JPA, トランザクション, 検証, 例外処理, セキュリティを1つの完全に機能するシステムに組み合わせます。

1. 学ぶ内容


2. フルスタック開発者の実話

(1) ペインポイント:モジュールが孤立して動作している

AliceはJPA, Service, Validation, Exception Handling, Securityの5つのモジュールの学習を完了しましたが, それらはすべて独立して存在しています。実際に注文を作成する際, プロセス全体を連携させる必要があります。「入力検証 → 在庫確認 → 在庫引当 → 注文作成 → トランザクション整合性の確保 → アクセス制御 → 例外処理」。各モジュールがどう連携するかがわかりません。

(2) 総合練習による解決策

このレッスンはすべてのモジュールを完全な注文作成フローに統合し, 実際のビジネスシナリオをシミュレーションします。

(3) 成果

Aliceが統合を完了すると, OrderFlowは完全なクローズドループのビジネス機能を持ちます。ユーザーログインから注文作成, 照会, キャンセルまで, 各ステップが検証, トランザクション, 権限, 例外処理で保護されています。


3. データモデルの統合

(1) 完全なERモデル

100%
erDiagram
    PRODUCT ||--o{ ORDER_ITEM : "含まれる"
    ORDER ||--o{ ORDER_ITEM : "含む"
    USER ||--o{ ORDER : "発注する"

    PRODUCT {
        bigint id PK
        varchar name
        decimal price
        int stock
        varchar sku
    }
    ORDER {
        bigint id PK
        bigint user_id FK
        varchar status
        decimal total_amount
        timestamp created_at
    }
    ORDER_ITEM {
        bigint id PK
        bigint order_id FK
        bigint product_id FK
        int quantity
        decimal unit_price
    }
    USER {
        bigint id PK
        varchar username
        varchar password
        varchar email
        varchar role
    }

(1) ▶ サンプル:User Entity

JAVA
@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String username;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    private String role;

    protected User() {}

    public User(String username, String password, String email, String role) {
        this.username = username;
        this.password = password;
        this.email = email;
        this.role = role;
    }
    // getters
}

出力:

TEXT
// 実行成功

4. 注文フローの統合

(1) 完全な注文タイムライン

100%
sequenceDiagram
    participant C as クライアント
    participant Ctrl as OrderController
    participant Val as 検証
    participant Svc as OrderService
    participant Tx as トランザクション
    participant Repo as Repository

    C->>Ctrl: POST /api/v1/orders
    Ctrl->>Val: @Valid CreateOrderRequest
    alt 検証失敗
        Val-->>C: 400 VALIDATION_ERROR
    end
    Ctrl->>Svc: createOrder(request)
    Svc->>Tx: トランザクション開始
    Svc->>Repo: findProductById
    alt 商品未検出
        Svc-->>C: 404 RESOURCE_NOT_FOUND
    end
    Svc->>Repo: deductStock (数量確認)
    alt 在庫不足
        Tx->>Tx: ロールバック
        Svc-->>C: 409 INSUFFICIENT_STOCK
    end
    Svc->>Repo: Order + OrderItemsを保存
    Tx->>Tx: コミット
    Svc-->>Ctrl: 注文作成完了
    Ctrl-->>C: 201 Created

(1) ▶ サンプル:完全なOrderController

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

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    @PreAuthorize("hasAnyRole('CUSTOMER', 'ADMIN')")
    public ResponseEntity<OrderResponse> createOrder(
            @Valid @RequestBody CreateOrderRequest request,
            @AuthenticationPrincipal UserDetails userDetails) {
        OrderResponse order = orderService.createOrder(
            request, userDetails.getUsername());
        return ResponseEntity.status(HttpStatus.CREATED).body(order);
    }

    @GetMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN') or @orderSecurity.isOwner(#id, authentication)")
    public ResponseEntity<OrderResponse> getOrder(@PathVariable Long id) {
        return ResponseEntity.ok(orderService.getOrder(id));
    }

    @DeleteMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<Void> cancelOrder(@PathVariable Long id) {
        orderService.cancelOrder(id);
        return ResponseEntity.noContent().build();
    }
}

出力:

TEXT
// 実行成功

5. サービス層のトランザクション統合

(1) ▶ サンプル:OrderServiceの完全な実装

JAVA
@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;
    private final UserRepository userRepository;

    public OrderService(OrderRepository orderRepository,
                        ProductRepository productRepository,
                        UserRepository userRepository) {
        this.orderRepository = orderRepository;
        this.productRepository = productRepository;
        this.userRepository = userRepository;
    }

    @Transactional(rollbackFor = Exception.class)
    public OrderResponse createOrder(CreateOrderRequest request, String username) {
        User user = userRepository.findByUsername(username)
            .orElseThrow(() -> new ResourceNotFoundException("User", 0L));

        Product product = productRepository.findById(request.productId())
            .orElseThrow(() -> new ResourceNotFoundException("Product", request.productId()));

        if (product.getStock() < request.quantity()) {
            throw new InsufficientStockException(
                product.getId(), product.getStock(), request.quantity());
        }

        product.deductStock(request.quantity());

        Order order = new Order(user, product, request.quantity());
        Order saved = orderRepository.save(order);

        return OrderResponse.from(saved);
    }

    @Transactional(rollbackFor = Exception.class)
    public void cancelOrder(Long orderId) {
        Order order = orderRepository.findByIdWithItems(orderId)
            .orElseThrow(() -> new ResourceNotFoundException("Order", orderId));

        if (!"PENDING".equals(order.getStatus())) {
            throw new OrderStateException(orderId, order.getStatus(), "CANCELLED");
        }

        order.getItems().forEach(item ->
            item.getProduct().addStock(item.getQuantity()));
        order.setStatus("CANCELLED");
    }

    @Transactional(readOnly = true)
    public OrderResponse getOrder(Long orderId) {
        Order order = orderRepository.findByIdWithItems(orderId)
            .orElseThrow(() -> new ResourceNotFoundException("Order", orderId));
        return OrderResponse.from(order);
    }
}

出力:

TEXT
// 実行成功

6. DTOと検証の統合

(1) ▶ サンプル:リクエスト/レスポンスDTO

JAVA
// 検証付きリクエストDTO
public record CreateOrderRequest(
    @NotNull(message = "商品IDは必須です")
    Long productId,

    @Min(value = 1, message = "数量は1以上である必要があります")
    @Max(value = 100, message = "数量は100を超えることはできません")
    Integer quantity
) {}

// レスポンスDTO
public record OrderResponse(
    Long id,
    String username,
    String status,
    BigDecimal totalAmount,
    List<OrderItemResponse> items,
    Instant createdAt
) {
    public static OrderResponse from(Order order) {
        List<OrderItemResponse> items = order.getItems().stream()
            .map(item -> new OrderItemResponse(
                item.getId(),
                item.getProduct().getName(),
                item.getQuantity(),
                item.getUnitPrice(),
                item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity()))
            ))
            .toList();
        return new OrderResponse(
            order.getId(),
            order.getUser().getUsername(),
            order.getStatus(),
            order.getTotalAmount(),
            items,
            order.getCreatedAt()
        );
    }
}

public record OrderItemResponse(
    Long id,
    String productName,
    Integer quantity,
    BigDecimal unitPrice,
    BigDecimal subtotal
) {}

出力:

TEXT
// 実行成功

7. セキュリティの統合

(1) ▶ サンプル:カスタムUserDetailsService

JAVA
@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException(
                "User not found: " + username));
        return User.builder()
            .username(user.getUsername())
            .password(user.getPassword())
            .roles(user.getRole())
            .build();
    }
}

// SpEL式のためのOrderSecurityヘルパー
@Component("orderSecurity")
public class OrderSecurity {

    private final OrderRepository orderRepository;

    public OrderSecurity(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public boolean isOwner(Long orderId, Authentication auth) {
        String username = auth.getName();
        return orderRepository.findById(orderId)
            .map(order -> order.getUser().getUsername().equals(username))
            .orElse(false);
    }
}

出力:

TEXT
// 実行成功

8. 総合サンプル:エンドツーエンドのビジネスプロセステスト

BASH
# 1. 商品データのシード
curl -u alice:admin123 -X POST http://localhost:8080/api/v1/products \
  -H "Content-Type: application/json" \
  -d '{"name":"Laptop Pro","price":1299.99,"stock":50,"sku":"LAP-0001"}'

# 2. 顧客として注文を作成
curl -u bob:pass123 -X POST http://localhost:8080/api/v1/orders \
  -H "Content-Type: application/json" \
  -d '{"productId":1,"quantity":3}'

# 3. 自分の注文を閲覧 (顧客bob)
curl -u bob:pass123 http://localhost:8080/api/v1/orders/1

# 4. 無効な数量を試行 -> 400
curl -u bob:pass123 -X POST http://localhost:8080/api/v1/orders \
  -H "Content-Type: application/json" \
  -d '{"productId":1,"quantity":0}'

# 5. 在庫超過の注文を試行 -> 409
curl -u bob:pass123 -X POST http://localhost:8080/api/v1/orders \
  -H "Content-Type: application/json" \
  -d '{"productId":1,"quantity":999}'

# 6. 管理者として注文をキャンセル
curl -u alice:admin123 -X DELETE http://localhost:8080/api/v1/orders/1
テストシナリオ 期待結果 検証ポイント
注文作成 201 Created トランザクション保証:注文と在庫が同時に更新
自分の注文閲覧 200 OK アクセス制御:ユーザーは自分の注文のみ閲覧可能
無効な数量 400 VALIDATION_ERROR Bean Validationが有効
在庫不足 409 INSUFFICIENT_STOCK ビジネス例外 → グローバル例外処理
注文キャンセル 204 No Content トランザクション:在庫の復元
未認証アクセス 401 Unauthorized Spring Securityが有効

❓ よくある質問

Q モジュール間の正しい階層呼び出し順序は何ですか?
A Controller → Service → Repository。Controllerはパラメータ検証と認可チェックを処理し, Serviceはビジネスロジックとトランザクションを処理し, Repositoryはデータアクセスを処理します。層をまたぐ呼び出しはしないでください。
Q 例外処理とトランザクションはどう連携しますか?
A ビジネス例外はServiceでスローされ, GlobalExceptionHandlerで統一的に処理されます。トランザクションは例外を検出すると自動的にロールバックします。Serviceで例外をキャッチして再スローしないと, トランザクションがロールバックしないので注意してください。
Q @PreAuthorizeのSpEL式のデバッグ方法は?
A logging.level.org.springframework.security=DEBUGを設定してアクセス決定ログを確認してください。SpEL式で参照されているBeanメソッドにログを追加することもできます。
Q セキュリティ設定のテスト方法は?
A @SpringBootTestと@WithMockUserアノテーションを使用して認証済みユーザーをシミュレートしてください。@WithMockUser(roles="ADMIN")でADMINロールをシミュレートします。
Q エンドツーエンドテストでカバーすべきシナリオは何ですか?
A 最低でも以下をカバーしてください:正常フロー, 検証失敗, ビジネス例外 (在庫不足), 権限拒否 (他人の注文), 未認証アクセス。各シナリオのステータスコードとレスポンス形式を検証してください。
Q フェーズ2完了後, プロジェクトはデプロイできますか?
A コア機能は完了していますが, まだ不十分です。JWT認証 (フェーズ3), キャッシュ (フェーズ3), 監視 (フェーズ4), コンテナ化 (フェーズ4), CI/CD (フェーズ5)が欠けています。全フェーズ完了後にプロジェクトをリリースすることを推奨します。

📖 まとめ


📝 練習問題

  1. 基本問題 (難易度 ⭐):このレッスンのすべてのコード統合を完了し, 注文の作成, 照会, キャンセルのエンドツーエンドプロセスがすべてcurlテストに通ることを確認してください。

  2. 応用問題 (難易度 ⭐⭐):商品管理モジュール (ProductController + ProductService)を追加し, ADMINロールのユーザーが商品の作成, 更新, 削除を行えるようにし, 一般ユーザーは照会のみ可能にしてください。検証と例外処理も含めてください。

  3. チャレンジ問題 (難易度 ⭐⭐⭐):@SpringBootTest + @WithMockUserを使って統合テストを書き, 注文成功, 在庫不足, 権限拒否などのシナリオをカバーし, コアプロセスのテストカバレッジ率を80%以上にしてください。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%