skip to Main Content

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)
    }


  }
}

2

Answers


  1. 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 make 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.

    Login or Signup to reply.
  2. Following Clean Architect , It should be place in interface adapter layer (controller, gateway, presenter).

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search