フェーズ2総合練習:OrderFlowのコアビジネス機能の完成
統合は理解の試金石です。JPA, トランザクション, 検証, 例外処理, セキュリティを1つの完全に機能するシステムに組み合わせます。
1. 学ぶ内容
- Order, OrderItem, Product間の複数テーブル結合とカスケード操作の実装
- 注文作成フローの改善:在庫確認 → 在庫引当 → 注文作成 → トランザクション保証
- 統一例外処理ですべてのビジネスシナリオをカバー
- ロール別のAPI権限制御 (CUSTOMER / ADMIN)
- H2コンソールとPostmanを使ったエンドツーエンドのビジネスプロセステスト
2. フルスタック開発者の実話
(1) ペインポイント:モジュールが孤立して動作している
AliceはJPA, Service, Validation, Exception Handling, Securityの5つのモジュールの学習を完了しましたが, それらはすべて独立して存在しています。実際に注文を作成する際, プロセス全体を連携させる必要があります。「入力検証 → 在庫確認 → 在庫引当 → 注文作成 → トランザクション整合性の確保 → アクセス制御 → 例外処理」。各モジュールがどう連携するかがわかりません。
(2) 総合練習による解決策
このレッスンはすべてのモジュールを完全な注文作成フローに統合し, 実際のビジネスシナリオをシミュレーションします。
(3) 成果
Aliceが統合を完了すると, OrderFlowは完全なクローズドループのビジネス機能を持ちます。ユーザーログインから注文作成, 照会, キャンセルまで, 各ステップが検証, トランザクション, 権限, 例外処理で保護されています。
3. データモデルの統合
(1) 完全なERモデル
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
@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
}
出力:
// 実行成功
4. 注文フローの統合
(1) 完全な注文タイムライン
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
@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();
}
}
出力:
// 実行成功
5. サービス層のトランザクション統合
(1) ▶ サンプル:OrderServiceの完全な実装
@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);
}
}
出力:
// 実行成功
6. DTOと検証の統合
(1) ▶ サンプル:リクエスト/レスポンスDTO
// 検証付きリクエスト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
) {}
出力:
// 実行成功
7. セキュリティの統合
(1) ▶ サンプル:カスタムUserDetailsService
@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);
}
}
出力:
// 実行成功
8. 総合サンプル:エンドツーエンドのビジネスプロセステスト
# 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が有効 |
❓ よくある質問
logging.level.org.springframework.security=DEBUGを設定してアクセス決定ログを確認してください。SpEL式で参照されているBeanメソッドにログを追加することもできます。@WithMockUser(roles="ADMIN")でADMINロールをシミュレートします。📖 まとめ
- JPA, Service, Validation, Exception, Securityを統合して完全なビジネスフローを構築
- 注文フロー:入力検証 → 在庫確認 → 在庫引当 → 注文作成 → トランザクションコミット
- 各モジュールの責務の明確化:Controller (検証/認可)→ Service (ビジネスロジック/トランザクション)→ Repository (データ)
- カスタムUserDetailsServiceでインメモリユーザーを置き換え。OrderSecurityヘルパーでメソッドレベル権限チェック
- エンドツーエンドテストは正常フロー + 失敗検証 + ビジネス例外 + 権限拒否をカバー
📝 練習問題
-
基本問題 (難易度 ⭐):このレッスンのすべてのコード統合を完了し, 注文の作成, 照会, キャンセルのエンドツーエンドプロセスがすべてcurlテストに通ることを確認してください。
-
応用問題 (難易度 ⭐⭐):商品管理モジュール (ProductController + ProductService)を追加し, ADMINロールのユーザーが商品の作成, 更新, 削除を行えるようにし, 一般ユーザーは照会のみ可能にしてください。検証と例外処理も含めてください。
-
チャレンジ問題 (難易度 ⭐⭐⭐):
@SpringBootTest+@WithMockUserを使って統合テストを書き, 注文成功, 在庫不足, 権限拒否などのシナリオをカバーし, コアプロセスのテストカバレッジ率を80%以上にしてください。



