結論から言うと、DAOパターンはデータソース固有のアクセス機構をクライアント/業務ロジックから分離し、汎用的なインターフェースの背後へ隠すために使います。JDBC実装では、SQL、行マッピング、JDBC資源管理をDAO実装へ集約することで、利用側をデータアクセス機構から切り離します。これはOracle公式のData Access Object定義に沿う責務分離です。接続取得はDataSourceへ集約し、複数の更新を一つの処理として扱う場合は全DAO操作で同じConnectionを共有して、成功時だけcommit、失敗時はrollbackします。
DAOを置いただけでは安全になりません。接続情報の直書き、SQLの文字列連結、別々の接続を使う見かけだけのトランザクションが残っていれば、責務を分けてもデータ破損や情報漏えいは防げません。この記事では、標準JDBC APIだけで構造と安全な実装順を確認します。
OracleのDAOパターンでは、データリソースのクライアント向けインターフェースと具体的なアクセス機構を分け、特定データソースのAPIを汎用的なクライアントインターフェースへ適合させます。利用側が汎用インターフェースだけへ依存すれば、JDBC、別DB向け実装、テスト用実装などのアクセス機構を、クライアント/業務ロジックから独立して変更できます。
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のDataSourceはDriverManagerに代わる推奨の接続取得手段で、接続プール対応実装も利用できます。アプリの起動処理やコンテナで設定したDataSourceをコンストラクターから渡せば、接続先を変えてもDAO本体を変更せずに済みます。
モデルとDAOの契約を先に決める
例ではusers表にid、name、emailがあるものとします。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、Connection、SQLExceptionを公開しないため、クライアント/業務ロジックは汎用的な操作へ依存し、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の構文から分離します。ただし、テーブル名や列名を?へ入れることはできません。並び替え列を画面から選ばせる場合は、name、created_atなど、コード内の固定候補と照合してからSQL断片を選びます。利用者が送った文字列をそのまま連結してはいけません。
複数DAO操作は同じConnectionで実行する
上のCRUDは一操作ごとに接続を取得するため、各メソッドは独立したトランザクションです。二つの更新を「両方成功、または両方取消」にする処理から、そのまま二つのCRUDメソッドを呼んではいけません。外側で別のConnectionをrollbackしても、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)にし、接続を閉じる前に必ずcommitかrollbackを呼びます。接続プールを使っていても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のトランザクション仕様を確認し、回数と待機時間に上限を設けます。
テストで確認する範囲
- 存在するIDと存在しないIDで、
findByIdの契約を確認します。 - 引用符、長い文字列、
null、境界値を入力し、パラメーターと制約の扱いを確認します。 - insert、update、deleteの影響行数を確認します。
- 二つ目の更新を意図的に失敗させ、最初の更新もrollbackされることを確認します。
- 接続・Statement・ResultSetが例外経路でも返却されることを、プールのメトリクスで確認します。
- メモリ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してはいけません。サービス層または採用フレームワークが一つのトランザクション境界を所有します。
公式資料
- Oracle Java:Data Access Object pattern
- Oracle Java SE 26: DataSource
- Oracle Java SE 26: Connection
- Oracle Java SE 26: PreparedStatement
- Oracle Java SE 26: SQLException
- Oracle Java Tutorials: Using Prepared Statements
まとめ
DAOパターンの中心は、データソース固有のアクセス機構をクライアント/業務ロジックから分離し、汎用的なインターフェースの背後へ隠すことです。JDBC実装はSQL、行マッピング、資源管理を所有し、サービスはDAOの契約を通して複数操作のトランザクションを所有します。接続はDataSourceから取得し、値はPreparedStatementへ渡し、すべてのJDBCリソースを閉じ、影響行数とrollbackをテストしてください。この境界が守られていれば、データアクセス機構やDBMS向け実装を変更するときも、利用側への影響を限定できます。

コメント