# 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. 1. **`withTransaction` Helper**: A new helper function, `withTransaction(callback: (client: PoolClient) => Promise): Promise`, will be created. This function will be responsible for: - Acquiring a client from the database pool. - Starting a transaction (`BEGIN`). - Executing the `callback` function, passing the transactional client to it. - If the callback succeeds, it will `COMMIT` the transaction. - If the callback throws an error, it will `ROLLBACK` the transaction and re-throw the error. - In all cases, it will `RELEASE` the client back to the pool. 2. **Repository Method Signature**: Repository methods that need to be part of a transaction will be updated to optionally accept a `PoolClient` in their constructor or as a method parameter. By default, they will use the global pool. When called from within a `withTransaction` block, they will be passed the transactional client. 3. **Service Layer Orchestration**: Service-layer functions that orchestrate multi-step operations will use `withTransaction` to ensure atomicity. They will instantiate or call repository methods, providing them with the transactional client from the callback. ### Example Usage ```typescript // 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. ## Implementation Details ### The `withTransaction` Helper Located in `src/services/db/connection.db.ts`: ```typescript export async function withTransaction(callback: (client: PoolClient) => Promise): Promise { const client = await getPool().connect(); try { await client.query('BEGIN'); const result = await callback(client); await client.query('COMMIT'); return result; } catch (error) { await client.query('ROLLBACK'); logger.error({ err: error }, 'Transaction failed, rolling back.'); throw error; } finally { client.release(); } } ``` ### Repository Pattern for Transaction Support Repository methods accept an optional `PoolClient` parameter: ```typescript // Function-based approach export async function createUser(userData: CreateUserInput, client?: PoolClient): Promise { const queryable = client || getPool(); const result = await queryable.query( 'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *', [userData.email, userData.passwordHash], ); return result.rows[0]; } ``` ### Transactional Service Example ```typescript // src/services/authService.ts import { withTransaction } from './db/connection.db'; import { createUser, createProfile } from './db'; export async function registerUserWithProfile( email: string, password: string, profileData: ProfileInput, ): Promise { return withTransaction(async (client) => { // All operations use the same transactional client const user = await createUser({ email, password }, client); const profile = await createProfile( { userId: user.user_id, ...profileData, }, client, ); return { user, profile }; }); } ``` ### Services Using `withTransaction` | Service | Function | Operations | | ------------------------- | ----------------------- | ----------------------------------- | | `authService` | `registerAndLoginUser` | Create user + profile + preferences | | `userService` | `updateUserWithProfile` | Update user + profile atomically | | `flyerPersistenceService` | `saveFlyer` | Create flyer + items + metadata | | `shoppingService` | `createListWithItems` | Create list + initial items | | `gamificationService` | `awardAchievement` | Create achievement + update points | ### Connection Pool Configuration ```typescript const poolConfig: PoolConfig = { max: 20, // Max clients in pool idleTimeoutMillis: 30000, // Close idle clients after 30s connectionTimeoutMillis: 2000, // Fail connect after 2s }; ``` ### Pool Status Monitoring ```typescript import { getPoolStatus } from './db/connection.db'; const status = getPoolStatus(); // { totalCount: 20, idleCount: 15, waitingCount: 0 } ``` ## Key Files - `src/services/db/connection.db.ts` - `getPool()`, `withTransaction()`, `getPoolStatus()` ## Related ADRs - [ADR-001](./0001-standardized-error-handling.md) - Error handling within transactions - [ADR-034](./0034-repository-pattern-standards.md) - Repository patterns for transaction participation