Transactions
Transactions in Ambiten allow related MongoDB operations to execute inside one atomic transaction boundary.
When participating operations succeed, the transaction can commit.
When the transaction callback fails, the surrounding boundary can abort the transaction so participating MongoDB writes do not partially complete.
Ambiten integrates transaction state into its execution model so application services do not need to manually propagate MongoDB sessions through every function call.
Conceptually:
Transaction Boundary
↓
ClientSession
↓
AmbitenContext
↓
AmbitenModel.mergeCtx()
↓
Effective ModelContext
↓
Participating Operations
↓
MongoDBThe boundary owns the transaction. Context carries the session. Models participate in it.
If you are looking for the wider runtime invariants behind this behavior, see Execution Guarantees.
Why Transactions Matter
Many workflows contain several dependent persistence operations.
For example:
create user
↓
create wallet
↓
write audit recordIf those writes represent one logical action, allowing only some of them to persist can leave the application in an invalid state.
A transaction provides one persistence boundary around those participating operations.
All participating writes succeed
↓
commitor:
transaction callback fails
↓
abortTransactions are therefore most useful when correctness depends on several MongoDB operations succeeding together.
The Ambiten Transaction Model
Ambiten treats transaction participation as execution state.
The primary explicit API is:
await AmbitenContext.withTransaction(
async () => {
// transactional work
}
);During transactional execution, the active MongoDB session becomes part of the Ambiten execution context.
Participating model operations then derive that session into their effective ModelContext.
withTransaction(...)
↓
ClientSession
↓
AmbitenContext.session
↓
AmbitenModel.mergeCtx()
↓
ModelContext.session
↓
Model OperationsThis removes the need to manually forward session through ordinary application services.
Transaction Participation vs Transaction Ownership
These are separate responsibilities.
Transaction Boundary
The enclosing transaction boundary owns:
session creation
transaction start
callback lifetime
commit
abort / rollback
session completionParticipating Operations
Model operations inside that boundary consume the active session.
Transaction Boundary
↓
session S1
↓
UserModel
↓
session S1
↓
WalletModel
↓
session S1Individual models do not independently commit the surrounding transaction.
Models participate. The enclosing boundary decides the outcome.
Basic Usage
import {
AmbitenContext
} from "@ambiten/core";
await AmbitenContext.withTransaction(
async () => {
await UserModel.create({
name: "John"
});
await ProfileModel.create({
user: "John"
});
}
);Both participating model operations execute using the active transaction session.
If the transaction callback rejects, the error propagates through the transaction boundary and the transaction can be aborted before control returns to the caller.
How Session Propagation Works
Transaction state follows the same context-binding model used by the rest of Ambiten.
Transaction Boundary
↓
AmbitenContext.session
↓
Application Logic
↓
AmbitenModel.mergeCtx()
↓
Effective ModelContext
↓
DbProvider / AmbitenClient
↓
MongoDBThe model does not need:
await UserModel.create(
data,
{
session
}
);during ordinary context-driven transactional execution.
Instead, the active session is inherited from the execution context.
AmbitenContext and ModelContext
AmbitenContextState contains the execution-level session:
session?: ClientSession;ModelContext also contains:
session?: ClientSession;The two contracts serve different layers.
AmbitenContextState
= full execution state
ModelContext
= model-operation-facing stateDuring model execution:
AmbitenContext.session
↓
AmbitenModel.mergeCtx()
↓
ModelContext.sessionThe provider and model operation then receive the transaction session through the effective operation context.
Explicit Operation Context
Ambiten also supports explicit operation-level context where the model API permits it.
The general precedence remains:
explicit operation context
↓
active AmbitenContext
↓
model defaultsThat means transaction state should not be thought of as an unconditional immutable value if the public operation API explicitly allows a session override.
For ordinary transactional execution, however, the recommended pattern is to let the active transaction boundary supply the session.
await AmbitenContext.withTransaction(
async () => {
await UserModel.create(data);
}
);Automatic Participation
Inside an active transaction:
await AmbitenContext.withTransaction(
async () => {
await OrderModel.create(
order
);
await InventoryModel.updateOne(
{
_id: itemId
},
{
$inc: {
stock: -1
}
}
);
}
);participating Ambiten model operations can resolve the same active MongoDB session.
This is the transaction guarantee that matters:
same active transaction boundary
↓
same active session
↓
participating Ambiten operationsThe application does not need to manually attach the session to each model call.
What “Automatic” Means
Automatic session participation applies to operations that execute through Ambiten's transaction-aware runtime path.
It should not be interpreted as:
every arbitrary MongoDB driver call
automatically joins the transactionFor example, direct raw driver operations must use the appropriate session according to MongoDB's own API contract.
The safe mental model is:
Ambiten propagates transaction state for participating Ambiten operations. External or raw database calls remain responsible for their own session usage unless explicitly integrated with the active transaction.
Nested Transaction-Aware Execution
Nested transaction-aware code should remain aligned with the active transaction state rather than creating unrelated transactional state for every nested model call.
Conceptually:
Outer Transaction Boundary
↓
session S1
↓
Service A
↓
Model A
↓
Service B
↓
Model BNested application calls can therefore participate in the same active session without receiving it as a function argument.
If a nested withTransaction(...) call is supported by the runtime, it should follow Ambiten's existing transaction-aware behavior rather than be treated as an independent cross-database transaction.
The important architectural invariant is:
one active transaction execution
→ one participating session staterather than competing session plumbing throughout nested services.
Error Propagation
Errors inside the transaction callback should propagate through the enclosing transaction boundary.
try {
await AmbitenContext.withTransaction(
async () => {
await UserModel.create(
data
);
throw new Error(
"Operation failed"
);
}
);
} catch (error) {
console.error(
"Transaction failed",
error
);
}Application code should not swallow an error inside the transaction callback if that error is intended to cause the transaction to fail.
The transaction boundary needs the callback outcome in order to determine whether execution completed successfully.
Commit and Rollback
Transaction completion belongs to the enclosing boundary.
Conceptually:
withTransaction()
↓
Model A
↓
Model B
↓
callback resolves
↓
commitand:
withTransaction()
↓
Model A
↓
Model B throws
↓
callback rejects
↓
abortPost-processing inside individual models should not be described as committing or rolling back the transaction.
The transaction lifecycle exists one level above the participating operations.
Explicit Transaction Boundaries
Use AmbitenContext.withTransaction(...) when one specific application workflow should execute transactionally.
For example:
await AmbitenContext.withTransaction(
async () => {
const user =
await UserModel.create({
name: "Alice"
});
await WalletModel.create({
userId: user._id,
balance: 0
});
}
);This gives the application precise control over where the transaction starts and ends.
Adapter-Managed Transactions
Supported adapters can also establish transaction-aware execution at the framework boundary.
Conceptually:
Request / Invocation
↓
Adapter
↓
AmbitenContext
↓
Transaction Boundary
↓
Application Handler
↓
Model OperationsFor example, an adapter may be configured with:
enableTransactions: trueIn that mode, the adapter runtime establishes the transaction boundary around the supported execution lifecycle.
Application code can then remain:
await UserModel.create(data);
await AuditModel.create(log);without manually opening another transaction around every handler.
Explicit vs Adapter-Managed Transactions
These are two different usage patterns.
Execution-Wide Transaction
enableTransactions: trueUse this when the entire supported request, invocation, or execution should be transactional.
Explicit Transaction
AmbitenContext.withTransaction(...)Use this when only a specific workflow or part of the execution requires atomicity.
They should not be presented as two mandatory layers around the same operation.
Choose the transaction boundary that matches the application requirement.
Express Example
A transaction-aware Express integration may conceptually look like:
adapter.install(app, {
tenancy: {
header: "x-tenant-id",
validate: async (
tenantId
) => {
const tenant =
await MultiTenantManager
.resolveTenant(
tenantId
);
if (!tenant) {
throw new Error(
`Tenant with ID "${tenantId}" not found.`
);
}
return true;
}
},
enableTransactions: true
});Tenant resolution identifies which tenant belongs to the execution.
The transaction boundary then operates using the MongoDB infrastructure resolved for that execution.
There is no need for an unrelated fallback tenant when required tenant resolution fails.
Framework Transaction Boundaries
Different adapters establish execution differently.
Examples include:
Express
→ middleware / request lifecycle
Fastify
→ lifecycle hook execution
NestJS
→ interceptor and Observable execution
GraphQL
→ operation / resolver execution
Lambda
→ invocation executionFor transaction-aware adapters, the transaction callback must remain active for the actual application execution that participates in the transaction.
The transaction must not complete merely because the framework initially returned a deferred execution object.
The execution boundary and the transaction boundary must cover the same participating work.
NestJS and Deferred Execution
Frameworks that defer application work require special care.
For example, NestJS handlers commonly execute through RxJS Observables.
The actual subscription must remain inside the Ambiten execution boundary for context continuity.
For request-wide transactions, the transaction boundary must likewise remain active until the participating Observable execution has completed or failed.
This is a framework-integration concern rather than a model concern.
Multi-Tenant Transactions
Tenant identity and transaction state can coexist in the same execution context.
AmbitenContext
├── tenantId = tenant-a
└── session = S1Model execution then derives:
Effective ModelContext
├── tenantId = tenant-a
└── session = S1Tenant infrastructure resolution and transaction participation remain distinct responsibilities.
tenantId
→ determines tenant infrastructure
session
→ determines transaction participationTenant Infrastructure Alignment
A transaction session must belong to the MongoDB client participating in that transaction.
Conceptually:
tenantId
↓
tenant infrastructure
↓
MongoClient
↓
ClientSession
↓
transactionAmbiten's runtime should therefore keep tenant infrastructure resolution and session participation aligned.
A session created from one MongoDB client should not be treated as a portable session for unrelated tenant infrastructure.
Transaction State Does Not Authorize Tenant Access
A transaction can be tenant-aware without performing authorization.
These remain separate concerns:
Authentication
→ who is the caller?
Tenant Resolution
→ which tenant belongs to execution?
Authorization
→ may caller act for tenant?
Transaction
→ which MongoDB operations commit together?A transaction boundary does not replace application security checks.
Transactions and Dynamic Tenants
Dynamic tenant discovery may occur before transaction-capable infrastructure becomes available.
Conceptually:
tenantId
↓
MultiTenantManager.resolveTenant()
↓
TenantConfigResolver if needed
↓
tenant MongoClient
↓
ClientSession
↓
Transaction BoundaryThe transaction itself should operate against already resolved, transaction-capable MongoDB infrastructure.
Dynamic tenant discovery and transaction lifecycle are related runtime steps but remain separate responsibilities.
Transactions and AmbitenClient
AmbitenClient provides session capability through the provider contract.
const session =
await client.startSession(
ctx
);Conceptually:
ModelContext
↓
AmbitenClient.startSession(ctx)
↓
AmbitenClient.client(ctx)
↓
resolved MongoClient
↓
ClientSessionFor tenant-aware execution, session creation can therefore resolve through the appropriate tenant client.
Direct Session Control
Applications can also use AmbitenClient.startSession() directly when explicit MongoDB session control is required.
const session =
await client.startSession();This is useful for lower-level infrastructure work.
It is different from Ambiten's context-managed transaction path.
The two styles are:
DIRECT SESSION CONTROL
AmbitenClient.startSession()
↓
application manages session usageand:
AMBITEN TRANSACTION BOUNDARY
AmbitenContext.withTransaction(...)
↓
runtime carries active session
↓
participating model operationsTransactions and Schema Middleware
Schema middleware participates in the model operation's effective context.
AmbitenContext.session
↓
AmbitenModel.mergeCtx()
↓
ModelContext.session
↓
Schema Middleware
↓
Model OperationMiddleware can therefore observe transaction-aware operation state where the middleware API exposes that context.
The schema does not commit or abort the surrounding transaction.
It participates inside it.
Transactions and Providers
The provider receives the effective ModelContext.
AmbitenModel
↓
ModelContext.session
↓
DbProvider
↓
AmbitenClientThe provider can therefore resolve infrastructure consistent with the active transaction state without application services manually forwarding the session.
Context Isolation
Transaction state belongs to one execution boundary.
Execution A
session = S1must remain isolated from:
Execution B
session = S2or:
Execution C
no transactionThis is one of the reasons transaction state belongs in AmbitenContext instead of mutable process-global state.
Process vs Execution Lifetime
Transaction state is execution-scoped.
PROCESS LIFETIME
────────────────
AmbitenRuntime
AmbitenClient
MongoClient
MultiTenantManager
providersEXECUTION LIFETIME
──────────────────
AmbitenContext
tenantId
requestId
ClientSession
transaction stateA long-lived MongoDB client may serve many independent transaction boundaries over time.
A transaction session should not become global process state.
Cross-Process Boundaries
AsyncLocalStorage does not propagate transaction state across:
message queues
worker processes
separate services
HTTP calls
external event systemsA transaction is therefore not automatically distributed across service boundaries.
For example:
Service A MongoDB Transaction
↓
HTTP Request
↓
Service Bdoes not create one shared MongoDB transaction across the two services.
Each service has its own execution and infrastructure boundary.
Cross-Service Transactions
Ambiten transactions should not be documented as distributed transactions.
They coordinate participating MongoDB operations inside the supported transaction boundary.
For workflows spanning:
MongoDB
payment provider
email service
message broker
another serviceMongoDB transaction semantics alone cannot make all external side effects atomic.
Those workflows may require application patterns such as:
outbox
compensation
idempotency
workflow orchestrationdepending on system requirements.
External Side Effects
Avoid assuming that this is globally atomic:
await AmbitenContext.withTransaction(
async () => {
await OrderModel.create(order);
await paymentProvider.charge(
payment
);
}
);MongoDB can roll back the participating database write.
It cannot automatically reverse an external payment request.
The transaction boundary only governs resources participating in the MongoDB transaction.
Detached Async Work
Detached work should not be assumed to remain part of an active transaction.
For example:
Transaction Callback
↓
fire-and-forget task
↓
callback completes
↓
transaction boundary closesThe detached work may execute outside the transaction lifetime.
Integrity-sensitive work should remain awaited inside the transaction callback.
await AmbitenContext.withTransaction(
async () => {
await firstOperation();
await secondOperation();
}
);Keep Transactions Finite
Transaction boundaries should remain finite and focused.
Avoid using a transaction as an open-ended application context.
Good transaction boundaries normally wrap:
a finite set of related MongoDB operationsrather than:
an entire long-running business processThis makes failure behavior easier to reason about and reduces transaction duration.
Performance Considerations
Transactions add coordination overhead.
Use them where atomic multi-operation consistency is required.
They are often unnecessary for:
single independent writes
read-only operations
unrelated persistence work
operations already atomic at document levelLong-running transactions can also hold resources longer and increase the chance of transient transaction failures.
The objective is:
Use the smallest transaction boundary that preserves correctness.
Avoid External Latency Inside Transactions
Where possible, avoid:
slow HTTP calls
email delivery
payment processing
large remote API requests
human interaction
long-running computationinside the MongoDB transaction callback.
These extend transaction duration without becoming part of MongoDB's atomicity guarantee.
A better architecture often separates:
database atomicityfrom:
external workflow coordinationRetry Strategy
MongoDB transactions can fail under transient conditions.
If application-level retry behavior is required, it should remain:
bounded
explicit
safe for the workflow
aware of idempotencyFor example:
async function runWithRetry<T>(
fn: () => Promise<T>,
retries = 3
): Promise<T> {
for (
let attempt = 0;
attempt < retries;
attempt++
) {
try {
return await AmbitenContext
.withTransaction(
fn
);
} catch (error) {
if (
attempt ===
retries - 1
) {
throw error;
}
}
}
throw new Error(
"Transaction retries exhausted."
);
}Retry logic should not blindly retry every application error.
The application should distinguish retryable infrastructure failures from business or validation failures according to its operational policy.
Idempotency and Retries
A transaction callback may contain code that is unsafe to repeat.
For example:
send email
charge payment
publish external eventIf the transaction itself is retried, those external effects can occur more than once unless the workflow is designed for idempotency.
Therefore:
transaction retry
≠
automatic workflow retry safetyRetries should consider the entire callback, not only the MongoDB writes.
Example Workflow
await AmbitenContext.withTransaction(
async () => {
const user =
await UserModel.create({
name: "Alice"
});
await WalletModel.create({
userId: user._id,
balance: 0
});
await AuditModel.create({
action: "USER_CREATED",
userId: user._id
});
}
);The intended transaction boundary is:
UserModel.create
+
WalletModel.create
+
AuditModel.create
↓
one participating MongoDB transactionIf one participating operation causes the transaction callback to fail, the enclosing transaction boundary determines the rollback outcome.
Observability
Transaction execution can expose useful runtime information when logging or instrumentation is configured.
Relevant metadata may include:
tenantId
requestId
operation metadata
transaction outcome
duration
participating model operations
error informationAmbitenContext makes execution metadata available to instrumentation.
The delivery guarantees of the logging or telemetry backend remain separate from the transaction contract.
Avoid treating observability delivery as part of transaction atomicity.
Auditing
Audit records written through participating Ambiten model operations can join the same MongoDB transaction.
For example:
await AmbitenContext.withTransaction(
async () => {
await UserModel.create(
user
);
await AuditModel.create({
action: "USER_CREATED"
});
}
);In this case, the audit write participates as a MongoDB operation inside the same transaction boundary.
External audit sinks do not automatically gain the same atomicity.
Best Practices
Keep transaction boundaries around integrity-sensitive MongoDB work.
Prefer:
short
finite
explicit
awaited
single-purposetransaction callbacks.
Allow participating model operations to inherit the active session through runtime context.
Avoid:
manual session plumbing everywhere
detached work inside transaction boundaries
unbounded transactions
unnecessary transactions around simple operations
external side-effect assumptions
cross-service atomicity assumptionsChoose either adapter-wide or explicit workflow-level transactions according to the desired atomic boundary.
Relationship with Other Components
Atomic execution coordinated through AmbitenContext
A transaction boundary binds a MongoDB session into AmbitenContext, allowing participating model operations to inherit the same transaction state through their effective ModelContext without manual session propagation.
When the transaction callback succeeds, the enclosing boundary can commit. When it fails, the boundary aborts the transaction so participating writes do not persist as a partial unit.
The active session is stored in AmbitenContext and projected by AmbitenModel into the effective ModelContext used by participating operations.
Each transaction belongs to its active execution boundary, preventing session state from leaking into unrelated requests, jobs, workers, or concurrent tenant executions.
The complete runtime relationship is:
Execution Boundary
↓
AmbitenContext
↓
Transaction Boundary
↓
ClientSession
↓
AmbitenContext.session
↓
Application Logic
↓
AmbitenModel.mergeCtx()
↓
Effective ModelContext
↓
Schema / Middleware
↓
DbProvider / AmbitenClient
↓
MongoDB
↓
Commit / AbortFor adapter-managed execution:
Adapter
↓
AmbitenContext
↓
Transaction Boundary
↓
ApplicationFor explicitly scoped execution:
Application
↓
AmbitenContext.withTransaction(...)
↓
Transaction BoundaryBoth ultimately provide the same model-facing transaction state:
ModelContext.sessionMental Model
A useful transaction mental model is:
Boundary owns atomicity.
Context carries the session.
Model binds session to the operation.
Provider resolves transaction-capable infrastructure.
MongoDB provides transaction semantics.Or more compactly:
Transaction Boundary
↓
AmbitenContext.session
↓
ModelContext.session
↓
Participating MongoDB OperationsWhat Ambiten Transactions Do Not Guarantee
Ambiten transactions do not by themselves guarantee:
cross-service transactions
external API atomicity
message-broker atomicity
payment rollback
network availability
MongoDB availability
authorization
automatic cross-process session propagation
arbitrary raw-driver participation
infinite transaction lifetimeThose concerns belong to other application or infrastructure layers.
Summary
Transactions in Ambiten are execution-scoped and context-driven.
The transaction boundary establishes and owns the MongoDB transaction.
AmbitenContext carries the active ClientSession.
AmbitenModel.mergeCtx() projects that session into the effective ModelContext.
Participating Ambiten operations then execute against the active transaction state without requiring manual session propagation.
The core path is:
Transaction Boundary
↓
AmbitenContext.session
↓
AmbitenModel.mergeCtx()
↓
ModelContext.session
↓
DbProvider / AmbitenClient
↓
MongoDBThe transaction boundary owns:
start
commit
abort
completionwhile models own:
operation executionand providers/client infrastructure own:
database
MongoClient
session capabilityThe guiding principle is:
Keep atomicity at the boundary, transaction state in context, and session plumbing out of application services.
