日程Fit|「いつ空いてますか?」の往復はもう不要。候補日を選んでURLを送るだけ|登録不要|今すぐ無料で使う →

JavaでのDAOパターンを使ったデータベース操作の徹底解説

結論から言うと、DAOパターンはデータソース固有のアクセス機構をクライアント/業務ロジックから分離し、汎用的なインターフェースの背後へ隠すために使います。JDBC実装では、SQL、行マッピング、JDBC資源管理をDAO実装へ集約することで、利用側をデータアクセス機構から切り離します。これはOracle公式のData Access Object定義に沿う責務分離です。接続取得はDataSourceへ集約し、複数の更新を一つの処理として扱う場合は全DAO操作で同じConnectionを共有して、成功時だけcommit、失敗時はrollbackします。

DAOを置いただけでは安全になりません。接続情報の直書き、SQLの文字列連結、別々の接続を使う見かけだけのトランザクションが残っていれば、責務を分けてもデータ破損や情報漏えいは防げません。この記事では、標準JDBC APIだけで構造と安全な実装順を確認します。

OracleのDAOパターンでは、データリソースのクライアント向けインターフェースと具体的なアクセス機構を分け、特定データソースのAPIを汎用的なクライアントインターフェースへ適合させます。利用側が汎用インターフェースだけへ依存すれば、JDBC、別DB向け実装、テスト用実装などのアクセス機構を、クライアント/業務ロジックから独立して変更できます。

日程Fit。無料・登録不要。「いつ空いてる?」を、ひとつのリンクで。リンクを送って、○△×でかんたん日程調整。無料で日程を作る。
目次

DAOを使うか判断する

状況判断理由
複数の画面・サービスから同じ表を操作するDAOが有効SQLと行マッピングを一か所へ集約できる
JDBC実装をテスト用実装へ差し替えたいDAOが有効呼び出し側をインターフェースへ依存させられる
複数DB操作を一つの業務処理にまとめるDAOとサービス層を分けるトランザクション境界は個別CRUDより上位に置く必要がある
一度だけ実行する短い管理スクリプト過剰な場合がある抽象化の保守コストが再利用性を上回ることがある
ORMが永続化を管理している既存のリポジトリ方式を優先手動JDBCとORMの接続・トランザクションを混在させない

DAOは「データベースなら必ず作る層」ではありません。SQLを隠す価値、複数の呼び出し元、テスト可能性、トランザクションの複雑さがあるときに効果を発揮します。小さな処理でも、接続とSQLを業務コードへ散らすより、最小のDAOへまとめた方が変更箇所を追いやすくなります。

責務を5つに分ける

  • モデル:データを表す。SQLや接続を知らない。
  • DAOインターフェース:クライアント/業務ロジックが使う汎用的な操作と戻り値を定義し、SQLやJDBC型など機構固有の詳細を公開しない。
  • JDBC実装:汎用DAOインターフェースを実装し、SQL、パラメーター設定、行マッピング、影響行数の確認、Connection/Statement/ResultSetの資源管理を集約する。
  • サービス:具体的なJDBCアクセス機構ではなくDAOの契約を利用し、複数DAOを組み合わせてトランザクションの開始・確定・取消を決める。
  • 構成:JDBCドライバー、接続URL、資格情報、接続プールを設定し、DataSourceを生成する。

DAO自身にURL、ユーザー名、パスワードを持たせないことが重要です。Java SEのDataSourceDriverManagerに代わる推奨の接続取得手段で、接続プール対応実装も利用できます。アプリの起動処理やコンテナで設定したDataSourceをコンストラクターから渡せば、接続先を変えてもDAO本体を変更せずに済みます。

モデルとDAOの契約を先に決める

例ではusers表にidnameemailがあるものとします。DDL、ID採番、文字列長、メールアドレスの一意制約はDBMSごとに決めてください。ここでは生成キーの方言差を避けるため、IDを呼び出し側から渡します。

public final class User {
    private final long id;
    private final String name;
    private final String email;

    public User(long id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }

    public long getId() { return id; }
    public String getName() { return name; }
    public String getEmail() { return email; }
}
import java.util.Optional;

public interface UserDao {
    Optional<User> findById(long id);
    void insert(User user);
    boolean update(User user);
    boolean deleteById(long id);
}

検索結果がないことをnullで曖昧にせず、Optional.empty()で契約に含めています。更新と削除は対象が存在したかをbooleanで返します。業務上「存在しないIDは異常」としたい場合は、サービス層で例外へ変換できます。UserDaoはSQL、ConnectionSQLExceptionを公開しないため、クライアント/業務ロジックは汎用的な操作へ依存し、JDBC実装の詳細や差し替えから分離されます。

DataSourceを使ったCRUDの完全例

次の実装では、取得列を明示し、すべての外部値を?へ設定します。SELECT *は使いません。例外にはSQLStateとベンダーコードを残しますが、入力値、接続文字列、パスワードはメッセージへ入れません。

