Atomic Repositories in Clean Architecture and TypeScript
You’re checking out on an e-commerce site. You have a cart with several items and quantities, and you click the checkout button. Under the hood, the operation flow might look like this:
Check for quantity and availability on the first item in the cart
Decrease the quantity of that item in the inventory with the amount from the cart
Repeat until you’ve done this for all items in the cart
If you didn’t run into any inventory issues, charge your credit card and complete the checkout
But alas, if you’ve designed your e-commerce site like this, you have likely created an atomicity problem.
Atomicity in the Repository Pattern occurs when multiple repositories execute their queries in one transaction. If one query fails, all of them fail and must be rolled back.
There are multiple points in this flow where this issue can show up:
The quantity of the item in the inventory can change mid-request, resulting in you over-promising and not being able to deliver on the sale
Products can become unavailable, creating a stale cart problem
Even if inventory is OK for this purchase, if the credit card cannot be charged, you’re creating a false inventory update and might lose out on a sale by someone else.
These scenarios can happen at any point during the whole operation, and if they do, you will need to revert the changes made to the inventory up until that point; you’ll want to “put the items back” in stock if a checkout cannot be executed for any reason.
You need to make this operation “atomic” - either everything will happen (decrease quantities, create order) or nothing should.
Understanding the atomicity problem through application tracing
To achieve atomicity in the Repository Pattern, we need to extract the transaction management out of the repositories. By default, every repository manages its own transactions. We need to pass an optional transaction argument to each of the repositories’ methods so they will either append their queries to the existing transaction or use the database driver directly if the transaction from the arguments is undefined
.
To be able to better understand the problem we need to solve, let’s take a look at a Trace View from Sentry. This is what the trace looks like when we try to execute multiple queries without a transaction:
NOTE: Since I’m using Turso with Drizzle during my atomic transactions research, the database queries show up as http.client
in the trace. If you have a direct database connection, you won’t see the http.client
span inside of the db.query
span.
If one of these queries fails, the execution will stop at that point and throw an error. The queries that got successfully executed until that point will actually modify the database, and that makes the whole operation produce inconsistent results - some rows were affected, some weren’t. That’s why you need transactions.
How to achieve atomicity through database transactions
To achieve atomicity, we need to create a “transaction” that gets passed throughout your application calls. This is best (and more easily) demonstrated by a Clean Architecture design. My recent blog, “Why Clean Architecture makes debugging easier,” goes into greater detail about this.
In a Clean Architecture application, you need to create the transaction at the controller level and pass it down to every use case. Use Cases will do their checks and call the transaction’s rollback
method when they throw any error. They also need to pass down the transaction to the Repository.
Implementing atomicity in JavaScript
Passing the transaction with the rollback
method allows for “interactive transactions”, which means your application doesn’t execute all queries in one go but allows for adding logic between queries. The inputs and outputs of these transactions are also chained together.
Implementing interactive transactions typically involves creating a new service called TransactionManagerService
that can look like this:
// Interfaces in app layer interface ITransaction { rollback: () => void; } export interface ITransactionManagerService { startTransaction<T>( clb: (tx: ITransaction) => Promise<T>, ): Promise<T>; } // Implementation in infra layer import { db, Transaction } from "@/drizzle"; @injectable() export class TransactionManagerService implements ITransactionManagerService { public startTransaction<T>( clb: (tx: Transaction) => Promise<T>, ): Promise<T> { return db.transaction(clb); } }
The TransactionManagerService
will expose a startTransaction
method that accepts a callback with a tx argument, which is the transaction. We need an “incomplete” ITransaction
because we’ll be using that type in the application layer, so no database-related imports are allowed. In its implementation (in the infra layer), we simply wrap Drizzle’s db.transaction
method with our callback. This is how we can create a Drizzle transaction and use it in a controller without having to import anything related to Drizzle.
Since we’re using TypeScript, we’re going to need to “extract” the Transaction type from Drizzle so we can use it in our repositories:
import { ResultSet } from "@libsql/client"; import { ExtractTablesWithRelations } from "drizzle-orm"; import { SQLiteTransaction } from "drizzle-orm/sqlite-core"; import { sessions, todos, users } from "./schema"; // Export Transaction type to be used in repositories type Schema = { users: typeof users; sessions: typeof sessions; todos: typeof todos; }; export type Transaction = SQLiteTransaction< "async", ResultSet, Schema, ExtractTablesWithRelations<Schema> >;
You should then leverage the transaction service at the controller level since that is where all of the use cases can be found. Each use case invokes repositories which then interact with the database, so starting at the controller level ensures the data across your application remains synced.
// ... Controller body const todosFromInput = ["One", "Two", "Three", "Four", "Five"] const transactionManagerService = getInjection( "ITransactionManagerService", ); const todos = await transactionManagerService.startTransaction( async (tx) => { try { return await Promise.all( todosFromInput.map((t) => createTodoUseCase({ todo: t }, user.id, tx), // Pass transaction to use case --------^^ ), ); } catch (err) { console.error("Rolling back!"); tx.rollback(); } }, ); // ... Controller body
Following the trace of the application, the use case simply accepts an optional transaction and passes it down to the repository:
export function createTodoUseCase( input: TodoInput, userId: string, tx?: any, ): Promise<Todo> { // ... Use case body const newTodo = await todosRepository.createTodo( { todo: input.todo, userId, completed: false, }, tx, // Pass the optional transaction to the repository method ); return newTodo; }
And then, in the repository, we either use Drizzle directly or the transaction if it exists:
// Accept the optional Transaction (type comes from drizzle) async createTodo(todo: TodoInsert, tx?: Transaction): Promise<Todo> { const invoker = tx ?? db; // If the transaction doesn't exist, use drizzle directly try { const [created] = await invoker.insert(todos).values(todo).returning(); } catch (err) { // ... } }
Confirming atomic transactions using Sentry’s Trace View during a successful transaction
Now that the application is using atomic transactions, the Trace View confirms you will not actually make changes to the database until you’re sure you are able to complete the order:
Notice the COMMIT;
at the bottom (the last http.client
span). That request sends the COMMIT;
command to the database, so all of the changes that the transaction made will get applied to the database. The database has a built-in mechanism to mark affected rows as “not committed” and keeps track of which transaction affected which rows. Once the transaction receives the COMMIT;
command, it takes all affected rows and “applies” them, treating them as normal data.
Confirming atomic transactions using Sentry’s Trace View during a failed transaction
If an error occurs in any of the queries, Drizzle sends a ROLLBACK;
command to the database that reverts all changes made until that point and drops the transaction. You can see that happening in Sentry’s Trace View:
From the Sentry screenshot above, we can see that there’s an extra http.client
span in the first query, which is actually the ROLLBACK;
command. Because of the second and third queries , the tx.rollback()
line got invoked and the backend sent the ROLLBACK;
command to Turso. This also ensured that the first query got reverted too, even though it was successful. The state of the database before and after this operation is the same.
Savepoints: Supporting atomicity across large transactions
There are scenarios where you might want to be able to complete the transactions up to a certain point instead of rolling back the entire transaction. These are called savepoints
. Savepoints
are currently possible with Drizzle, but it doesn’t seem that Prisma has them yet.
In Drizzle, savepoints
are simply nested transactions. If a transaction fails it will rollback, regardless if it’s nested or not. If you want to create a savepoint
, you just start a nested transaction so it rolls back to that point.
To achieve nested transactions, you need to make changes to the transaction code:
export interface ITransactionManagerService { startTransaction<T>( clb: (tx: ITransaction) => Promise<T>, // Add an optional "parent" argument to the transaction manager interface parent?: ITransaction, ): Promise<T>; } // Modify the implementation to accept it @injectable() export class TransactionManagerService implements ITransactionManagerService { public startTransaction<T>( clb: (tx: Transaction) => Promise<T>, parent?: Transaction, ): Promise<T> { // If parent transaction isn't supplied, fallback to Drizzle const invoker = parent ?? db; return invoker.transaction(clb); } }
Now, if we want to create a savepoint
within a transaction, all we need to do is call startTransaction
again, passing the parent transaction as the second argument:
// Start a transaction just like you would await transactionManagerService.startTransaction(async (mainTx) => { try { // Trigger bulk toggles first await Promise.all( dirty.map((t) => toggleTodoUseCase({ todoId: t }, user.id, mainTx), ), ); } catch (err) { console.error("Rolling back toggles!"); mainTx.rollback() } // Create a savepoint to avoid rolling back toggles if deletes fail await transactionManagerService.startTransaction( async (deleteTx) => { try { // Trigger bulk deletes await Promise.all( deleted.map((t) => deleteTodoUseCase({ todoId: t }, user.id, deleteTx), ), ); } catch (err) { console.error("Rolling back deletes!"); deleteTx.rollback(); } }, mainTx, // Pass mainTx as a parent to create a savepoint ); })
To be able to verify that the savepoint
worked, you can cause an exception in the “deletes” part of the transaction. Then, in the Sentry Trace View, you will see the rollbacks and the exception being thrown:
We can see that the bulk update operation included two toggle queries and three delete queries, but the server action reports that an error of type DrizzleError
with a message of Rollback
happened. If we open the error, we’ll see that it’s originating from the deleteTx.rollback()
method:
This tells us that Drizzle’s rollback
method actually throws its own exception. In the context of Clean Architecture, we’d have to map that to our own custom error from the Entities layer, but that’s a topic for another day.
To verify further, you can check the data in the database. You should see that the first block of operations (bulk toggles in the code snippet) has been applied, but the deletes have been rolled back.
Atomicity helps maintain data consistency across your application
So there we go! In summary, achieving atomicity in the Repository Pattern is crucial for maintaining consistency across operations that span multiple repositories. By creating transactions at the Controller layer and passing them down all the way to the individual repositories, you can ensure that either all related operations are successful or none are. This approach prevents partial updates to the database, which could lead to inconsistent states and potential issues in your application.
The implementation involves creating a TransactionManagerService
that provides you with a transaction instance so you can pass it down. This setup allows repositories to participate in a shared transaction if provided or use the database driver directly if not. The use of savepoints
within transactions adds another layer of control so you can enable specific sections of the operation to be rolled back without affecting others.
This method of handling transactions aligns well with Clean Architecture principles by separating concerns and keeping the business logic decoupled from the underlying database operations. While it does introduce some complexity, it offers significant benefits in terms of reliability and consistency, especially in scenarios where multiple dependent operations are involved.