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​