Skip to main content
Archived design reference — not the current architecture

These docs describe a proposed Simplified Hexagonal Architecture from an internal design exploration that was not adopted. The design principles (entity invariants, contracts, dependency inversion, load → authorize → validate → execute, in-memory-fake testing) remain useful, but the specifics below do not exist in this codebase: the package paths packages/core / packages/infra / packages/shared, the @packages/* import aliases, the main.ts wiring entry point, and the EnableArchitectureTransition feature flag. Do not follow the paths, aliases, or imports here as-is.

Transactions

← Back to README


The Challenge

Actions often need to save multiple things atomically:

// What if inventory reservation fails after order is saved?
async function createOrder(deps, ctx, input) {
await deps.orderRepository.save(order); // Saved
await deps.reserveStock(order.id, items); // Failed - now inconsistent!
}

We need all-or-nothing behavior while maintaining feature isolation.


Strategy: Ambient Transactions

This architecture uses ambient transactions - repositories automatically detect and use an active transaction without callers passing it explicitly.

tx.run(async () => {
AsyncLocalStorage holds the transaction session

await deps.repository.save(order)
-> getSession() checks context -> uses txSession

await deps.reserveStock(orderId, items)
-> inventoryRepo.reserve(...)
-> getSession() checks context -> uses txSession
})

Key benefits:

  • Function dependencies stay clean (no tx parameter)
  • Actions don't know which repositories participate
  • Familiar pattern (Spring @Transactional, .NET TransactionScope)

The Contract

// packages/core/src/shared/TransactionManager.ts
export interface TransactionManager {
run<T>(work: () => Promise<T>): Promise<T>;
}

Wrap work in tx.run(), and all database operations inside automatically participate.


Using Transactions in Actions

// packages/core/src/orders/actions/createOrder.ts
export interface CreateOrderDeps {
tx: TransactionManager;
repository: OrderRepository;
mailer: Mailer;
getCustomer: (customerId: string) => Promise<CustomerData | null>;
reserveStock: (orderId: string, productId: string, quantity: number) => Promise<boolean>;
}

export async function createOrder(
deps: CreateOrderDeps,
ctx: AuthContext,
input: CreateOrderInput
): Promise<Order> {
// 1. Authorize (before transaction)
if (!OrderPolicies.canCreate(ctx)) {
throw new BusinessError('Not authorized to create orders');
}

// 2. Everything inside runs in a transaction
const { order, customer } = await deps.tx.run(async () => {
const customer = await deps.getCustomer(input.customerId);
if (!customer) throw new NotFoundError('Customer not found');
if (!customer.isInGoodStanding) {
throw new BusinessError('Customer account is not in good standing');
}

const order = new Order(crypto.randomUUID(), input.customerId, input.items);
order.submit();
await deps.repository.save(order);

for (const item of order.items) {
const reserved = await deps.reserveStock(order.id, item.productId, item.quantity);
if (!reserved) throw new BusinessError(`Insufficient stock for ${item.productId}`);
}

return { order, customer };
});

// 3. Side effects AFTER transaction commits
await deps.mailer.send(customer.email, 'Order Confirmed', `Order #${order.id} placed.`);

return order;
}

Function dependencies (getCustomer, reserveStock) automatically participate in the transaction.


Implementation

Transaction Context (AsyncLocalStorage)

// packages/infra/src/shared/mongodb/TransactionContext.ts
import { AsyncLocalStorage } from 'async_hooks';
import { ClientSession, Connection } from 'mongoose';

export const transactionContext = new AsyncLocalStorage<ClientSession>();

export function getCurrentSession(): ClientSession | undefined {
return transactionContext.getStore();
}

Transaction Manager

// packages/infra/src/shared/mongodb/MongoTransactionManager.ts
import { Connection } from 'mongoose';
import { TransactionManager } from '@packages/core/shared/TransactionManager';
import { transactionContext } from './TransactionContext';

export class MongoTransactionManager implements TransactionManager {
constructor(private connection: Connection) {}

async run<T>(work: () => Promise<T>): Promise<T> {
const session = await this.connection.startSession();
try {
let result: T;
await session.withTransaction(async () => {
result = await transactionContext.run(session, work);
});
return result!;
} finally {
await session.endSession();
}
}
}

Base Repository

All repositories extend this to auto-detect active transactions:

// packages/infra/src/shared/mongodb/BaseMongoRepository.ts
import { Model, ClientSession } from 'mongoose';
import { getCurrentSession } from './TransactionContext';

export abstract class BaseMongoRepository<T> {
constructor(protected model: Model<T>) {}

protected get session(): ClientSession | undefined {
return getCurrentSession();
}

// Use this.session in all operations to automatically use transaction if active
protected async saveWithSession(doc: T): Promise<void> {
await this.model.create([doc], { session: this.session });
}
}

Repository implementations use this.session for all queries - automatically uses transaction if active.


When to Use Transactions

Use tx.run() WhenDon't Use When
Multiple writes must succeed/fail togetherSingle write operation (already atomic)
Cross-feature writes need atomicityRead-only queries
Read-then-write must be consistentSide effects (email, webhooks)
Long-running operations (holds locks)

Guidelines

// Good: Multiple related writes
const order = await deps.tx.run(async () => {
await deps.repository.save(order);
await deps.reserveStock(order.id, items);
return order;
});

// Good: Side effects after transaction
const order = await deps.tx.run(async () => {
await deps.repository.save(order);
return order;
});
await deps.mailer.send(...); // After commit

// Bad: Side effects inside transaction
await deps.tx.run(async () => {
await deps.repository.save(order);
await deps.mailer.send(...); // Rolls back order if email fails
});

// Bad: Single operation (unnecessary overhead)
await deps.tx.run(async () => {
await deps.repository.save(order); // Just do: await deps.repository.save(order)
});

Key Principles

PrincipleDescription
Side Effects AfterEmail, webhooks happen after tx.run() completes
Keep It ShortOnly database operations inside transaction
Auth BeforeCheck permissions before starting transaction

Testing

See Testing for FakeTransactionManager and testing patterns.

// Quick reference: FakeTransactionManager
export class FakeTransactionManager implements TransactionManager {
public runCount = 0;
async run<T>(work: () => Promise<T>): Promise<T> {
this.runCount++;
return work();
}
}

Summary

ConceptDescription
Ambient TransactionsRepositories auto-detect via AsyncLocalStorage
Clean DependenciesCross-feature calls don't need tx parameter
Side Effects AfterEmail, webhooks after commit
Keep It ShortOnly DB operations inside tx.run()

Next Steps