銀行振込システムにおけるMVCアーキテクチャの段階的実装と階層化設計

単一サーブレットからMVCへ:関心の分離

初期の実装では、HTTPリクエストの受信、入金残高の検証、JDBCによるデータベース操作、トランザクション制御、およびレスポンス画面の生成が一つのサーブレットクラスに集中していました。このアプローチは以下の課題を抱えています:

  • コンポーネントの役割が不明確で、コードの再利用性が著しく低い
  • データアクセス層と業務ロジック層が密結合しており、修正時の影響範囲が特定しにくい
  • 画面描画ロジックが制御層に混在し、テスト難易度が上昇する

これらの課題を解決するために、MVC(Model-View-Controller)パターンに基づく3層アーキテクチャへのリファクタリングを実施します。

初回リファクタリング:役割の明確化

まず、データを保持するモデル層、データベース操作を担うDAO層、業務ルールを処理するサービス層、リクエストルーティングを行うコントローラ層に分割します。

モデルクラス(Entity)

package jp.example.finance.model;

import java.math.BigDecimal;

public class BankAccount {
    private Long recordId;
    private String accountNumber;
    private BigDecimal currentBalance;

    public BankAccount() {}

    public BankAccount(Long recordId, String accountNumber, BigDecimal currentBalance) {
        this.recordId = recordId;
        this.accountNumber = accountNumber;
        this.currentBalance = currentBalance;
    }

    // Getter/Setter省略
    public String getAccountNumber() { return accountNumber; }
    public BigDecimal getCurrentBalance() { return currentBalance; }
    public void setCurrentBalance(BigDecimal newBalance) { this.currentBalance = newBalance; }
    public Long getRecordId() { return recordId; }
    public void setRecordId(Long recordId) { this.recordId = recordId; }

    @Override
    public String toString() {
        return String.format("Account[id=%d, no=%s, bal=%s]", recordId, accountNumber, currentBalance);
    }
}

データベースアクセスクラス

package jp.example.finance.dao;

import jp.example.finance.exception.InsufficientBalanceException;
import jp.example.finance.exception.SystemFailureException;
import jp.example.finance.model.BankAccount;
import jp.example.finance.util.JdbcConnector;

import java.math.BigDecimal;
import java.sql.*;

public class AccountDataAccess {

    public BankAccount findByNumber(String acctNo) throws SQLException {
        String query = "SELECT account_id, balance_amount FROM account_master WHERE account_no = ?";
        try (Connection conn = JdbcConnector.obtainConnection();
             PreparedStatement stmt = conn.prepareStatement(query)) {
            
            stmt.setString(1, acctNo);
            try (ResultSet rs = stmt.executeQuery()) {
                if (rs.next()) {
                    return new BankAccount(
                        rs.getLong("account_id"),
                        acctNo,
                        rs.getBigDecimal("balance_amount")
                    );
                }
            }
        }
        return null;
    }

    public void executeBalanceUpdate(BankAccount account) throws SQLException {
        String query = "UPDATE account_master SET balance_amount = ? WHERE account_id = ?";
        try (Connection conn = JdbcConnector.obtainConnection();
             PreparedStatement stmt = conn.prepareStatement(query)) {
            
            stmt.setBigDecimal(1, account.getCurrentBalance());
            stmt.setLong(2, account.getRecordId());
            stmt.executeUpdate();
        }
    }
}

業務ロジック層(Service)

package jp.example.finance.service;

import jp.example.finance.dao.AccountDataAccess;
import jp.example.finance.exception.InsufficientBalanceException;
import jp.example.finance.exception.TransferProcessingException;
import jp.example.finance.model.BankAccount;

import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.SQLException;

public class TransferOrchestrator {
    
    private final AccountDataAccess dataAccess = new AccountDataAccess();

    public void processFundTransfer(String senderNo, String receiverNo, BigDecimal amount) 
            throws TransferProcessingException, InsufficientBalanceException {
        
        Connection dbConn = null;
        try {
            dbConn = JdbcConnector.obtainConnection();
            dbConn.setAutoCommit(false);

            BankAccount sender = dataAccess.findByNumberWithConn(senderNo, dbConn);
            if (sender == null || sender.getCurrentBalance().compareTo(amount) < 0) {
                throw new InsufficientBalanceException("出金元の残高が不足しています");
            }

            BankAccount receiver = dataAccess.findByNumberWithConn(receiverNo, dbConn);
            
            sender.setCurrentBalance(sender.getCurrentBalance().subtract(amount));
            receiver.setCurrentBalance(receiver.getCurrentBalance().add(amount));

            dataAccess.updateWithConn(sender, dbConn);
            // 意図的な遅延やテスト用バリデーションを挿入可能
            dataAccess.updateWithConn(receiver, dbConn);

            dbConn.commit();
        } catch (SQLException e) {
            try { if (dbConn != null) dbConn.rollback(); } catch (SQLException ex) { /* ignore */ }
            throw new TransferProcessingException("振込処理中にシステムエラーが発生しました", e);
        } finally {
            JdbcConnector.release(dbConn);
        }
    }
}

コントローラ層(Servlet)

package jp.example.finance.web;

import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jp.example.finance.exception.InsufficientBalanceException;
import jp.example.finance.exception.TransferProcessingException;
import jp.example.finance.service.TransferOrchestrator;

import java.io.IOException;
import java.math.BigDecimal;

@WebServlet(name = "TransferDispatcher", urlPatterns = "/api/v1/fund-transfer")
public class TransferDispatcher extends HttpServlet {
    
    private final TransferOrchestrator serviceLayer = new TransferOrchestrator();

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        String from = req.getParameter("sourceAccount");
        String to = req.getParameter("destinationAccount");
        BigDecimal transferAmt;
        try {
            transferAmt = new BigDecimal(req.getParameter("transferAmount"));
        } catch (NumberFormatException e) {
            resp.sendRedirect(req.getContextPath() + "/error-input.jsp");
            return;
        }

