Unit of Work
Introduction
Section titled “Introduction”Use Unit of Work when multiple database operations must succeed or fail together.
A typical application operation looks like this:
Application service / handler ↓Unit of Work ↓Repository / Data Source ↓DatabaseIn Xeno.JS, the public transaction API is:
await unitOfWork.runInTransaction(callback, signal)You do not manually call begin(), commit(), or rollback().
Prerequisites
Section titled “Prerequisites”Before using Unit of Work:
- Configure a database with
AppBuilder.addDb(). - Build the application.
- Execute the transactional operation inside an active service scope.
- Resolve
UNIT_OF_WORKfrom that scope.
For provider-specific configuration, see:
For the DI concepts used by this page, see:
Configure the Database
Section titled “Configure the Database”Unit of Work is registered by the database module, so the database must be configured during application bootstrap.
For example:
import { AppBuilder } from '@xeno-js/core'
const app = new AppBuilder()
app.addDb((options, config) => { options.connectionString = config.getOrThrow('DB_CONNECTION_STRING')})For SQLite, enable the SQLite provider:
app.addDb((options, config) => { options.connectionString = config.get('DB_CONNECTION_STRING', 'file:./data/app.db')
options.enableSqlLite = true})The provider configuration belongs in application bootstrap. Unit of Work does not require a separate transaction configuration.
Build the Application
Section titled “Build the Application”Build the application before resolving UNIT_OF_WORK:
const container = await app.build()The returned container exposes the configured dependency-injection services.
UNIT_OF_WORK is a scoped service, so it must be resolved from an active scope.
Resolve Unit of Work
Section titled “Resolve Unit of Work”Create a service scope and resolve UNIT_OF_WORK from it:
const scope = container.createScope()
try { const unitOfWork = scope.resolve('UNIT_OF_WORK')
// transactional work} finally { await scope.dispose()}The scope determines the lifetime of the Unit of Work instance.
In a request-driven application, the framework integration may already provide an active service scope. In that case, resolve the service from the current application scope instead of creating a second scope unnecessarily.
Start Transactional Work
Section titled “Start Transactional Work”Call runInTransaction() with an asynchronous callback:
const result = await unitOfWork.runInTransaction( async () => { // database operations return result }, undefined,)The callback contains the complete set of operations that must execute as one transaction.
The second argument is an optional AbortSignal. Pass undefined when cancellation is not required.
Perform Multiple Data Operations
Section titled “Perform Multiple Data Operations”Unit of Work is useful when one application operation changes multiple pieces of data.
For example, creating a user may require both a user record and a related profile:
class UserService { constructor( private readonly unitOfWork: IUnitOfWork, private readonly users: UserRepository, private readonly profiles: ProfileRepository, ) {}
async createUser(input: CreateUserInput): Promise<User> { return this.unitOfWork.runInTransaction( async () => { const user = await this.users.create({ email: input.email, name: input.name, })
await this.profiles.create({ userId: user.id, displayName: input.displayName, })
return user }, undefined, ) }}The repositories in this example are application-defined. The important Xeno.JS contract is the transaction boundary around the application operation.
If users.create() and profiles.create() both succeed, the callback completes successfully.
If either operation fails, the callback rejects and the transaction does not complete successfully.
Commit and Rollback Behavior
Section titled “Commit and Rollback Behavior”Xeno.JS exposes transaction boundaries through runInTransaction() rather than separate commit and rollback methods.
Successful callback
Section titled “Successful callback”When the callback completes successfully:
await unitOfWork.runInTransaction( async () => { await users.create(user) await profiles.create(profile) }, undefined,)the transaction completes successfully.
There is no explicit commit() call.
Failed callback
Section titled “Failed callback”When an operation throws an error:
await unitOfWork.runInTransaction( async () => { await users.create(user)
throw new Error('Profile creation failed') }, undefined,)the error is propagated to the caller.
There is no explicit rollback() call.
Handle the application error at the appropriate application boundary:
try { await unitOfWork.runInTransaction( async () => { await users.create(user) await profiles.create(profile) }, undefined, )} catch (error) { // Handle or propagate the application failure. throw error}Do not catch an error only to continue execution as if the transactional operation had succeeded.
Use an AbortSignal
Section titled “Use an AbortSignal”runInTransaction() accepts an optional AbortSignal:
const controller = new AbortController()
await unitOfWork.runInTransaction( async () => { await users.create(user) }, controller.signal,)If the signal has already been aborted when runInTransaction() is called, Xeno.JS rejects the operation before starting the transaction.
Use the signal when the surrounding application operation already has cancellation semantics.
Complete Example
Section titled “Complete Example”The following example shows the complete application-level flow:
import { AppBuilder } from '@xeno-js/core'import type { IUnitOfWork } from '@xeno-js/shared'
interface CreateUserInput { email: string name: string displayName: string}
interface User { id: string email: string name: string}
interface UserRepository { create(input: { email: string name: string }): Promise<User>}
interface ProfileRepository { create(input: { userId: string displayName: string }): Promise<void>}
class UserService { constructor( private readonly unitOfWork: IUnitOfWork, private readonly users: UserRepository, private readonly profiles: ProfileRepository, ) {}
async createUser(input: CreateUserInput): Promise<User> { return this.unitOfWork.runInTransaction( async () => { const user = await this.users.create({ email: input.email, name: input.name, })
await this.profiles.create({ userId: user.id, displayName: input.displayName, })
return user }, undefined, ) }}
const app = new AppBuilder()
app.addDb((options, config) => { options.connectionString = config.get( 'DB_CONNECTION_STRING', 'postgres://username:password@localhost:5432/myapp', )})
const container = await app.build()
const scope = container.createScope()
try { const unitOfWork = scope.resolve('UNIT_OF_WORK')
const userService = new UserService( unitOfWork, userRepository, profileRepository, )
await userService.createUser({ email: 'alice@example.com', name: 'Alice', displayName: 'Alice', })} finally { await scope.dispose()}The repository instances in this example represent application-specific data-access services. They are intentionally separate from the Unit of Work API.
The important flow is:
addDb() ↓app.build() ↓createScope() ↓resolve('UNIT_OF_WORK') ↓runInTransaction(...) ↓repository / data-source operationsUse Unit of Work from a Handler
Section titled “Use Unit of Work from a Handler”Unit of Work normally belongs below the transport boundary.
For a CQRS command, the flow can be:
HTTP / transport ↓Command ↓Command Handler ↓Application Service ↓Unit of Work ↓Data Source / Repository ↓DatabaseFor example:
class CreateUserHandler { constructor(private readonly users: UserService) {}
async execute(command: CreateUserCommand): Promise<User> { return this.users.createUser({ email: command.email, name: command.name, displayName: command.displayName, }) }}The handler does not need to manage transaction boundaries directly when the application service owns the transactional operation.
This keeps transaction management in the application layer rather than in the HTTP or transport layer.
Common Problems
Section titled “Common Problems”UNIT_OF_WORK cannot be resolved
Section titled “UNIT_OF_WORK cannot be resolved”Make sure that:
addDb()was called during bootstrap.- The application was built with
await app.build(). - You are resolving
UNIT_OF_WORKfrom an active service scope. - The database module was not omitted from the application bootstrap.
Example:
const container = await app.build()const scope = container.createScope()
try { const unitOfWork = scope.resolve('UNIT_OF_WORK')} finally { await scope.dispose()}Resolving a scoped service from the root container
Section titled “Resolving a scoped service from the root container”UNIT_OF_WORK is scoped.
Do not do this:
const container = await app.build()
container.resolve('UNIT_OF_WORK')Resolve it from an IServiceScope:
const scope = container.createScope()
try { const unitOfWork = scope.resolve('UNIT_OF_WORK')} finally { await scope.dispose()}See Scoped Lifetime for the general DI lifetime rules.
Transaction work is not grouped together
Section titled “Transaction work is not grouped together”Make sure every operation that must succeed or fail together is inside the same callback:
await unitOfWork.runInTransaction( async () => { await users.create(user) await profiles.create(profile) await auditLog.create(entry) }, undefined,)Operations performed outside the callback are not part of that transaction boundary.
Manually calling commit() or rollback()
Section titled “Manually calling commit() or rollback()”Do not add manual transaction control around runInTransaction().
The public API is:
await unitOfWork.runInTransaction(callback, signal)The callback defines the transactional operation.
The transaction callback throws
Section titled “The transaction callback throws”A failure from the callback is propagated to the caller.
Handle the error at the application boundary where the operation should be reported, retried, or converted into an application error.
Do not assume that returning a failure value automatically represents a transaction failure. If the operation must fail transactionally, let the callback reject.
Unit of Work and Data Sources
Section titled “Unit of Work and Data Sources”Unit of Work defines the transaction boundary; the provider-specific Data Source performs the actual data access.
For example:
Unit of Work ↓transactional application operation ↓PostgreSQL Data Source ↓PostgreSQLor:
Unit of Work ↓transactional application operation ↓SQLite Data Source ↓SQLiteThe same Unit of Work API is used with the database configured through AppBuilder.addDb().
Provider-specific setup belongs in the provider documentation:
Related Docs
Section titled “Related Docs”Dependency Injection
Section titled “Dependency Injection”Application
Section titled “Application”Support Us
Section titled “Support Us”Xeno.JS is an MIT-licensed open source project. It can grow thanks to the support of these awesome people. If you’d like to join them, please read more at support section