public final class DaoException extends RuntimeException {
    public DaoException(String message) {
        super(message);
    }

    public DaoException(String message, Throwable cause) {
        super(message, cause);
    }
}
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Optional;

public final class JdbcUserDao implements UserDao {
    private static final String FIND_BY_ID =
        "SELECT id, name, email FROM users WHERE id = ?";
    private static final String INSERT =
        "INSERT INTO users (id, name, email) VALUES (?, ?, ?)";
    private static final String UPDATE =
        "UPDATE users SET name = ?, email = ? WHERE id = ?";
    private static final String DELETE =
        "DELETE FROM users WHERE id = ?";

    private final DataSource dataSource;

    public JdbcUserDao(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Override
    public Optional<User> findById(long id) {
        try (Connection con = dataSource.getConnection();
             PreparedStatement ps = con.prepareStatement(FIND_BY_ID)) {
            ps.setLong(1, id);

            try (ResultSet rs = ps.executeQuery()) {
                if (!rs.next()) {
                    return Optional.empty();
                }
                return Optional.of(mapUser(rs));
            }
        } catch (SQLException e) {
            throw translate("find user", e);
        }
    }

    @Override
    public void insert(User user) {
        try (Connection con = dataSource.getConnection();
             PreparedStatement ps = con.prepareStatement(INSERT)) {
            ps.setLong(1, user.getId());
            ps.setString(2, user.getName());
            ps.setString(3, user.getEmail());

            int count = ps.executeUpdate();
            if (count != 1) {
                throw new DaoException("insert user affected " + count + " rows");
            }
        } catch (SQLException e) {
            throw translate("insert user", e);
        }
    }

    @Override
    public boolean update(User user) {
        try (Connection con = dataSource.getConnection();
             PreparedStatement ps = con.prepareStatement(UPDATE)) {
            ps.setString(1, user.getName());
            ps.setString(2, user.getEmail());
            ps.setLong(3, user.getId());

            return changedAtMostOne(ps.executeUpdate(), "update user");
        } catch (SQLException e) {
            throw translate("update user", e);
        }
    }

    @Override
    public boolean deleteById(long id) {
        try (Connection con = dataSource.getConnection();
             PreparedStatement ps = con.prepareStatement(DELETE)) {
            ps.setLong(1, id);

            return changedAtMostOne(ps.executeUpdate(), "delete user");
        } catch (SQLException e) {
            throw translate("delete user", e);
        }
    }

    private static User mapUser(ResultSet rs) throws SQLException {
        return new User(
            rs.getLong("id"),
            rs.getString("name"),
            rs.getString("email")
        );
    }

    private static boolean changedAtMostOne(int count, String operation) {
        if (count > 1) {
            throw new DaoException(operation + " affected unexpected rows");
        }
        return count == 1;
    }

    private static DaoException translate(String operation, SQLException e) {
        String message = operation
            + " failed [SQLState=" + e.getSQLState()
            + ", errorCode=" + e.getErrorCode() + "]";
        return new DaoException(message, e);
    }
}

PreparedStatementは値をSQLの構文から分離します。ただし、テーブル名や列名を?へ入れることはできません。並び替え列を画面から選ばせる場合は、namecreated_atなど、コード内の固定候補と照合してからSQL断片を選びます。利用者が送った文字列をそのまま連結してはいけません。

複数DAO操作は同じConnectionで実行する

上のCRUDは一操作ごとに接続を取得するため、各メソッドは独立したトランザクションです。二つの更新を「両方成功、または両方取消」にする処理から、そのまま二つのCRUDメソッドを呼んではいけません。外側で別のConnectionrollbackしても、DAO内で確定した更新は戻りません。

複数操作用には、サービスが所有するConnectionを受け取る内部メソッドを用意します。次の例は二人のメールアドレスを同一トランザクションで交換する骨格です。実際には一意制約や一時値が関係するため、対象DBの制約設計に合わせて処理を調整してください。

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;

final class TransactionalUserStatements {
    int updateEmail(Connection con, long id, String email)
            throws SQLException {
        String sql = "UPDATE users SET email = ? WHERE id = ?";
        try (PreparedStatement ps = con.prepareStatement(sql)) {
            ps.setString(1, email);
            ps.setLong(2, id);
            return ps.executeUpdate();
        }
    }
}
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;

public final class UserService {
    private final DataSource dataSource;
    private final TransactionalUserStatements statements;

    public UserService(
            DataSource dataSource,
            TransactionalUserStatements statements) {
        this.dataSource = dataSource;
        this.statements = statements;
    }