        try {
            serviceLayer.processFundTransfer(from, to, transferAmt);
            resp.sendRedirect(req.getContextPath() + "/views/success.jsp");
        } catch (InsufficientBalanceException e) {
            resp.sendRedirect(req.getContextPath() + "/views/insufficient.jsp");
        } catch (TransferProcessingException e) {
            resp.sendRedirect(req.getContextPath() + "/views/system-error.jsp");
        }
    }
}

トランザクションの同期問題とThreadLocalの導入

前段階のサービス層では、`Connection`オブジェクトをDAOのメソッド引数として引き回す必要がありました。これはレイヤーの境界を曖昧にし、メソッドシグネチャの膨張を招きます。理想的な状態は、各リクエストスレッド内で同一のデータベース接続を保持し、Service層のみがトランザクション境界を制御することです。これを実現するために`java.lang.ThreadLocal`を活用します。

接続管理ユーティリティの改修

package jp.example.finance.util;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.ResourceBundle;

public class JdbcConnector {
    
    private static final ThreadLocal<Connection> THREAD_BOUND_CONN = new ThreadLocal<>();
    private static final ResourceBundle CONFIG = ResourceBundle.getBundle("config/database");
    
    private JdbcConnector() {}

    static {
        try {
            Class.forName(CONFIG.getString("db.driver"));
        } catch (ClassNotFoundException e) {
            throw new RuntimeException("ドライバロードに失敗しました", e);
        }
    }

    public static Connection obtainConnection() throws SQLException {
        Connection current = THREAD_BOUND_CONN.get();
        if (current == null || current.isClosed()) {
            current = DriverManager.getConnection(
                CONFIG.getString("db.url"),
                CONFIG.getString("db.user"),
                CONFIG.getString("db.pass")
            );
            THREAD_BOUND_CONN.set(current);
        }
        return current;
    }

    public static void release(Connection conn) {
        if (conn != null) {
            try {
                conn.close();
            } catch (SQLException ignored) {}
            finally {
                // Tomcat等のスレッドプール環境では、次のリクエストで再利用されないようバインドを解除する
                THREAD_BOUND_CONN.remove();
            }
        }
    }
}

この変更により、DAO層は`JdbcConnector.obtainConnection()`を直接呼び出すだけで、暗黙的にService層と同じトランザクションコンテキストに属する接続を取得できます。引数による`Connection`の受け渡しが不要になり、インターフェース設計がクリーンになります。

インターフェース駆動設計とパッケージ構成の最適化

実運用レベルの保守性を高めるには、具体的な実装クラスではなく抽象(インターフェース)に依存する設計が不可欠です。これにより、将来のフレームワーク導入やテスト用モックへの置き換えが容易になります。

  • bank.dao/AccountDataAccess.java(インターフェース)
  • bank.dao.impl/AccountDataAccessImpl.java(JDBC実装)
  • bank.service/TransferOrchestrator.java(インターフェース)
  • bank.service.impl/TransferOrchestratorImpl.java(業務実装)

Service実装クラスでは、以下のようにポリモーフィズムを利用します:

public class TransferOrchestratorImpl implements TransferOrchestrator {
    // 実体化時に具象クラスをインスタンス化
    private final AccountDataAccess dataAccess = new AccountDataAccessImpl();
    // 以降の処理は同一
}

この構成は依存性逆転の原則(DIP)への第一歩です。実際のエンタープライズ環境では、Spring FrameworkのIoCコンテナやJakarta CDIがXMLやアノテーションによる自動ワイヤリングを担い、`new`キーワードによるハードコードを排除します。

技術補足事項

金融数値の取り扱い

Double.parseDouble()は浮動小数点演算に伴う丸め誤差が発生するため、通貨計算には適していません。本記事のコードではjava.math.BigDecimalを採用し、BigDecimal(String)コンストラクタ経由でインスタンス化することで精度を保証しています。

HTML5フォームバリデーション

クライアント側での簡易入力制限には<input>pattern属性が有効です。

<input type="text" name="transferAmount" pattern="^[0-9]+\.?[0-9]*$" 
       title="数値形式で入力してください" required>

ただし、正規表現による制限はあくまでUX向上のためのものであり、サーバーサイドでの厳密な検証は必須です。

DAOパターンとPOJOの定義

DAO(Data Access Object)は永続化レイヤーとの相互作用を抽象化するデザインパターンです。原則として1テーブルに対応する1インターフェース/1実装クラスを定義し、内部にSQL発行ロジックのみを閉じ込めます。一方、POJO(Plain Old Java Object)はフレームワーク固有の継承や実装を行わない純粋なデータ保持クラスです。ゲッター/セッター、引数なしコンストラクタ、および必要に応じてtoString()equals()を備えるのが一般的です。

次世代スタック(SSM)への発展

今回の手動DIとJDBC直書き構成は、学習用にアーキテクチャの骨格を理解するために意図的に採用されています。本番開発では以下の構成が標準的です:

  • Spring Framework:IoCコンテナによるBean管理、宣言的トランザクション(@Transactional)、AOP
  • Spring MVC:リクエストマッピング、データバインディング、ビューリゾルバ
  • MyBatis:XML/アノテーションベースのSQLマッピング、オブジェクトリレーションマッピングの軽減

これらのフレームワークを組み合わせることで、トランザクション境界の宣言や接続プール管理、セキュリティ設定などが大幅に自動化されます。

タグ: mvc-architecture jdbc-transaction-management threadlocal-java dao-design-pattern servlet-jsp

7月6日 01:02 投稿