How to abstract a database transactions on a usecase level for Firebase in a Clean Architecture?

242 Views Asked by At

I’m currently building a project using Clean Architecture principles with Firebase. I’m also using Turborepo for my monorepo structure.

In my codebase, under packages/, there is a “core” package that contains my business logic, including Entities and Usecases. For instance, I have User, Promotion, and Affiliate entities, and a usecase such as “create affiliate code” that executes the business logic with the corresponding entity.

export abstract class Usecase<T> {
  abstract execute(...args: any[]): Promise<T>;
}

export abstract class UserRepository {
  abstract getUser(userId: string): Promise<User>;
  abstract createUser(user: User): Promise<string>;
  // ...
}

export class CreateAffiliateCodeUsecase implements Usecase<void> {
  constructor(private userRepository: UserRepository) {}

  async execute(userId: string, code: string): Promise<void> {
    const user = await this.userRepository.getUser(userId);
    if (!user) {
      throw new Error("User not found");
    }

    if (user.affiliateCodes.includes(code)) {
      throw new Error("Code already exists");
    }
    // ...
    }
}

In my usecase object constructor is injected with a repository interface.

Later on application level I will implement it and use it with implementation details sorted out:

export abstract class RemoteStorageService {
  abstract get(path: string): Promise<any>;
  abstract update(path: string): Promise<any>;
  // and so on
}

export class UserRepositoryImpl implements core.UserRepository {
  constructor(private remoteStorageService: RemoteStorageService) {}

  getUser(userId: string): Promise<core.User> {
    return this.remoteStorageService.get(`/users/${userId}`);
  }
}

export class FirestoreStorageService extends RemoteStorageService {
  // implementation

  async get(path: string): Promise<any> {
    const doc = await this.db.doc(path).get();
    if (!doc.exists) {
      throw new Error('Document not found');
    }
    return doc.data();
  }
}

My question is: How can I abstract transactions when writing into storage at a usecase level without knowing anything about the Firebase transaction implementation details? I want to ensure that my usecases are not directly dependent on Firebase.

I want to perform something like this:

export class CheckAffiliateCodeUsecase implements Usecase<void> {
  constructor(
    private userRepository: UserRepository,
    private promotionRepository: PromotionRepository,
  ) {}

