Skip to main content

Transactional Consistency in Hexagonal Architecture: The Unit of Work Pattern

 The hardest boundary to maintain in Hexagonal Architecture (Ports and Adapters) is the one between your Application layer and your Persistence layer when ACID transactions are involved.

You have likely faced this dilemma:

  1. The "Dirty" Approach: You pass a database transaction object (like JPA’s EntityManager or a Prisma tx client) into your Domain services. Result: Your domain is now coupled to your database library.
  2. The "Magic" Approach: You rely on framework decorators like @Transactional (Spring/NestJS). Result: This often works for simple CRUD, but fails when composing complex Use Cases where the transaction boundary needs to be explicit, or when you are strictly separating your core logic into libraries that do not depend on the framework.

In a strict Clean Architecture, the Use Case (Application Service) dictates the transaction boundary, but the Domain layer must remain ignorant of how that transaction is mechanically achieved.

The Root Cause: Interface Contamination

The fundamental problem is a conflict between Dependency Inversion and Stateful Context.

In a standard call stack, the database transaction is a stateful object created at the start of a request and committed at the end. To ensure multiple repositories participate in the same transaction, they typically need access to that shared transaction object.

If your Repository interface looks like this:

// ❌ Bad: Leaking infrastructure into the domain
interface UserRepository {
  save(user: User, transactionManager: EntityManager): Promise<void>;
}

You have violated the architecture. The Domain now knows about EntityManager.

If you remove it:

interface UserRepository {
  save(user: User): Promise<void>;
}

How does the repository implementation know which transaction to attach to? Without a mechanism to pass this context invisibly, developers often resort to "dirty" hacks or forfeit transactional integrity entirely.

The Solution: AsyncLocalStorage and the Unit of Work

To solve this in a Node.js/TypeScript environment (like NestJS) without polluting the domain, we need two components:

  1. The Unit of Work (UoW) Port: A semantic interface in the Domain/Application layer defining when a transaction starts and ends.
  2. AsyncLocalStorage (ALS): The Node.js equivalent of Java's ThreadLocal. This allows us to propagate the transaction context to repositories implicitly, decoupling the method signatures from the infrastructure reality.

Here is the implementation using NestJSPrisma (as the ORM example), and AsyncLocalStorage.

1. The Domain Layer (Ports)

First, define the contract. The Domain only cares that a set of operations happens atomically.

// src/core/ports/unit-of-work.port.ts

export interface UnitOfWork {
  /**
   * Execute a block of logic within a transaction.
   * If the block throws, the transaction rolls back.
   * If the block returns, the transaction commits.
   */
  runInTransaction<T>(work: () => Promise<T>): Promise<T>;
}

2. The Infrastructure Layer (Context Management)

We need a static store to hold the current transaction reference. We use Node's native AsyncLocalStorage.

// src/infrastructure/database/transaction.context.ts
import { AsyncLocalStorage } from 'async_hooks';
import { PrismaClient } from '@prisma/client';

// The type of our transaction client (Prisma specific)
type PrismaTransactionClient = Omit<
  PrismaClient,
  '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'
>;

export class TransactionContext {
  private static storage = new AsyncLocalStorage<PrismaTransactionClient>();

  static run<T>(tx: PrismaTransactionClient, callback: () => Promise<T>): Promise<T> {
    return this.storage.run(tx, callback);
  }

  static getcurrentTransaction(): PrismaTransactionClient | undefined {
    return this.storage.getStore();
  }
}

3. The Infrastructure Layer (Adapters)

Now we implement the Unit of Work and the Repositories. This is where the magic happens: Repositories check the context dynamically.

The Unit of Work Implementation:

// src/infrastructure/database/prisma-unit-of-work.ts
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { UnitOfWork } from '../../core/ports/unit-of-work.port';
import { TransactionContext } from './transaction.context';

@Injectable()
export class PrismaUnitOfWork implements UnitOfWork {
  constructor(private readonly prisma: PrismaClient) {}

