Files
flyer-crawler.projectium.com/docs/adr/0002-standardized-transaction-management.md

7.0 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.

  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

// 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:

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:

// 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

// 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

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

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()
  • ADR-001 - Error handling within transactions
  • ADR-034 - Repository patterns for transaction participation