  async execute(// .. //) {
    // validations steps here..

    // time to make multiple writes to DB
    const promotionforNewUser = Promotion.createPromotion(//data1//);
    const promotionforOldUser = Promotion.createPromotion(//data2//);
    const user = User.getUser(//params//)
    user.upgrade(//params//)

    // transaction
    transaction {
      promitionRepository.save(promotionforNewUser)
      promitionRepository.save(promotionforOldUser)
      userRepository.updateUser(user)
    }


  }
}
1

There are 1 best solutions below

5
VonC On

To abstract database transactions at a use-case level in your Clean Architecture setup, you could introduce an additional layer of abstraction for handling transactions. That would make sure your use cases remain agnostic to the details of Firebase transactions.

For that, create an interface that encapsulates the transactional behavior. That interface will define methods for starting a transaction, committing, and rolling back.

export interface Transactional {
    startTransaction(): Promise<void>;
    commit(): Promise<void>;
    rollback(): Promise<void>;
}

Implement the Transactional interface specifically for Firebase, to handle the Firebase-specific transaction logic:

export class FirebaseTransaction implements Transactional {
    // Firebase transaction logic
}

And modify your repositories to accept a Transactional object and use it for database operations that need to be part of a transaction.

export class UserRepositoryImpl implements UserRepository {
    constructor(
    private remoteStorageService: RemoteStorageService,
    private transaction: Transactional
    ) {}

    // Implement methods using this.transaction
}

Now, you can use the transaction in your use cases. For example:

export class CheckAffiliateCodeUsecase implements Usecase<void> {
    constructor(
    private userRepository: UserRepository,
    private promotionRepository: PromotionRepository,
    private transaction: Transactional
    ) {}

    async execute(/* */) {
    await this.transaction.startTransaction();
    try {
        // Your business logic here
        await this.transaction.commit();
    } catch (error) {
        await this.transaction.rollback();
        throw error;
    }
    }
}

Your layers would now include the Transactional Interface Layer serves as a bridge between the business logic and data storage:

+---------------------------------+
|           Application           |
+---------------------------------+
|         Usecases (Logic)        |
|    +------------------------+   |
|    | CheckAffiliateCode     |   |
|    +------------------------+   |
+---------------------------------+
|            Repositories         |
|    +------------------------+   |
|    |    UserRepository      |   |
|    +------------------------+   |
|    +------------------------+   |
|    | PromotionRepository    |   |
|    +------------------------+   |
+---------------------------------+
|     Transactional Interface     |  <<=====
|    +------------------------+   |
|    |  FirebaseTransaction   |   |
|    +------------------------+   |
+---------------------------------+
|       RemoteStorageService      |
|    +------------------------+   |
|    |   FirestoreService     |   |
|    +------------------------+   |
+---------------------------------+

Your use cases only interact with this interface, not with Firebase directly, keeping them clean and testable.


From there, in your monorepo managed by Turborepo, you would likely have multiple packages such as core, repositories, services, etc. Each of these can be a separate Turborepo package. The core package might contain your business logic and use cases, while repositories could include your abstract and concrete repository implementations.

When you introduce changes to the transaction layer or any other part of your system, Turborepo can efficiently rebuild and retest only the affected packages. This is particularly useful when implementing abstracted transactional layers, as changes in one package might impact others.

That should encourage modular design, as each package can be developed, tested, and deployed independently. This modularity aligns well with Clean Architecture principles, where different layers (like the transactional layer or repository layer) are decoupled and can be developed in isolation.


Unfortunately, one of the reasons for the question was the unorthodox Transaction interface in Firebase.
Do you have any thoughts on implementing Firebase transactions with your interface?

From what I can read from Firebase / Transactions and batched writes, Transactions consist of a set of read and write operations that are atomic. A transaction can include any number of get() operations, followed by write operations like set(), update(), or delete(). Firebase transactions automatically retry if a concurrent edit affects a document read by the transaction. Firebase transactions make sure all writes in a transaction are applied at once if the transaction is successful.

The Transactional interface proposed above would need to accommodate Firebase's transaction model. Since Firebase transactions are a combination of read and write operations, the interface should provide methods to handle these operations collectively.
The implementation of the Transactional interface for Firebase would use the runTransaction method, which allows for executing a series of operations atomically.

You could define the Transactional interface with a method that mirrors Firebase's runTransaction functionality. The interface method should accept a function that performs the transaction operations.

However, including Firebase-specific details like the Transaction object in the domain layer (use cases and repositories) would violate the Clean Architecture principles. The domain layer should be agnostic to such implementation details.

The Transactional interface should not expose any implementation-specific details like the Firebase Transaction object. Instead, it should provide a more abstract way to handle transactions, perhaps using callbacks or other domain-specific structures.

export interface Transactional {
    runTransaction<T>(operation: () => Promise<T>): Promise<T>;
}

Then implement the Transactional interface for Firebase using the Firebase's runTransaction method in a FirebaseTransaction class (Infrastructure Layer).
The Firebase Transaction object stays within this class and does not leak into the domain layer.

import { Firestore, runTransaction as firebaseRunTransaction } from "firebase/firestore";

export class FirebaseTransaction implements Transactional {
    constructor(private firestore: Firestore) {}

    async runTransaction<T>(operation: () => Promise<T>): Promise<T> {
    return firebaseRunTransaction(this.firestore, async (transaction) => {
        // Pass the Firebase transaction to the operation, but within the closure
        return operation();
    });
    }
}

The Repository interface (Domain Layer) would remain clean of any external framework or infrastructure details.

export interface UserRepository {
    getUser(userId: string): Promise<User>;
    // Other methods
}

The use case interacts with the Transactional interface without needing to know about Firebase transactions.

export class SomeUseCase {
    constructor(private userRepository: UserRepository, private transactional: Transactional) {}

    async execute(userId: string): Promise<void> {
    await this.transactional.runTransaction(async () => {
        const user = await this.userRepository.getUser(userId);
        // Additional operations within the transaction
    });
    }
}

So:

  • Firebase-specific transaction logic is encapsulated within the FirebaseTransaction class in the infrastructure layer. The domain layer interacts with a clean Transactional interface that does not expose any Firebase details.

  • Repositories remain independent of the transaction mechanism. They define methods for domain operations without reference to transactions.

  • Use cases leverage the Transactional interface to perform operations within a transaction. The specifics of the transaction (like Firebase's transaction logic) are hidden behind this interface.


But:

It's still not working. On a Usecase level, you still need to provide details of what Transaction is (runTransaction is accepting very specific function that accepts very specific Transaction object), which will violate the whole idea.

I tried to look for other examples, but it doesn't look like it's possible at all. At this point, I don't think Clean Architecture is something doable on a real project. It's working really well as long as you don't touch transactions.

I agree: the main issue is the tension between maintaining the purity of Clean Architecture (where the domain layer remains completely agnostic to implementation details) and the practicalities of using a specific technology (like Firebase) that requires certain implementation-specific details to be handled, such as transactions.

One approach to resolve this is to further abstract the transaction details so that the domain layer (use cases) does not have to know about the specific Transaction object of Firebase. That could mean encapsulating the transaction logic within the repository layer or having an application service layer that handles transactions.

Instead of having the use case directly control the transaction, you could introduce an application service layer that orchestrates the transaction. The use case calls this service layer, which then coordinates the repositories and their respective operations within a transaction.

export class AffiliateCodeService {
  constructor(private userRepository: UserRepository, private transactional: Transactional) {}

  async createAffiliateCode(userId: string, code: string): Promise<void> {
    await this.transactional.runTransaction(async () => {
      // The service layer handles the transaction details.
      // The use case delegates to this service.
    });
  }
}

The use case then becomes simpler and delegates the transaction handling to the service layer, thus not worrying about the specifics of the transaction.

export class CreateAffiliateCodeUsecase implements Usecase<void> {
  constructor(private affiliateCodeService: AffiliateCodeService) {}

  async execute(userId: string, code: string): Promise<void> {
    await this.affiliateCodeService.createAffiliateCode(userId, code);
  }
}

The Firebase-specific transaction logic stays within the infrastructure layer, and the domain layer remains agnostic to these details. The service layer acts as a bridge, managing the transaction's lifecycle while using the domain layer for business logic.


Notes: Sometimes, a pragmatic approach is necessary. Clean Architecture provides guidelines and principles, but real-world scenarios might require deviations or adaptations to these principles. Certain technologies or frameworks might not fit perfectly within the architecture's idealized model. In such cases, it is about finding a balance between adhering to the principles and making practical decisions.

It is possible to introduce intermediate layers or services that handle the specifics of a technology, keeping the domain layer as clean as possible, but acknowledging that some details might permeate slightly into higher layers.