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)
}
}
}
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
Transactionalinterface specifically for Firebase, to handle the Firebase-specific transaction logic:And modify your repositories to accept a
Transactionalobject 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. Thecorepackage might contain your business logic and use cases, whilerepositoriescould 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.
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
Transactionalinterface 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
Transactionalinterface for Firebase would use therunTransactionmethod, which allows for executing a series of operations atomically.You could define the
Transactionalinterface with a method that mirrors Firebase'srunTransactionfunctionality. The interface method should accept a function that performs the transaction operations.However, including Firebase-specific details like the
Transactionobject 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
Transactionalinterface should not expose any implementation-specific details like the FirebaseTransactionobject. Instead, it should provide a more abstract way to handle transactions, perhaps using callbacks or other domain-specific structures.Then implement the
Transactionalinterface for Firebase using the Firebase'srunTransactionmethod in aFirebaseTransactionclass (Infrastructure Layer).The Firebase
Transactionobject stays within this class and does not leak into the domain layer.The
Repositoryinterface (Domain Layer) would remain clean of any external framework or infrastructure details.The use case interacts with the
Transactionalinterface without needing to know about Firebase transactions.So:
Firebase-specific transaction logic is encapsulated within the
FirebaseTransactionclass in the infrastructure layer. The domain layer interacts with a cleanTransactionalinterface 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
Transactionalinterface to perform operations within a transaction. The specifics of the transaction (like Firebase's transaction logic) are hidden behind this interface.But:
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
Transactionobject 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.
The use case then becomes simpler and delegates the transaction handling to the service layer, thus not worrying about the specifics of the transaction.
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.