Transaction Management
Grant wraps multi-step operations in database transactions to guarantee atomicity — if any step fails, everything rolls back.
How It Works
Transaction support follows the project's ports and adapters pattern:
- Port —
ITransactionalConnection<TTransaction>in@grantjs/coredefines the contract. - Adapter —
DrizzleTransactionalConnectioninapps/apiimplements it using Drizzle ORM.
// @grantjs/core — port
export interface ITransactionalConnection<TTransaction = unknown> {
withTransaction<T>(operation: (transaction: TTransaction) => Promise<T>): Promise<T>;
}
// apps/api — Drizzle adapter
export class DrizzleTransactionalConnection implements ITransactionalConnection<Transaction> {
constructor(private readonly db: DbSchema) {}
async withTransaction<T>(operation: (transaction: Transaction) => Promise<T>): Promise<T> {
return await this.db.transaction(operation);
}
}The Transaction type is derived from Drizzle's transaction parameter, ensuring full type safety across the stack. Handlers and services receive ITransactionalConnection — they never depend on Drizzle directly.
Why Transactions Matter
- Atomicity — Creating a user with a role assignment either completes fully or not at all. No orphaned entities.
- Connection reuse — A single database connection flows through the entire request chain, preventing connection pool exhaustion.
- Consistent audit trail — Entity changes and their audit logs are written in the same transaction.
Service Layer Pattern
Handlers coordinate multi-repository operations using withTransaction:
async createUserWithRole(userData: CreateUserInput, roleId: string, performedBy: string): Promise<User> {
return this.txConnection.withTransaction(async (tx) => {
// Create user
const user = await this.repositories.users.create(userData, performedBy, tx);
// Assign role (same transaction)
await this.repositories.roles.assignToUser(roleId, user.id, performedBy, tx);
// Audit log (same transaction)
await this.repositories.auditLogs.create({
entityType: 'USER',
entityId: user.id,
action: 'CREATE_WITH_ROLE',
metadata: JSON.stringify({ roleId }),
performedBy,
}, tx);
return user;
});
}Every repository method accepts an optional tx parameter. When provided, the operation joins the existing transaction instead of creating a new one.
Repository Integration
All 25+ repositories extend base classes (EntityRepository, PivotRepository) that support transaction injection:
async create(data: TCreate, performedBy: string, tx?: Transaction): Promise<TEntity> {
const executor = tx || this.db;
return executor.transaction(async (transaction) => {
const [entity] = await transaction.insert(this.table).values(data).returning();
await transaction.insert(this.auditTable).values({
entityId: entity.id, action: 'CREATE',
newValues: JSON.stringify(entity), performedBy,
});
return entity;
});
}Error Handling
Transactions automatically roll back on any thrown error. GraphQL resolvers and REST handlers catch transaction failures and convert them to appropriate error responses:
try {
return await this.txConnection.withTransaction(async (tx) => {
// ... multi-step operation
});
} catch (error) {
// Transaction already rolled back
throw new GraphQLError('Failed to create user', {
extensions: { code: 'USER_CREATION_FAILED' },
});
}Related:
- Audit Logging — Audit logs written within transactions
- Contributing Guide — Layer boundaries and service patterns