Skip to main content

Command Palette

Search for a command to run...

Clean Database Transactions with TypeScript Decorators

Using @Transactional and AsyncContext API

Updated
Clean Database Transactions with TypeScript Decorators

Part 2 of the Database Reliability Series

You get a ticket. A customer is upset—they updated their profile, got a success message, but never received the confirmation email. Support checks the logs: profile updated successfully, notification record created. But here's the twist: the notification was created after the database crashed mid-operation. When the system recovered, the profile update went through, but the notification entry vanished into the void.

The next morning, you discover the same user received three duplicate notifications because your retry logic kept attempting the operation without proper transaction boundaries.

Welcome to the world of data inconsistency: where business logic meets the harsh reality of distributed systems.

This is the silent killer of production systems. Not the spectacular crashes that wake everyone up, but the quiet data corruption that erodes user trust one inconsistency at a time.

In Part 1, we built a database adapter that lets you swap databases without touching business logic. That solved the infrastructure problem. But there's still a glaring issue: how do you ensure multiple database operations succeed or fail together?

The answer is transactions. But here's the problem: most developers avoid them because they're "too complicated." The result? Data inconsistencies in production that are nearly impossible to debug.

Today, we're fixing that with a pattern that makes transactions as simple as adding @Transactional() to your method. No boilerplate. No ceremony. Just atomic operations that actually work.

Prerequisites

Before diving in, make sure you have:

  • Node.js v23+ (required for AsyncContext API)

  • TypeScript knowledge

  • Basic understanding of databases and SQL

  • Completed Part 1: Database Adapter Pattern or familiar with database abstractions

📦 Complete code: github.com/arda-e/database-reliability - Tag: v2-transactions

Clone the repository to try the transactional decorator examples yourself:

git clone https://github.com/arda-e/database-reliability
git checkout v2-transactions

What Transactions Actually Do

Let's start with the basics. A transaction is a database mechanism that ensures a group of operations either all succeed together or all fail together. No middle ground. No partial updates.

The manual approach requires explicit transaction management with try-catch blocks:

async updateUserProfile(userId: string, data: ProfileData) {
  const trx = await this.db.transaction();
  try {
    await trx.updateUser(userId, data);
    await trx.createNotification(userId, "Profile updated");
    await trx.commit();
  } catch (error) {
    await trx.rollback();
    throw error;
  }
}

This works, but it has serious problems:

The ceremony is overwhelming. Every method needs transaction initialization, try-catch blocks, explicit commit and rollback calls. That's five lines of infrastructure code for two lines of business logic.

It's error-prone. Forget await before commit()? Your transaction might commit before all operations complete. Forget to catch the error? Your transaction never rolls back. Forget to call rollback() in the catch block? Database locks persist until timeout.

It doesn't compose. What happens when one transactional method calls another? Do you nest transactions? Reuse the parent transaction? Create savepoints? The manual approach gives you no guidance.

Testing becomes harder. Your tests now need to mock transaction objects, track commit/rollback calls, and verify the transaction lifecycle. That's testing infrastructure code, not business logic.

But here's the real problem: transactions aren't just for writes.

Why reads need transactions too: Isolation levels prevent dirty reads and ensure consistent snapshots when querying related data. Without transactions, you might read a user's updated email but their old profile picture mid-update—seeing an inconsistent view of the world that never actually existed.

This is infrastructure code polluting business logic. We're mixing what the code does (update profile, send notification) with how it ensures consistency (transactions). That's a violation of separation of concerns, and it makes your codebase harder to understand, test, and maintain.

There has to be a better way.


The Decorator Solution

With the decorator, the same logic becomes clean and declarative:

@Transactional()
async updateUserProfile(userId: string, data: ProfileData) {
  await this.db.updateUser(userId, data);
  await this.db.createNotification(userId, "Profile updated");
  // Transaction commits automatically on success
  // Rolls back automatically on any error
}

That's it. No try-catch. No explicit transaction management. Just clean business logic that happens to be atomic.

How it works:

TypeScript decorators are functions that wrap other functions, adding behavior without changing the original code. The @Transactional() decorator:

  1. Begins a transaction before your method executes

  2. Executes your method with the transaction context available

  3. Commits automatically if your method succeeds

  4. Rolls back automatically if your method throws an error

  5. Propagates the error so your application can handle it normally

The transaction management becomes invisible infrastructure. Your code reads like plain English: "Update the user profile and create a notification." The fact that it happens atomically is an implementation detail.

The benefits are immediate:

Your business logic stays clean. No transaction objects cluttering method signatures. No try-catch blocks obscuring the happy path. Just the essential operations.

It works with the DbAdapter from Post 1. The decorator detects when a transaction is active and ensures all database operations use the same transaction. Swap from PostgreSQL to MySQL? The decorator still works.

It's type-safe. TypeScript knows which methods are transactional at compile time. You can't accidentally call a transactional method without proper error handling—the type system enforces it.

Testing becomes simpler. Mock the decorator in unit tests to focus on business logic. Use real transactions in integration tests to verify atomicity. Each test focuses on one concern.

This is separation of concerns in action. Business logic does business things. Infrastructure code handles transactions. Neither knows about the other's implementation details.


Implementation Deep Dive

Let's build the decorator from scratch. We need three pieces: transaction storage, transaction management, and the decorator itself.

Transaction Storage with AsyncContext

First, we need to track which transaction is active for the current operation. Node.js 23+ provides AsyncContext for exactly this—context storage that works across async calls:

import { AsyncContext } from 'node:async_hooks';

interface TransactionContext {
  transaction: any; // Your database transaction object
  isNested: boolean;
}

const transactionContext = new AsyncContext<TransactionContext>();

Note: Node.js 23 or higher is required for the stable AsyncContext API. Earlier versions used AsyncLocalStorage, which works similarly but is being superseded by AsyncContext.

Transaction Manager

The TransactionManager handles the transaction lifecycle—begin, commit, and rollback:

class TransactionManager {
  constructor(private dbAdapter: DbAdapter<any>) {}

  async runInTransaction<T>(
    operation: () => Promise<T>
  ): Promise<T> {
    // Check if we're already in a transaction
    const existingContext = transactionContext.get();

    if (existingContext) {
      // Already in a transaction - just run the operation
      // (Nested transactions will be covered in advanced posts)
      return operation();
    }

    // Start new transaction
    const trx = await this.dbAdapter.beginTransaction();
    const context: TransactionContext = {
      transaction: trx,
      isNested: false
    };

    try {
      // Run operation with transaction context
      const result = await transactionContext.run(
        context,
        operation
      );

      await trx.commit();
      return result;
    } catch (error) {
      await trx.rollback();
      throw error;
    }
  }

  getCurrentTransaction() {
    return transactionContext.get()?.transaction;
  }
}

The @Transactional Decorator

The decorator wraps your method and automatically manages transactions:

function Transactional() {
  return function (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    const originalMethod = descriptor.value;

    descriptor.value = async function (...args: any[]) {
      // Get the transaction manager from the class instance
      const txManager: TransactionManager = this.transactionManager;

      if (!txManager) {
        throw new Error(
          '@Transactional requires transactionManager property'
        );
      }

      // Run the original method inside a transaction
      return txManager.runInTransaction(() =>
        originalMethod.apply(this, args)
      );
    };

    return descriptor;
  };
}

Integration with DbAdapter

Your database adapter checks for an active transaction:

class KnexAdapter extends DbAdapter<Knex> {
  private transactionManager: TransactionManager;

  async query(sql: string, params?: any[]) {
    // Check if we're in a transaction
    const trx = this.transactionManager.getCurrentTransaction();
    const conn = trx || this.connection;

    return this.withRetry(() => conn.raw(sql, params));
  }

  async beginTransaction() {
    return this.connection.transaction();
  }
}

Usage in Services

Services use the decorator without knowing about transaction internals:

class UserService {
  constructor(
    private db: KnexAdapter,
    public transactionManager: TransactionManager
  ) {}

  @Transactional()
  async updateUserProfile(userId: string, data: ProfileData) {
    // Both operations use the same transaction automatically
    await this.db.updateUser(userId, data);
    await this.db.createNotification(userId, "Profile updated");
  }

  @Transactional()
  async processPayment(orderId: string) {
    await this.updateOrderStatus(orderId, 'paid'); // Also @Transactional
    await this.sendReceipt(orderId);
    // Single transaction for everything
  }
}

The magic is in AsyncContext. When the decorator calls runInTransaction(), it stores the transaction context. Any database operation during that async chain can retrieve the active transaction and use it. No need to pass transaction objects through method parameters.


Real-World Patterns

Now that we have the machinery, let's look at common scenarios where transactional decorators shine.

Multi-table Updates

Here's a practical example creating an order with multiple related tables atomically:

class OrderService {
  @Transactional()
  async createOrder(orderData: CreateOrderDto) {
    // All or nothing
    const order = await this.db.insertOrder({
      userId: orderData.userId,
      total: orderData.total,
      status: 'pending'
    });

    await this.db.decrementInventory(
      orderData.items.map(item => ({
        productId: item.productId,
        quantity: item.quantity
      }))
    );

    await this.db.createInvoice({
      orderId: order.id,
      amount: orderData.total,
      dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
    });

    return order;
  }
}

If createInvoice() fails, the entire operation rolls back. No orphaned orders. No phantom inventory deductions. Atomic consistency guaranteed.

Calling Other Transactional Methods

Methods with @Transactional can call other @Transactional methods seamlessly:

class PaymentService {
  @Transactional()
  async processPayment(orderId: string, paymentInfo: PaymentInfo) {
    // This method is transactional
    const payment = await this.createPaymentRecord(orderId, paymentInfo);

    // These methods also have @Transactional - they'll use the same transaction
    await this.updateOrderStatus(orderId, 'paid');
    await this.sendPaymentReceipt(orderId);

    return payment;
  }

  // createPaymentRecord(), updateOrderStatus(), sendPaymentReceipt() 
  // are also @Transactional methods (implementation omitted for brevity)
}

If any method fails, the entire payment process rolls back—maintaining data consistency.

Important Patterns

Idempotency matters. If your operation might be retried, ensure it's safe to run multiple times by checking if work was already done.

Read-your-writes guarantee. Within a transaction, you always see your own changes, even if they're not committed:

@Transactional()
async updateAndVerify(userId: string, data: ProfileData) {
  await this.db.updateUser(userId, data);

  // This read sees the uncommitted update
  const updated = await this.db.getUser(userId);

  if (!this.isValid(updated)) {
    // Transaction rolls back, update never happened
    throw new Error('Invalid update');
  }
}

When NOT to use transactions:

Long-running operations. Transactions hold locks. Keep them short (milliseconds, not seconds).

External API calls. Never make HTTP requests inside transactions. The API delay keeps your transaction open, blocking other operations.

Complex calculations. Compute outside the transaction, then save the result inside:

// Bad
@Transactional()
async processAnalytics(data: Data[]) {
  const results = await this.expensiveComputation(data); // Holds lock
  await this.db.saveResults(results);
}

// Good
async processAnalytics(data: Data[]) {
  const results = await this.expensiveComputation(data); // No lock

  @Transactional()
  await this.saveResults(results); // Brief lock for save only
}

Keep transactions SHORT. The longer a transaction runs, the longer it holds locks, and the more it blocks other operations. Transactions are for ensuring consistency, not for doing work.


Testing Transactional Code

Testing transactional code requires two strategies:

Unit Tests: Mock the Infrastructure

Mock the transaction manager to test business logic in isolation:

  • Mock transactionManager.runInTransaction() to just execute the operation

  • Verify your business rules without a real database

  • Fast tests that focus on logic, not infrastructure

Integration Tests: Verify Atomicity

Use a real database (SQLite in-memory works great) to verify transactions actually work:

  • Test that failures trigger rollback (data unchanged)

  • Test that success commits all changes

  • Use :memory: SQLite database—no Docker needed

Testing Strategy:

  • Unit tests → Business logic correctness

  • Integration tests → Transaction behavior correctness

  • Separate concerns → Test what matters in each layer

Full test examples are in the GitHub repository.


Gotchas & Performance

Transactions are powerful, but they come with trade-offs. Here are the common mistakes and how to avoid them.

Transaction Duration = Lock Duration

The biggest mistake developers make: keeping transactions open too long.

// ❌ BAD: External API call inside transaction
@Transactional()
async processOrder(orderId: string) {
  await this.db.updateOrderStatus(orderId, 'processing');

  // This holds the transaction open for seconds!
  const result = await fetch('https://payment-api.com/charge', {
    method: 'POST',
    body: JSON.stringify({ orderId })
  });

  await this.db.updateOrderStatus(orderId, 'completed');
}

// ✅ GOOD: Only database operations in transaction
async processOrder(orderId: string) {
  // API call outside transaction
  const result = await fetch('https://payment-api.com/charge', {
    method: 'POST',
    body: JSON.stringify({ orderId })
  });

  // Quick transaction to save result
  @Transactional()
  await this.savePaymentResult(orderId, result);
}

Why this matters: While your transaction is open, it holds locks on the rows it touched. Other transactions that need those rows wait. If your external API takes 5 seconds to respond, you've blocked other operations for 5 seconds. In high-traffic systems, this causes connection pool exhaustion and cascading failures.

Deadlocks

Two transactions waiting for each other create a deadlock:

// Transaction A
@Transactional()
async transferFunds() {
  await this.db.lockUser(userId1);  // Gets lock on user 1
  await this.db.lockUser(userId2);  // Waits for lock on user 2
}

// Transaction B (running concurrently)
@Transactional()
async anotherTransfer() {
  await this.db.lockUser(userId2);  // Gets lock on user 2
  await this.db.lockUser(userId1);  // Waits for lock on user 1
}
// Deadlock! A waits for B, B waits for A

Solution: Always acquire locks in the same order:

@Transactional()
async transferFunds(fromUserId: string, toUserId: string) {
  // Sort to ensure consistent order
  const [first, second] = [fromUserId, toUserId].sort();

  await this.db.lockUser(first);
  await this.db.lockUser(second);

  // Now do the transfer
}

Forgotten Awaits

TypeScript won't save you from this one:

// ❌ SUBTLE BUG
@Transactional()
async updateProfile(userId: string, data: ProfileData) {
  this.db.updateUser(userId, data); // Missing await!
  await this.db.createNotification(userId, 'Updated');
  // Transaction commits before updateUser finishes
}

The transaction commits after the last await, but updateUser() is still running in the background. Result: the notification gets saved, but the profile update might fail after the commit. Use a linter rule to catch this: @typescript-eslint/no-floating-promises.

Connection Pool Exhaustion

If transactions don't complete (due to errors, timeouts, or bugs), they hold connections from the pool:

// ❌ BAD: Complex query that might hang
@Transactional()
async slowOperation() {
  // If this hangs, connection is held forever
  await this.db.complexQuery();
}

// ✅ GOOD: Keep transactions short and simple
@Transactional()
async quickOperation() {
  // Only essential database operations
  await this.db.updateUser(userId, data);
  await this.db.createNotification(userId, message);
}

// For long operations, do work outside transaction
async processLargeDataset(data: any[]) {
  // Process data first
  const results = this.computeResults(data);

  // Then save in quick transaction
  await this.saveResults(results);
}

Transactions aren't free—they add latency and hold locks. Keep them short and focused on database operations only.

What's Next

We've covered a lot of ground. Let's recap:

Post 1 gave us database adapters—the ability to swap PostgreSQL for MongoDB without changing business logic. That's infrastructure flexibility.

Post 2 (this post) gave us transactional decorators—automatic data consistency without polluting our code. That's write integrity.

Now we have reliable, flexible database operations. But there's still a problem: services still directly call database queries with SQL strings, mixing business logic with data access code.

The problems:

  • Services directly call db.query() with SQL strings

  • Business logic knows about database schema

  • Hard to test without a database

  • No clear boundaries between layers

Next up: Repository Pattern + Dependency Injection

We need to encapsulate data access logic, hide the database from business logic, and make testing trivial. That's what repositories do.

What you'll learn:

  • Repository pattern: one repository per aggregate

  • Dependency injection: testing without mocks

  • Clean architecture: business logic knows nothing about databases

  • How repositories use transactions internally

  • Query vs Command separation (preview of CQRS)

The journey so far:

✅ Database Adapter    → Swap databases without touching logic
✅ Transactions        → Data consistency automatically
🔜 Repository Pattern  → Organize data access, enable testing
🔜 Models + Validation → Type-safe data with guarantees
🔜 Entities + FSM      → Business logic in domain objects
🔜 CQRS                → Separate reads and writes
🔜 Outbox + Kafka      → Reliable event publishing
🔜 Saga Pattern        → Distributed transactions

Each pattern solves a real problem. Each implementation is production-ready. And by the end, you'll have a database layer that's reliable, testable, and maintainable.


Advanced Features & Future Topics

The basic transaction decorator we built today handles the most common use cases. As your applications grow, you may need:

  • Savepoints and nested transactions - Partial rollback within a larger transaction

  • Isolation levels - Fine-tuning read consistency (Read Committed, Repeatable Read, Serializable)

  • Transaction timeouts - Automatic rollback for long-running operations

  • Distributed transactions - Coordinating across multiple databases (covered in the Saga Pattern post)

We'll explore these advanced patterns in future articles when they become necessary. For now, the basic decorator gives you solid, production-ready transaction management.


Wrap Up

You now have transactional decorators that make your code cleaner and your data consistent. The complete code is in the GitHub repository under tag v2-transactions.

Questions? Drop a comment below or open an issue on GitHub.

Next up: Repository Pattern + Dependency Injection—organizing your data access layer properly.


This is Part 2 of the Database Reliability series. Start from the beginning