A transaction is a sequence of operations that must either all succeed or all be rolled back. In a single-database monolith, that's mostly handled for you. In distributed systems spanning multiple services, databases, and external APIs, it becomes one of the harder problems in software engineering. This post covers the core concepts, practical implementation in Node.js and PHP, and the patterns that apply when you're operating across service boundaries.

Understanding Transactions and Their Core Principles

Transactions guarantee four properties, grouped under the ACID acronym:

Atomicity means every operation in a transaction is part of one indivisible unit. If a single step fails, everything rolls back. Consistency means the system is always in a valid state when a transaction completes. No partial writes, no violated constraints. Isolation means concurrent transactions don't see each other's intermediate states. Durability means once a transaction commits, the data survives even if the process crashes immediately afterward.

These properties are easy to take for granted in relational databases. They become much harder to guarantee when your transaction needs to span a PostgreSQL database, a MongoDB collection, and a payment API call.

Transactions in SQL Databases

Relational databases like MySQL, PostgreSQL, and SQL Server handle ACID transactions natively. In Node.js using a PostgreSQL client:

const { Client } = require('pg');

(async () => {
  const client = new Client();
  await client.connect();
  
  try {
    await client.query('BEGIN');
    await client.query('UPDATE accounts SET balance = balance - 100 WHERE id = $1', [1]);
    await client.query('UPDATE accounts SET balance = balance + 100 WHERE id = $2', [2]);
    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK');
    console.error('Transaction error:', err);
  } finally {
    client.end();
  }
})();

Laravel's transaction helper does the same thing with less boilerplate:

DB::transaction(function () {
    DB::table('accounts')->where('id', 1)->decrement('balance', 100);
    DB::table('accounts')->where('id', 2)->increment('balance', 100);
});

Laravel manages BEGIN, COMMIT, and ROLLBACK automatically and retries on deadlock by default.

Transactions in NoSQL Databases

MongoDB added multi-document ACID transactions in version 4.0. Before that, you could only atomically write a single document. Multi-document transactions require a replica set or sharded cluster.

In Node.js using the MongoDB driver:

const session = client.startSession();
session.startTransaction();

try {
  await accountsCollection.updateOne({ _id: 1 }, { $inc: { balance: -100 } }, { session });
  await accountsCollection.updateOne({ _id: 2 }, { $inc: { balance: 100 } }, { session });
  await session.commitTransaction();
} catch (err) {
  await session.abortTransaction();
  console.error('Transaction error:', err);
} finally {
  await session.endSession();
}

In serverless environments like AWS Lambda, connection management matters more. Serverless functions scale horizontally, so each instance may open its own database connection. Cache connections where possible and handle contention explicitly in your error handling.

Distributed Transactions and Microservices

When a user action touches multiple services, a single-database transaction isn't an option. Two patterns handle this problem differently.

Two-Phase Commit (2PC)

2PC coordinates a transaction across multiple resource managers by splitting the commit into two phases: prepare and commit. Each participant confirms it's ready to commit, then a coordinator sends the final commit signal. 2PC guarantees consistency but is slow, requires all participants to be available simultaneously, and can block indefinitely if the coordinator fails between phases. It's theoretically clean but rarely the right choice for high-traffic distributed systems.

Saga Pattern

The Saga pattern models a distributed transaction as a sequence of local transactions. Each service performs its own local transaction and publishes an event. If a later step fails, each service that already committed runs a compensating transaction to undo its change.

sequenceDiagram
    participant Service A
    participant Service B
    participant Service C

    Service A->>Service B: Perform local transaction
    Service B->>Service C: Perform local transaction
    alt Success
        Service C->>Service A: OK
    else Failure
        Service C->>Service B: Trigger compensating action
        Service B->>Service A: Trigger compensating action
    end

Sagas keep services loosely coupled. Each service only knows about its own local transaction and its compensating action. The tradeoff is that the system is eventually consistent, not immediately consistent. Some window exists where Service A has committed but Service C hasn't yet.

Ensuring Concurrency Control

