Clean Database Transactions with TypeScript Decorators
Using @Transactional and AsyncContext API

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:
Begins a transaction before your method executes
Executes your method with the transaction context available
Commits automatically if your method succeeds
Rolls back automatically if your method throws an error
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
AsyncContextAPI. Earlier versions usedAsyncLocalStorage, which works similarly but is being superseded byAsyncContext.
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 operationVerify 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 stringsBusiness 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



