総合演習:OrderFlowプロジェクト開発
プロジェクト開発は設計をコードに変えるプロセスです。モジュール単位で進め, 各ステップを検証し, 継続的インテグレーションで負債を防ぎます。
1. 学ぶこと
- ユーザーモジュール:登録 / ログイン, JWT発行 / ロールベースアクセス制御
- 商品モジュール:CRUD / Redisキャッシュ / Caffeine第2段キャッシュ
- 注文モジュール:注文 / キャンセル / タイムアウト自動キャンセル / 在庫引当トランザクション
- 決済モジュール:シミュレート決済 / コールバック処理 / 冪等性保証
- グローバル機能:例外処理 / リクエストログ / パラメータバリデーション / APIドキュメント (SpringDoc)
2. フルスタック開発者の実話
(1) ペインポイント: 設計とコードのギャップ
AliceはOrderFlowのシステム設計を完了しましたが, 膨大なコード量に圧倒されています。5テーブル, 20API, 4モジュール。データベースコードから書き始めるべきか, APIコードからか?モジュール間の依存関係はどうする?各ステップが実行して検証に通ることをどう確保する?
(2) モジュール別開発のソリューション
モジュールごとに段階的に進め, 各モジュールが独立して検証可能:
graph TD
A["ユーザーモジュール<br/>Auth + JWT"] --> B["商品モジュール<br/>CRUD + Cache"]
B --> C["注文モジュール<br/>Transaction + Schedule"]
C --> D["決済モジュール<br/>Callback + Idempotent"]
D --> E["横断的関心<br/>Exception + Logging + Doc"]
(3) 成果
Aliceはモジュールごとに開発を進め, 各モジュールの完了時にテストで検証しました。2週間でコードベース全体を完了し, 「一括統合」による問題を回避しました。
3. ユーザーモジュール
(1) ▶ サンプル:Userエンティティとリポジトリ
JAVA
@Entity
@Table(name = "users")
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String username;
@Column(nullable = false, unique = true, length = 100)
private String email;
@Column(nullable = false, name = "password_hash")
private String passwordHash;
@Column(nullable = false, length = 20)
private String role = "CUSTOMER";
@CreationTimestamp
private Instant createdAt;
protected User() {}
public User(String username, String email, String passwordHash, String role) {
this.username = username;
this.email = email;
this.passwordHash = passwordHash;
this.role = role;
}
// getters
}
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
}
出力:
TEXT
// 実行成功
(2) ▶ サンプル:登録とログイン
JAVA
@Service
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtEncoder jwtEncoder;
public AuthService(UserRepository userRepository,
PasswordEncoder passwordEncoder,
JwtEncoder jwtEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.jwtEncoder = jwtEncoder;
}
@Transactional
public UserResponse register(RegisterRequest request) {
if (userRepository.existsByUsername(request.username())) {
throw new BusinessException("USERNAME_EXISTS", "Username already taken");
}
User user = new User(
request.username(),
request.email(),
passwordEncoder.encode(request.password()),
"CUSTOMER"
);
return UserResponse.from(userRepository.save(user));
}
public Map<String, String> login(LoginRequest request) {
Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.username(), request.password()));
Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("orderflow").subject(auth.getName())
.issuedAt(now).expiresAt(now.plus(2, ChronoUnit.HOURS))
.claim("role", extractRole(auth))
.build();
String token = jwtEncoder
.encode(JwtEncoderParameters.from(claims)).getTokenValue();
return Map.of("accessToken", token);
}
}
出力:
TEXT
// 実行成功
4. 商品モジュール
(1) ▶ サンプル:キャッシュ付きProductService
JAVA
@Service
public class ProductService {
private final ProductRepository productRepository;
private final ProductSearchRepository searchRepository; // Redis
@Cacheable(value = "products", key = "#id")
public ProductResponse getProduct(Long id) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product", id));
return ProductResponse.from(product);
}
@Cacheable(value = "product-list",
key = "#keyword + '-' + #pageable.pageNumber + '-' + #pageable.pageSize")
public PagedResponse<ProductResponse> searchProducts(
String keyword, Pageable pageable) {
Page<Product> page = productRepository
.findByNameContaining(keyword, pageable);
return PagedResponse.from(page.map(ProductResponse::from));
}
@CacheEvict(value = "product-list", allEntries = true)
@CachePut(value = "products", key = "#result.id()")
public ProductResponse createProduct(CreateProductRequest request) {
Product product = new Product(
request.name(), request.sku(),
request.price(), request.stock(), request.category());
return ProductResponse.from(productRepository.save(product));
}
@CachePut(value = "products", key = "#result.id()")
@CacheEvict(value = "product-list", allEntries = true)
public ProductResponse updateProduct(Long id, UpdateProductRequest request) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product", id));
product.setName(request.name());
product.setPrice(request.price());
product.setStock(request.stock());
return ProductResponse.from(productRepository.save(product));
}
@Caching(evict = {
@CacheEvict(value = "products", key = "#id"),
@CacheEvict(value = "product-list", allEntries = true)
})
public void deleteProduct(Long id) {
productRepository.deleteById(id);
}
}
出力:
TEXT
// 実行成功
5. 注文モジュール
(1) ▶ サンプル:OrderService注文トランザクション
JAVA
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
private final UserRepository userRepository;
private final OrderEventPublisher eventPublisher;
@Transactional(rollbackFor = Exception.class)
public OrderResponse createOrder(String username, CreateOrderRequest request) {
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);
eventPublisher.publishOrderCreated(saved.getId());
return OrderResponse.from(saved);
}
@Transactional(rollbackFor = Exception.class)
public void cancelOrder(String username, 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");
eventPublisher.publishOrderCancelled(orderId);
}
@Scheduled(fixedRateString = "${orderflow.order.expiry-check-interval:300000}")
@Transactional
public void cancelExpiredOrders() {
List<Order> expired = orderRepository
.findByStatusAndCreatedAtBefore("PENDING",
Instant.now().minus(30, ChronoUnit.MINUTES));
expired.forEach(order -> {
order.getItems().forEach(item ->
item.getProduct().addStock(item.getQuantity()));
order.setStatus("CANCELLED");
});
if (!expired.isEmpty()) {
log.info("Auto-cancelled {} expired orders", expired.size());
}
}
}
出力:
TEXT
// 実行成功
6. 決済モジュール
(1) ▶ サンプル:冪等性付きPaymentService
JAVA
@Service
public class PaymentService {
private final PaymentRepository paymentRepository;
private final OrderRepository orderRepository;
private final StringRedisTemplate redis;
@Transactional(rollbackFor = Exception.class)
public PaymentResponse initiatePayment(String username, CreatePaymentRequest request) {
Order order = orderRepository.findById(request.orderId())
.orElseThrow(() -> new ResourceNotFoundException("Order", request.orderId()));
if (!"PENDING".equals(order.getStatus())) {
throw new OrderStateException(order.getId(), order.getStatus(), "PAYMENT");
}
// 冪等性チェック: 同じtransaction_idで重複決済を作成しない
String idempotencyKey = "payment:idempotent:" + request.orderId();
Boolean isNew = redis.opsForValue()
.setIfAbsent(idempotencyKey, "1", Duration.ofMinutes(10));
if (Boolean.FALSE.equals(isNew)) {
throw new BusinessException("DUPLICATE_PAYMENT",
"Payment already initiated for order " + request.orderId());
}
Payment payment = new Payment(order, request.method(), order.getTotalAmount());
payment.setTransactionId(generateTransactionId());
return PaymentResponse.from(paymentRepository.save(payment));
}
@Transactional(rollbackFor = Exception.class)
public void handlePaymentCallback(String transactionId, String status) {
Payment payment = paymentRepository.findByTransactionId(transactionId)
.orElseThrow(() -> new ResourceNotFoundException("Payment", 0L));
if ("COMPLETED".equals(status) && "PENDING".equals(payment.getStatus())) {
payment.setStatus("COMPLETED");
payment.setPaidAt(Instant.now());
payment.getOrder().setStatus("PAID");
} else if ("FAILED".equals(status)) {
payment.setStatus("FAILED");
}
}
private String generateTransactionId() {
return "TXN-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase();
}
}
出力:
TEXT
// 実行成功
7. グローバル機能
(1) ▶ サンプル:SpringDoc APIドキュメント設定
JAVA
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI orderFlowOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("OrderFlow API")
.version("1.0.0")
.description("E-commerce order management system API"))
.addSecurityItem(new SecurityRequirement().addList("Bearer Auth"))
.schemaRequirement("Bearer Auth",
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT"));
}
}
出力:
TEXT
// 実行成功
| グローバル機能 | 実装 | 範囲 |
|---|---|---|
| 例外処理 | @RestControllerAdvice | グローバル標準エラーフォーマット |
| リクエストログ | HandlerInterceptor | 全受信リクエストログ |
| パラメータバリデーション | Bean Validation | 全リクエストDTO |
| APIドキュメント | SpringDoc OpenAPI | Swagger UI自動生成 |
| セキュリティ認証 | Spring Security + JWT | 全保護インターフェース |
8. 総合サンプル:OrderFlowの完全プロジェクト構造
TEXT
orderflow-service/
├── src/main/java/com/orderflow/
│ ├── OrderFlowApplication.java
│ ├── config/
│ │ ├── SecurityConfig.java # JWT + ロールベース認証
│ │ ├── CacheConfig.java # Caffeine + Redis
│ │ ├── AsyncConfig.java # スレッドプール + スケジューリング
│ │ └── OpenApiConfig.java # APIドキュメント
│ ├── controller/
│ │ ├── AuthController.java # ログイン / 登録
│ │ ├── ProductController.java # 商品CRUD
│ │ ├── OrderController.java # 注文管理
│ │ └── PaymentController.java # 決済処理
│ ├── service/
│ │ ├── AuthService.java
│ │ ├── ProductService.java
│ │ ├── OrderService.java
│ │ ├── PaymentService.java
│ │ └── NotificationService.java # @Async通知
│ ├── repository/
│ │ ├── UserRepository.java
│ │ ├── ProductRepository.java
│ │ ├── OrderRepository.java
│ │ └── PaymentRepository.java
│ ├── model/
│ │ ├── User.java
│ │ ├── Product.java
│ │ ├── Order.java
│ │ ├── OrderItem.java
│ │ └── Payment.java
│ ├── dto/
│ │ ├── request/ # CreateOrderRequest等
│ │ └── response/ # OrderResponse等
│ ├── exception/
│ │ ├── BusinessException.java
│ │ ├── ResourceNotFoundException.java
│ │ ├── GlobalExceptionHandler.java
│ │ └── ErrorResponse.java
│ ├── security/
│ │ ├── JwtConfig.java # RSA鍵ペア
│ │ ├── CustomUserDetailsService.java
│ │ └── OrderSecurity.java # @PreAuthorizeヘルパー
│ └── metrics/
│ └── OrderMetrics.java # カスタムMicrometerメトリクス
├── src/main/resources/
│ ├── application.yml
│ ├── application-dev.yml
│ ├── application-prod.yml
│ └── db/migration/ # Flywayスクリプト
│ ├── V1__init_schema.sql
│ └── V2__add_indexes.sql
├── Dockerfile
├── docker-compose.yml
└── pom.xml
❓ よくある質問
Q EntityとController, どちらを先に書くべきですか?
A ボトムアップアプローチを推奨:Entity → Repository → Service → Controller。まずデータモデルを定義し, 次にデータアクセスを実装し, ビジネスロジックを書き, 最後にAPI層を実装します。これにより各層を独立してテストできます。
Q 決済コールバックの冪等性はどう確保しますか?
A 1) Redis SETNXで注文ごとの決済開始が1回のみ; 2) データベースの一意制約 (transaction_id); 3) 決済ステートマシン (PENDING → COMPLETEDは不可逆); 4) 照合用に全コールバックイベントをログ記録。
Q 2段キャッシュの整合性はどう確保しますか?
A 更新時:1) 先にデータベースを更新; 2) Redisキャッシュを削除; 3) Redis Pub/Subで全インスタンスに通知しローカルCaffeineキャッシュをクリア。読み取り時:Caffeine → Redis → データベースの順に確認。
Q APIドキュメントとコードの同期はどう保ちますか?
A SpringDocはControllerのアノテーションを自動スキャンしてOpenAPIドキュメントを生成; コード変更時にドキュメントも自動更新されます。ただし, DTOに
@Schemaアノテーションを追加して補足説明を提供する必要があります。Q Flywayマイグレーションスクリプトの命名規則は?
A
V{version}__{description}.sql。例:V1__init_schema.sql, V2__add_indexes.sql。バージョン番号は連続している必要があり, 実行済みスクリプトは修正してはいけません。Q 複数人での協調開発はどう進めますか?
A 1) モジュールごとにGitブランチを作成 (feature/user, feature/order); 2) 毎日developブランチに統合; 3) コードレビュー後にマージ; 4) CIでテストを自動実行。重要なのは各モジュールが自己完結してテスト可能であることです。
📖 まとめ
- ユーザーモジュール:BCryptパスワードハッシュ, JWT発行, ロールベースアクセス制御
- 商品モジュール:@Cacheableで2段キャッシュ, @CachePutで同時更新, @CacheEvictで排除
- 注文モジュール:@Transactionalで注文時の在庫引当, @Scheduledでタイムアウト自動キャンセル, Pub/Subイベント通知
- 決済モジュール:Redis SETNXの冪等性, ステートマシン保証, コールバック処理
- グローバル機能:@RestControllerAdviceで統一例外処理, SpringDoc APIドキュメント, Bean Validation
📝 練習問題
-
基本問題 (難易度:⭐): ユーザー登録/ログインと商品CRUD操作の完全なコードを実装し, JWT認証とキャッシュが有効であることを確認してください。
-
応用問題 (難易度:⭐⭐): 注文モジュール (注文, キャンセル, タイムアウト自動キャンセル)と決済モジュール (決済開始と冪等コールバック)を実装し, Postmanでエンドツーエンドのビジネスプロセステストを行ってください。
-
チャレンジ (難易度:⭐⭐⭐): OrderFlowにSpringDoc APIドキュメントを追加し, Swagger UIでインタラクティブテストを設定してください。Flywayデータベースマイグレーションスクリプトを追加し, TestContainersでコアワークフローの統合テストを書いてください。



