🐡

Clean ArchitectureにおけるFirestoreのトランザクション実装

2024/03/05に公開

はじめに

Clean Architectureは、ソフトウェア設計において、機能の独立性を高めるためにレイヤーを分割するアプローチです。

このアーキテクチャは、可読性、再利用性、テスト容易性を向上させることを目的としています。
一方、FirestoreはGoogleが提供するNoSQLクラウドデータベースであり、リアルタイムでのデータ同期や自動スケーリングなど、多くの強力な機能を持っています。

Clean ArchitectureとFirestoreを組み合わせてアプリケーションを開発する際には、さまざまな技術的な挑戦に直面します。
特に、Firestoreのトランザクション機能をUseCase層で適切に扱う際には、Infrastructure領域の関心事をどうやって適切に分離するかに頭を悩ませるはずです。
※ちなみに筆者は悩みました。

そこで同じ悩みに頭を悩ませる方を減らすべく、今回は、悩んだ末に形にしたInfrastructure領域の関心ごとをUseCase層で直接扱わずにトランザクションを実装する方法を紹介します。

ソースコードはこちらにありますので、コード全般を確認したい場合は活用ください。

立ちはだかる障壁

要はトランザクションを抽象化したオブジェクトをUseCase層で扱うようにしたら良いのですが、Firestoreでは、トランザクションを実行するために必要なTransactionオブジェクトを、関数の引数として提供する仕組みになっています。
直接トランザクション用のオブジェクトを生成できる仕組みになっておらず、この構造を踏まえてどうやって隠蔽するかの工夫が求められます。

var db = FirestoreClient.getFirestore();
db.runTransaction(transaction -> {
    // 何らかのデータアクセス処理
}).get();

解決策

目指す最終形

目標は、Firestoreを完全に隠蔽しながら、トランザクションを柔軟に実行できる構造を構築することです。
今回の最終形は以下のようになります。

public class UserUseCase {  
  private final DatabaseTransaction databaseTransaction;  
  private final UserRepository userRepository;  

  public UserUseCase(DatabaseTransaction databaseTransaction, UserRepository userRepository) {  
    this.databaseTransaction = databaseTransaction;  
    this.userRepository = userRepository;  
  }  

  public void createUserWithTransaction(String id, String name) {  
    databaseTransaction.runInTransaction(transaction -> {  
      User user = new User();  
      user.setId(id);  
      user.setName(name);  
      userRepository.saveUser(transaction, user);  
      return null;  
    });  
  }  
}

トランザクション管理のインターフェース定義

今回の最終形を達成するために、以下の3つのインターフェースを定義します

images

DatabaseTransaction

トランザクション内で実行する処理を定義するインターフェースです。

このインターフェースは、トランザクション内で実行する処理を定義します。
runInTransactionメソッドは、TransactionCallableを引数に取り、トランザクション内で実行する処理をラップします。このインターフェースを実装した処理をUseCase層で呼び出します。

public interface DatabaseTransaction {  
  <T> void runInTransaction(TransactionCallable<T> transactionCallable);  
}

TransactionCallable

トランザクション内で実行される具体的な処理を表す関数型インターフェースです。

このインターフェースは、トランザクション内で実行される具体的な処理を表します。
関数型インターフェースで定義されており、callメソッドは、TransactionContextを引数に取り、トランザクション内で行いたい操作(データの読み書きなど)を実装します。

@FunctionalInterface  
public interface TransactionCallable<T> {  
  T call(TransactionContext transaction) throws Exception;  
}

TransactionContext

トランザクション内でのデータの読み書き操作を抽象化するインターフェースです。

このインターフェースは、トランザクション内でのデータの読み書き操作を抽象化します。

例えば、setメソッドは、指定されたパスにオブジェクトを書き込む操作を表します。
このインターフェースを通じて、Firestoreのデータベース内の特定のパスにデータを設定するなどの操作を行うことができます。他に実現したい操作がある場合は、このインターフェースにメソッドを追加します。

public interface TransactionContext {  
  void set(String path, Object object);  
}

これらのインターフェースを通じて、Firestoreの詳細を抽象化し、UseCase層で直接的なデータベース操作を行うことなく、トランザクションを効率的に管理できるようにします。

実装

定義したインターフェースを使用して、Firestoreを使ったデータ登録の処理を実装します。

まずはTransactionContextを使って、実際のデータ操作部分から実装します。

public class FirestoreTransactionContext implements TransactionContext {  
  private final Transaction firestoreTransaction;  
  
  public FirestoreTransactionContext(Transaction firestoreTransaction) {  
    this.firestoreTransaction = firestoreTransaction;  
  }  
  
  @Override  
  public void set(String path, Object object) {  
    firestoreTransaction.set(FirestoreClient.getFirestore().document(path), object);  
  }  
}

次にDatabaseTransactionを使って、呼び出し部分を実装します。

public class FirestoreDatabaseTransaction implements DatabaseTransaction {  
  
  @Override  
  public <T> void runInTransaction(TransactionCallable<T> transactionCallable) {  
    var db = FirestoreClient.getFirestore();  
  
    try {  
      db.runTransaction(transaction -> {  
        TransactionContext context = new FirestoreTransactionContext(transaction);  
        return transactionCallable.call(context);  
      }).get();  
    } catch (Exception e) {  
      throw new RuntimeException("Transaction failed", e);  
    }  
  }  
}

これで実装は完了しました。

使用例

実際にこれが機能するかを確認するために、実際のRepositoryパターンとうまく連携できているかの処理を書いてみます。

Userを登録するシンプルなRepositoryを作成します。

インターフェースは以下の通りです。

public interface UserRepository {  
  void saveUser(TransactionContext transaction, User user);  
}

実装は以下の通りです。

public class FirestoreUserRepository implements UserRepository {  
  
  @Override  
  public void saveUser(TransactionContext transaction, User user) {  
    transaction.set("users/" + user.getId(), user);  
  }  
}

ポイントはTransactionContextを受け取って処理を実行するようにすることです。

これらをUseCase層で使うと、以下のようになります。

public class UserUseCase {  
  private final DatabaseTransaction databaseTransaction;  
  private final UserRepository userRepository;  
  
  public UserUseCase(DatabaseTransaction databaseTransaction, UserRepository userRepository) {  
    this.databaseTransaction = databaseTransaction;  
    this.userRepository = userRepository;  
  }  
  
  public void createUserWithTransaction(String id, String name) {  
    databaseTransaction.runInTransaction(transaction -> {  
      User user = new User();  
      user.setId(id);  
      user.setName(name);  
      userRepository.saveUser(transaction, user);  
      // 他のリポジトリ操作もこのトランザクション内で行うことができます  
      return null;  
    });  
  }  
}

目指す最終形が無事実現できました。

実装してみての所感

特定のレイヤーの関心事を別のレイヤーから隠蔽するこの手法は、実装の複雑さと工数の増加を伴います。
Firestoreのオブジェクトを直接使用する方法に比べて、より多くの設計と実装が必要になりますが、Clean Architectureの原則に忠実なアプローチを取ることで、よりメンテナンスしやすく、テストしやすいコードベースを構築できると考えられます。

が、さまざまなコストとトレードオフなので、実践で採用するかどうかはプロジェクトの状況に応じて適宜判断いただければと思います。

さいごに

Infrastructure領域の関心ごとをUseCase層で直接扱わない形で、トランザクションを実装することができました。非常に満足です。

特に関数型インターフェースを使う発想は、慣れてないと出てきにくい発想だと思うので、この記事がそういう方に届けば嬉しく思っています。

Discussion