404 Not Found

404 Not Found


nginx

総合演習:OrderFlowプロジェクト開発

プロジェクト開発は設計をコードに変えるプロセスです。モジュール単位で進め, 各ステップを検証し, 継続的インテグレーションで負債を防ぎます。

1. 学ぶこと


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

(1) ペインポイント: 設計とコードのギャップ

AliceはOrderFlowのシステム設計を完了しましたが, 膨大なコード量に圧倒されています。5テーブル, 20API, 4モジュール。データベースコードから書き始めるべきか, APIコードからか?モジュール間の依存関係はどうする?各ステップが実行して検証に通ることをどう確保する?

(2) モジュール別開発のソリューション

モジュールごとに段階的に進め, 各モジュールが独立して検証可能:

100%
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でテストを自動実行。重要なのは各モジュールが自己完結してテスト可能であることです。

📖 まとめ


📝 練習問題

  1. 基本問題 (難易度:⭐): ユーザー登録/ログインと商品CRUD操作の完全なコードを実装し, JWT認証とキャッシュが有効であることを確認してください。

  2. 応用問題 (難易度:⭐⭐): 注文モジュール (注文, キャンセル, タイムアウト自動キャンセル)と決済モジュール (決済開始と冪等コールバック)を実装し, Postmanでエンドツーエンドのビジネスプロセステストを行ってください。

  3. チャレンジ (難易度:⭐⭐⭐): OrderFlowにSpringDoc APIドキュメントを追加し, Swagger UIでインタラクティブテストを設定してください。Flywayデータベースマイグレーションスクリプトを追加し, TestContainersでコアワークフローの統合テストを書いてください。

Web-Tutorial.com

Web-Tutorial 技術チーム

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

100%