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
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.
Implement the
Transactional
interface specifically for Firebase, to handle the Firebase-specific 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.Now, you can use the transaction in your use cases. For example:
Your layers would now include the Transactional Interface Layer serves as a bridge between the business logic and data storage:
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. Thecore
package might contain your business logic and use cases, whilerepositories
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.
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 likeset()
,update()
, ordelete()
. 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 therunTransaction
method, which allows for executing a series of operations atomically.You could define the
Transactional
interface with a method that mirrors Firebase’srunTransaction
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 FirebaseTransaction
object. Instead, it should provide a more abstract way to handle transactions, perhaps using callbacks or other domain-specific structures.Then implement the
Transactional
interface for Firebase using the Firebase’srunTransaction
method in aFirebaseTransaction
class (Infrastructure Layer).The Firebase
Transaction
object stays within this class and does not leak into the domain layer.The
Repository
interface (Domain Layer) would remain clean of any external framework or infrastructure details.The use case interacts with the
Transactional
interface without needing to know about Firebase transactions.So:
Firebase-specific transaction logic is encapsulated within the
FirebaseTransaction
class in the infrastructure layer. The domain layer interacts with a cleanTransactional
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.Following Clean Architect , It should be place in interface adapter layer (controller, gateway, presenter).