  async runInTransaction<T>(work: () => Promise<T>): Promise<T> {
    // We start a transaction using Prisma's interactive transaction API
    return this.prisma.$transaction(async (tx) => {
      // We wrap the execution of 'work' inside the AsyncLocalStorage run method.
      // Any code running inside 'work' can now access 'tx' via TransactionContext.
      return TransactionContext.run(tx, work);
    });
  }
}

The Repository Implementation:

Note that the repository method signature remains clean. It grabs the client from the context if it exists, or falls back to the global client.

// src/infrastructure/adapters/user.repository.ts
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { UserRepositoryPort } from '../../core/ports/user-repository.port';
import { TransactionContext } from '../database/transaction.context';
import { UserMapper } from '../mappers/user.mapper';
import { User } from '../../core/domain/user';

@Injectable()
export class PrismaUserRepository implements UserRepositoryPort {
  constructor(private readonly prisma: PrismaClient) {}

  // Helper to get the correct client (Transaction or Global)
  private getClient() {
    const tx = TransactionContext.getcurrentTransaction();
    return tx || this.prisma;
  }

  async save(user: User): Promise<void> {
    const persistenceModel = UserMapper.toPersistence(user);
    
    // Uses the transaction if active, otherwise uses standard client
    await this.getClient().user.upsert({
      where: { id: persistenceModel.id },
      update: persistenceModel,
      create: persistenceModel,
    });
  }
}

4. The Application Layer (Usage)

Finally, here is how a Use Case utilizes this. Notice there are no imports from @prisma/client or infrastructure folders. It relies entirely on the Ports.

// src/application/use-cases/register-user.use-case.ts
import { Inject, Injectable } from '@nestjs/common';
import { UnitOfWork } from '../../core/ports/unit-of-work.port';
import { UserRepositoryPort } from '../../core/ports/user-repository.port';
import { WalletRepositoryPort } from '../../core/ports/wallet-repository.port';
import { User } from '../../core/domain/user';

@Injectable()
export class RegisterUserUseCase {
  constructor(
    @Inject('UnitOfWork') private readonly uow: UnitOfWork,
    @Inject('UserRepository') private readonly userRepo: UserRepositoryPort,
    @Inject('WalletRepository') private readonly walletRepo: WalletRepositoryPort,
  ) {}

  async execute(command: RegisterUserCommand): Promise<void> {
    // All operations inside this callback are atomic
    await this.uow.runInTransaction(async () => {
      
      const user = User.create(command.email, command.password);
      await this.userRepo.save(user);

      // Business Rule: New users get a wallet. 
      // This MUST happen in the same transaction as user creation.
      const wallet = user.createWallet(); 
      await this.walletRepo.save(wallet);
      
      // If walletRepo fails, userRepo rolls back automatically via Prisma.$transaction
    });
  }
}

Why This Works

1. Context Propagation via ALS

AsyncLocalStorage creates a context that is unique to the current asynchronous execution chain. When uow.runInTransaction is called, we store the database transaction handle in this context. Because Node.js handles async continuations, the userRepo.save method—called deeper in the stack—can "see" this context without it being passed as an argument.

2. Strict Boundary Enforcement

The RegisterUserUseCase depends only on UnitOfWork (an interface). It does not know we are using Prisma, TypeORM, or raw SQL. We could swap PrismaUnitOfWork for a MongoUnitOfWork (using startSession), and the application code would remain unchanged.

3. Testing is Simplified

Because the dependency is an interface (UnitOfWork), unit testing the Use Case is trivial. You can mock the UoW to simply execute the callback immediately:

// Mock for Unit Tests
const mockUow: UnitOfWork = {
  runInTransaction: async (work) => work(),
};

Conclusion

Transactional consistency in Hexagonal Architecture does not require compromising your domain purity. By leveraging the Unit of Work pattern combined with AsyncLocalStorage, you achieve a rigorous separation of concerns. The Domain defines the boundaries of atomicity, while the Infrastructure handles the mechanics of the database driver.

This approach provides the reliability of ACID transactions with the flexibility required for long-term maintainability.