3.9 KiB
ADR-002: Standardized Transaction Management and Unit of Work Pattern
Date: 2025-12-12
Status: Accepted
Implemented: 2026-01-07
Context
Following the standardization of error handling in ADR-001, the next most common source of architectural inconsistency and potential bugs is database transaction management. Currently, several repository methods (createUser, createFlyerAndItems, etc.) implement their own transaction logic by manually acquiring a client from the connection pool, and then managing BEGIN, COMMIT, and ROLLBACK states.
This manual approach has several drawbacks:
Repetitive Boilerplate: The try/catch/finally block for transaction management is duplicated across multiple files.
Error-Prone: It is easy to forget to client.release() in all code paths, which can lead to connection pool exhaustion and bring down the application. 3. Poor Composability: It is difficult to compose multiple repository methods into a single, atomic "Unit of Work". For example, a service function that needs to update a user's points and create a budget in a single transaction cannot easily do so if both underlying repository methods create their own transactions.
Decision
We will implement a standardized "Unit of Work" pattern through a high-level withTransaction helper function. This function will abstract away the complexity of transaction management.
-
withTransactionHelper: A new helper function,withTransaction<T>(callback: (client: PoolClient) => Promise<T>): Promise<T>, will be created. This function will be responsible for:- Acquiring a client from the database pool.
- Starting a transaction (
BEGIN). - Executing the
callbackfunction, passing the transactional client to it. - If the callback succeeds, it will
COMMITthe transaction. - If the callback throws an error, it will
ROLLBACKthe transaction and re-throw the error. - In all cases, it will
RELEASEthe client back to the pool.
-
Repository Method Signature: Repository methods that need to be part of a transaction will be updated to optionally accept a
PoolClientin their constructor or as a method parameter. By default, they will use the global pool. When called from within awithTransactionblock, they will be passed the transactional client. -
Service Layer Orchestration: Service-layer functions that orchestrate multi-step operations will use
withTransactionto ensure atomicity. They will instantiate or call repository methods, providing them with the transactional client from the callback.
Example Usage
// In a service file...
async function registerUserAndCreateDefaultList(userData) {
return withTransaction(async (client) => {
// Pass the transactional client to the repositories
const userRepo = new UserRepository(client);
const shoppingRepo = new ShoppingRepository(client);
const newUser = await userRepo.createUser(userData);
await shoppingRepo.createShoppingList(newUser.user_id, 'My First List');
return newUser;
});
}
Consequences
Positive
DRY (Don't Repeat Yourself): Transaction logic is defined in one place, eliminating boilerplate from repository and service files. Safety and Reliability: Guarantees that clients are always released, preventing connection leaks. Ensures proper rollback on any error. Clear Composability: Provides a clear and explicit pattern for composing multiple database operations into a single atomic unit. Improved Readability: Service logic becomes cleaner, focusing on the business operations rather than the mechanics of transaction management.
Negative
Learning Curve: Developers will need to learn and adopt the withTransaction pattern for all transactional database work.
Refactoring Effort: Existing methods that manually manage transactions (createUser, createBudget, etc.) will need to be refactored to use the new pattern.