Multiple concurrent transactions accessing the same data need a strategy to avoid conflicts.

Optimistic locking assumes conflicts are rare. Add a version field to your records, increment it on each update, and reject updates that present a stale version number. The rejected transaction retries with fresh data. This works well when actual conflicts are infrequent.

Pessimistic locking acquires an explicit lock before reading data, preventing other transactions from modifying it until the lock is released. This is safer for high-conflict scenarios but reduces throughput under concurrent load.

Database isolation levels give you control over which anomalies you're willing to accept. Read Committed prevents dirty reads. Repeatable Read prevents non-repeatable reads. Serializable prevents phantom reads. Higher isolation levels reduce concurrency, so choose based on actual business requirements rather than defaulting to the strictest setting.

For critical sections in Node.js, queue-based processing or atomic operations in Redis can enforce a serialized execution order without database-level locking.

Transactions in Serverless and DevOps Paradigms

Serverless functions are stateless and ephemeral, which complicates transaction management. A few patterns help:

Event-driven transactions model state changes as events. If an event handler fails, a compensating event undoes the previous state change. This works well with services like Amazon Kinesis, Google Pub/Sub, or Azure Event Hubs.

Idempotent operations ensure that processing the same event multiple times produces the same result. This removes the need for strict distributed transactions in many cases, since retrying a failed operation is safe.

AWS Step Functions and Azure Durable Functions provide built-in orchestration for chaining operations with retry logic, timeout handling, and compensation. They don't give you ACID guarantees, but they make the state of a multi-step workflow observable and manageable.

Best Practices for Transaction Implementation

Design for partial failure. In distributed systems, partial failures are not edge cases. Build workflows that handle them explicitly, with defined retry behavior and compensating actions.

Keep transaction scope small. The longer a transaction holds locks, the higher the chance of conflict. Do any expensive computation outside the transaction boundary, then execute the minimum set of operations inside it.

Log everything. Transaction starts, commits, rollbacks, and concurrency conflicts should all be logged. Without that data, diagnosing production issues is guesswork. AWS CloudWatch, Google Stackdriver, and Azure Monitor all integrate without much overhead.

Test concurrency explicitly. Unit tests rarely cover transaction conflicts. Write integration tests that simulate concurrent requests to the same resources and verify the system handles them correctly.

Applying Transactions to Diverse Tech Stacks

Node.js and TypeScript developers have TypeORM and Sequelize for SQL transactions, plus the native MongoDB driver for NoSQL. Always handle promise rejections inside transaction blocks. An unhandled rejection that skips the rollback leaves the database in an inconsistent state.

Laravel and CodeIgniter both provide clean transaction APIs with automatic rollback on exception. The built-in helpers handle the common cases well. Focus testing effort on the rollback paths, which are easy to skip in manual testing but critical to correctness.

Cloud platforms each offer their own transaction-aware storage: DynamoDB Transactions on AWS, Cloud Spanner on Google Cloud, and Cosmos DB multi-region writes on Azure. Each has different consistency guarantees and cost profiles, so verify the tradeoffs before committing to one.

Strategic Use Cases for Transactions

Financial services are the canonical example. Transfers, withdrawals, and payment gateway calls all require that either the full sequence completes or nothing changes. Saga patterns work well here when the operation spans multiple accounts or third-party systems.

E-commerce checkout is another common case: create the order record, decrement inventory, charge the payment gateway. These touch different systems and often require distributed transaction patterns to handle partial failures cleanly.

Message-based workflows using Kafka or RabbitMQ can implement transactional behavior through exactly-once delivery semantics and idempotent consumers. Once a message is processed, the consumer records that fact, so redelivery doesn't cause duplicate operations.

Conclusion

Transactions are straightforward in a single relational database and genuinely difficult across distributed services. The ACID properties give you the vocabulary to reason about guarantees, and the Saga pattern gives you a practical tool when those guarantees can't span a single database boundary. Start with the simplest approach that meets your consistency requirements, keep transaction scope narrow, and make sure your concurrency and failure paths are tested as carefully as your happy paths.