    public void updateTwoEmails(
            long firstId, String firstEmail,
            long secondId, String secondEmail) throws SQLException {
        try (Connection con = dataSource.getConnection()) {
            con.setAutoCommit(false);
            try {
                int first = statements.updateEmail(con, firstId, firstEmail);
                int second = statements.updateEmail(con, secondId, secondEmail);

                if (first != 1 || second != 1) {
                    throw new SQLException("expected one row for each user");
                }
                con.commit();
            } catch (SQLException | RuntimeException e) {
                try {
                    con.rollback();
                } catch (SQLException rollbackError) {
                    e.addSuppressed(rollbackError);
                }
                throw e;
            }
        }
    }
}

新しいConnectionは既定でauto-commitが有効です。複数文をまとめるときだけsetAutoCommit(false)にし、接続を閉じる前に必ずcommitrollbackを呼びます。接続プールを使っていてもcloseは省略しません。多くのプールでは、物理切断ではなく借りた接続を返却する操作になります。

失敗時の切り分け

症状確認点安全な対応
接続が増え続ける全経路でConnectionを閉じているかtry-with-resourcesへ統一し、プールの使用中接続数を確認
入力に引用符があるとSQLエラー文字列連結をしていないか値をPreparedStatementへ移し、識別子は固定候補に限定
二つ目の更新失敗後も一つ目が残る同じConnectionか、auto-commitか一つの接続で開始し、例外時rollbackする
更新成功なのに対象が変わらないexecuteUpdateの行数、commitの有無影響行数とトランザクション終了を検証
制約違反の原因が分からないSQLState、ベンダーコード、連鎖例外秘密値を除いた診断情報を記録し、対象DBの公式エラー表を確認
テストDBでは通るが本番DBで失敗方言、型、大小文字、制約、分離レベル対象DBMSと同じ版・設定の統合テストを追加

例外とログの扱い

SQLExceptionは、説明文、SQLState、DBMS固有コード、原因、連鎖した例外を持ちます。利用者へは「処理を完了できませんでした」のような一般的なメッセージを返し、運用ログには操作名、SQLState、エラーコード、相関IDを残します。SQLへ設定した氏名、メールアドレス、パスワード、アクセストークン、完全な接続URLは記録しません。

一律の自動再試行も避けます。一時的な接続障害に見えても、すでにDB側で処理が確定している可能性があります。再試行するなら、処理の冪等性、SQL例外の分類、対象DBMSのトランザクション仕様を確認し、回数と待機時間に上限を設けます。

テストで確認する範囲

  1. 存在するIDと存在しないIDで、findByIdの契約を確認します。
  2. 引用符、長い文字列、null、境界値を入力し、パラメーターと制約の扱いを確認します。
  3. insert、update、deleteの影響行数を確認します。
  4. 二つ目の更新を意図的に失敗させ、最初の更新もrollbackされることを確認します。
  5. 接続・Statement・ResultSetが例外経路でも返却されることを、プールのメトリクスで確認します。
  6. メモリDBだけで終えず、採用DBMSと同じ方言、制約、文字コード、分離レベルで統合テストします。

ConnectionやPreparedStatementを細かくモックするテストは、JDBC呼び出し順の確認には使えますが、SQLの妥当性や制約は検証できません。DAOの価値は実データとの境界にあるため、DAO契約テストと対象DBを使う統合テストを中心にします。

よくある質問

DAOとRepositoryは同じですか?

重なる部分はありますが、同義とは限りません。DAOはSQLやデータソースへのアクセス操作を隠すことに重点を置きます。Repositoryはドメイン上の集合として検索・保存を表現する設計が多く、より業務モデル寄りです。既存プロジェクトの用語と責務を優先し、名前だけの層を重ねないでください。

DriverManagerを使ってはいけませんか?

短い学習コードでは使えますが、アプリケーションではDataSourceが適しています。接続設定を外出しでき、接続プール実装へ切り替えやすいからです。どちらを使っても、接続のクローズと資格情報の保護は必要です。

PreparedStatementだけでSQLインジェクション対策は完了しますか?

完了しません。値の文字列連結を防ぐ中心的な対策ですが、動的な列名・表名、過剰なDB権限、認可漏れ、危険なストアド処理までは解決しません。値はパラメーター、識別子は固定候補、DBユーザーは最小権限という組み合わせで守ります。

DAOごとにcommitしてよいですか?

一つのDAO操作だけで業務処理が完結するならauto-commitでも成立します。複数操作を一体として扱う場合、個々のDAOが先にcommitしてはいけません。サービス層または採用フレームワークが一つのトランザクション境界を所有します。

公式資料

まとめ

DAOパターンの中心は、データソース固有のアクセス機構をクライアント/業務ロジックから分離し、汎用的なインターフェースの背後へ隠すことです。JDBC実装はSQL、行マッピング、資源管理を所有し、サービスはDAOの契約を通して複数操作のトランザクションを所有します。接続はDataSourceから取得し、値はPreparedStatementへ渡し、すべてのJDBCリソースを閉じ、影響行数とrollbackをテストしてください。この境界が守られていれば、データアクセス機構やDBMS向け実装を変更するときも、利用側への影響を限定できます。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次