Lucid Transaction Boundaries and Data Integrity in AdonisJS
Validate before opening a Lucid transaction, keep writes small, and map database constraint errors to predictable HTTP responses.
19 Aug 2025, 00:52 UTC

The problem
Lucid is AdonisJS's Active Record ORM: a model instance represents a database row, and methods such as save() persist that row directly. When one HTTP request must write several related rows—an order and its line items, for example—it is tempting to call save() on each model in sequence. If the third call fails, the first two rows stay in the database unless you explicitly wrapped the writes in a transaction.
Lifecycle hooks such as beforeSave and afterUpdate add another risk: they can run side effects inside the transaction, so a hook failure can obscure which write actually failed. The useful takeaway is to validate input before opening a transaction, keep the transaction body small, and map database constraint errors to predictable HTTP responses at the framework edge.
Requirements
- All writes triggered by one request must commit together or not at all.
- Input validation must complete before any database write begins.
- Database constraint violations, such as unique or foreign key failures, must become predictable HTTP errors, not unhandled 500 responses.
- Model hooks must not hide write failures or execute outside the transaction when they are not needed.
Smallest suitable design
Open a transaction through the Lucid database manager, pass the transaction client to each model write, and commit only after every write succeeds.
import Database from '@ioc:Adonis/Lucid/Database'
async function placeOrder (input) {
const trx = await Database.transaction()
try {
const order = await Order.create(
{ customer_id: input.customerId, status: 'pending' },
{ client: trx }
)
await OrderItem.query().useTransaction(trx).createMany(
input.items.map((item) => ({ order_id: order.id, ...item }))
)
await trx.commit()
return order
} catch (error) {
await trx.rollback()
throw error
}
}
Order and OrderItem are placeholders for your application models. The import path depends on your AdonisJS major version; the transaction pattern itself is the same across supported Lucid releases. Run this code inside a controller or service with the same database credentials the application already uses. A meaningful risk is forgetting await trx.rollback() in the catch block, which can leave the transaction open and hold database locks.
Trust and data boundaries
| Boundary | Responsibility | Failure result |
|---|---|---|
| Controller input | AdonisJS Validator schema rejects malformed JSON | 422 response before the database is touched |
| Lucid model | Database constraints such as unique and foreign keys | Constraint exception mapped to 400 or 409 |
| Transaction block | All-or-nothing persistence across multiple rows | Rollback on exception; no partial rows |
Validate the request in the controller or a dedicated request object. The model layer should receive already-sanitized data, not raw HTTP input. This keeps the trust boundary outside the persistence layer.
Operational checks
- Force a failure halfway through a transaction block—for example, throw after creating the parent row—and assert that no partial data was persisted.
- Pass malformed JSON to the validator and confirm the database layer is never reached.
- Inspect Adonis logger query output to confirm eager loading is resolving relationships instead of producing N+1 queries.
Failure modes
- Hook side effects. beforeSave or afterUpdate may hash passwords or write audit rows. If a hook throws mid-transaction, the rollback should undo database changes, but external side effects such as emails are not undone.
- Manual transaction leaks. If you create a transaction manually and omit rollback in the catch path, the connection can remain open and block other writers.
- Constraint violations. A duplicate unique key or missing foreign key raises a database exception. Without a global exception handler, this becomes a 500 error instead of a meaningful client response.
- Large fetches. Loading many models without chunking or the query builder can exhaust memory because Active Record instantiates a full object per row.
Conditions that would change the design
- If you need consistency across services, a database transaction alone is insufficient; use a transactional outbox or saga pattern with compensating actions.
- If writes are high-volume and independent, avoid one monolithic transaction. Use idempotency keys and smaller batches instead.
- If Lucid hooks become difficult to trace, move side effects into explicit service methods and use the query builder directly for bulk writes so hooks do not run unexpectedly.
Limitations
- Active Record couples business logic to the database schema, which can become restrictive as domain complexity grows.
- Transactions cover database changes only. Non-database side effects require separate compensation logic.
- Bulk operations can bypass certain model hooks, so audit or normalization logic may not run where expected.
Practical way to check the result
Write an integration test that opens a transaction, creates a parent record, throws before creating child records, rolls back, and then queries the parent table to assert zero rows were committed. Repeat the test with a valid payload to confirm the happy path persists all rows. Do not assume the example above was executed; verify it against your own database and AdonisJS version.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.