AdonisJS: When to Use Lucid Transactions for Multi-Step Writes
Don't wrap every AdonisJS write in a database transaction. Use them only when multiple related writes must succeed or fail together, and keep the transaction boundary inside your service layer.
07 May 2026, 08:55 UTC

The Problem: Partial State After a Failed Multi-Step Write
When a single HTTP request creates several related database rows—an order, its line items, and a stock decrement—a failure halfway through leaves orphaned records and inconsistent data. The useful takeaway is to wrap only those related writes in a database transaction, and to keep that boundary inside a service layer, not inside the controller.
Requirements Before Adding a Transaction
Define atomicity for the feature. Ask: if step three fails, must steps one and two be undone? If the answer is no, a transaction adds lock contention and complexity without benefit. Simple creates, updates, or deletes that touch one table can stay outside a transaction.
Smallest Suitable Design
Start with one model per write. Introduce a transaction only when the business rule requires all-or-nothing behavior across multiple models. In AdonisJS with Lucid, a database transaction is a unit of work that either fully commits or fully rolls back. The transaction object is passed through the query client so every statement in the group shares the same database connection.
Example: Creating an Order with Line Items
Run node ace make:service Order from the project root. No special permissions are required. The generated file lives in app/services/. This example assumes AdonisJS 6 with Lucid ORM; verify the exact transaction API against your installed Lucid version.
import { inject } from '@adonisjs/core'
import { Database } from '@adonisjs/lucid/database'
import Order from '#models/order'
import OrderItem from '#models/order_item'
@inject()
export default class OrderService {
constructor(protected db: Database) {}
async createOrder(input: {
customerId: string
items: { productId: string; quantity: number }[]
}) {
return this.db.transaction(async (trx) => {
const order = await Order.create(
{ customerId: input.customerId },
{ client: trx }
)
await Promise.all(
input.items.map((item) =>
OrderItem.create(
{ orderId: order.id, productId: item.productId, quantity: item.quantity },
{ client: trx }
)
)
)
return order
})
}
}The controller should never call db.transaction directly. It validates the request, then hands the sanitized input to the service. If the service throws, the transaction rolls back automatically; if it resolves, the transaction commits.
Trust and Data Boundaries
The trust boundary sits between the HTTP layer and the service layer. The controller treats the request body as untrusted and runs it through AdonisJS validators. The service layer treats the validated input as trusted but still enforces database constraints. The transaction boundary is the service boundary: the controller does not know whether a transaction is used.
Operational Checks
- Watch for database deadlock errors when concurrent requests touch the same rows inside transactions. Lock contention happens when multiple operations wait for the same row or table lock.
- Log transaction duration. Long-running transactions hold connections and can exhaust the connection pool.
- Check that failed requests do not leave partial rows by querying the related tables after a known failure.
Failure Modes
| Failure mode | Symptom | Design response |
|---|---|---|
| Database connection lost mid-transaction | Request hangs or returns 500; partial writes may remain depending on the driver | Set a statement timeout; retry idempotent steps outside the transaction |
| Application crash before commit | Client receives no response; the database rolls back uncommitted work | Use idempotency keys—unique client-generated identifiers that let the server recognize retries and avoid duplicate work—so clients can safely retry |
| Validation error inside transaction | Transaction aborts; no partial data persists | Validate before entering the transaction when possible |
Conditions That Change the Design
| Condition | Design impact | Action |
|---|---|---|
| Single table write | No transaction needed | Keep the service method transaction-free |
| Multiple related writes in one service | Use a Lucid transaction | Wrap writes in db.transaction |
| Writes span multiple services or databases | A local transaction is insufficient | Introduce a saga—a sequence of local transactions where each step has a compensating action if a later step fails—or an outbox pattern instead of distributed transactions |
Limitations and How to Check the Result
Transactions do not fix invalid business logic; they only guarantee that the database moves from one consistent state to another or stays put. They also do not protect against errors in non-database systems, such as a third-party API call made inside the transaction block.
Practical check: write a test that makes the second insert fail—for example, by violating a foreign key—and assert that the first insert is not visible afterward. If the first row persists, the transaction boundary is misplaced or the query client was not passed correctly.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.