169 lines
7.0 KiB
Markdown
169 lines
7.0 KiB
Markdown
# 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<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 `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<T>(callback: (client: PoolClient) => Promise<T>): Promise<T> {
|
|
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<User> {
|
|
const queryable = client || getPool();
|
|
const result = await queryable.query<User>(
|
|
'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<UserWithProfile> {
|
|
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
|