Which per-object behaviours does a set-based delete issued through a data-access layer skip, and who handles them instead?
answer
- the layer never sees individual rows
- cascade and callbacks are application-side, not engine-side
- the engine still enforces its own constraints
- schema referential actions still fire
basics
~20 sIt skips everything the layer runs per object: cascades to children, lifecycle hooks and the events or audit writes inside them, and the per-row version check. The database still enforces its own constraints, and the caller must supply the rest.
solid answer
~50 sA per-object delete is a small program: the layer walks the object's mapped associations and cascades the delete to children, fires whatever before/after callbacks are registered, and issues a `DELETE ... WHERE id = ? AND version = ?` so a concurrent change is detected. A set-based delete is one statement; the layer forwards it and the engine matches rows. None of that per-object program runs. What remains is only what the database itself enforces — foreign keys, check constraints, and any referential action such as `ON DELETE CASCADE` declared in the schema. So either the schema owns child cleanup, or you delete children explicitly in dependency order before the parent, or the statement fails on a foreign key. Audit rows, published events and derived-field maintenance that lived in hooks must be re-created deliberately, in the bulk code path.
go deeper
Know that a bulk statement does not run the extra work the layer does per object — no cascade to children, no callbacks — while database constraints still apply.
Separate the two enforcement sites cleanly: mapped cascade and lifecycle hooks live in the layer and are skipped, whereas foreign keys, check constraints and schema-declared referential actions live in the engine and always apply.
Say how you discharge the skipped work — schema referential actions, explicit child statements in dependency order, audit rows written set-based — and how you keep the bulk path from drifting when someone extends the object path later.
Weigh where cascade policy should live at all. Schema-level actions bind every writer including other services; application-level cascade is visible in review but only binds this codebase. Pick one and make it the standard.
## What the per-object path quietly does for you When a layer deletes one tracked object, it does considerably more than emit a `DELETE`: - **Cascade.** It follows mapped associations marked to cascade and removes or updates the dependent objects first, in an order that satisfies foreign keys. - **Orphan handling.** Children removed from a collection may be deleted, or have their parent reference nulled, depending on the mapping. - **Lifecycle hooks.** Registered callbacks run before and after the write — writing audit rows, stamping timestamps, maintaining a denormalised counter, publishing a domain event. - **Version check.** Where a version column is mapped, the delete carries the loaded version in its `WHERE` clause, so a row changed by someone else since load is detected rather than silently removed. (The mechanics of that check belong to optimistic concurrency; here the point is simply that a bulk statement does not carry it.) - **Bookkeeping.** The object leaves the tracked set and the layer knows the row is gone. ## What a set-based delete does instead It sends one statement: ```sql DELETE FROM orders WHERE status = 'CANCELLED' AND created_at < ? ``` The layer has no object, no mapping walk, and no per-row knowledge. Its role ends at translating and forwarding. Everything in the list above is skipped — not deferred, not batched, skipped. | Behaviour | Per-object delete | Set-based delete | |---|---|---| | Children removed via mapped cascade | yes | no | | Orphaned children detached or deleted | yes | no | | Before/after callbacks | yes | no | | Version column checked | yes | no | | Foreign keys and check constraints | enforced by the engine | enforced by the engine | | Schema-declared referential actions | applied by the engine | applied by the engine | | Layer's tracked set updated | yes | no | The last two rows of the middle block are the ones people get backwards in both directions. Database-level enforcement does **not** disappear — a set-based delete that would orphan a row still fails on the foreign key, and a schema-declared `ON DELETE CASCADE` still fires, because the engine, not the layer, implements it. Conversely, nothing that lives in application code runs. ## Who picks up the work Once you choose the statement path you own the skipped behaviour, and there are only a few honest ways to discharge it: 1. **Push cascade into the schema.** Declare the referential action on the foreign key so the engine deletes or nulls children for you. This is the most robust option because it holds for every writer, including maintenance scripts and other services — but it moves a policy decision into the schema, where reviewers of application code will not see it. 2. **Delete explicitly, in dependency order.** Issue a statement per child table before the parent, driven by the same predicate or a join back to it. Verbose, but visible in the code that performs the bulk change. 3. **Reproduce the hook effects in the bulk path.** If the hook wrote audit rows, write them with a set-based `INSERT ... SELECT` over the same predicate. If it published an event per row, decide whether consumers can accept one summary event instead — and confirm that with the consumers rather than assuming. 4. **Accept the loss, deliberately.** Sometimes a stamped `updated_by` on an archival sweep genuinely does not matter. That is a legitimate answer, provided it was a decision and not a discovery. ## The failure mode to watch The dangerous version of this is not the day the bulk path is written; it is six months later, when someone adds a hook or a cascade to the object path for a new requirement. Their change is correct and complete for every caller they can see, and the bulk path silently does not honour it. Nothing fails, and the divergence is discovered from bad data. Two defences help. Keep the bulk path next to the object path in the same module, so a change to one is visibly a change to the neighbourhood of the other. And write a test that asserts the invariant itself — no orphaned children exist, the audit trail covers every removed row — rather than asserting that a particular callback ran, because only the invariant test fails when the bulk path drifts.
- If cascades do not run, why can a set-based delete still remove child rows?Because the schema, not the layer, may declare a referential action on the foreign key. That action is implemented by the engine and applies to every statement from any source. Mapped cascade in the layer and a schema-declared referential action look the same from the outside and are entirely different mechanisms.
- How would you catch the case where someone later adds a lifecycle hook the bulk path skips?Test the invariant rather than the callback. Assert that after the bulk operation no orphaned children remain and the audit trail covers every affected row. A test that asserts the hook fired passes on the object path and never exercises the bulk one, so it cannot detect the drift.
- A bulk delete fails with a foreign key violation. What does that tell you?That children exist and neither the schema nor your code removed them — the layer's mapped cascade would have, and it did not run. Either declare the referential action in the schema or delete the child rows first with their own statement driven by the same predicate.
saying these in an interview costs you the question
- Thinks the layer's mapped cascade also applies to a bulk statement
- Believes constraints stop being enforced for set-based writes
- Confuses a schema referential action with the layer's cascade setting
- Assumes hooks still fire because the write went through the layer's API
- Tests that a callback ran instead of testing the invariant it protects