← all articles

A Delete Button Is A Promise

A Delete Button Is A Promise

If your interface shows a delete button, it is telling the user that deletion will work. When the database then refuses and the user gets a generic server error, the bug is not the error. The bug is that the screen made a promise using less knowledge than the data layer holds. The fix is to answer the question "can this be deleted?" in one place, using every relationship that matters.

I came across a small, ordinary version of this in a reference-data screen this week. A record could be removed through a trash-can control, and that control only appeared when the record was considered safe to remove. Someone clicked it, the request failed with a plain server error, and nothing on screen explained why. The database had done exactly the right thing. The record was still referenced by thousands of rows elsewhere, and a foreign-key constraint refused to let it disappear underneath them. The eligibility check behind the button had looked at one kind of dependent record and not the other. So the screen said yes, the data said no, and the user was left holding the argument between them.

It would be easy to file that as a missing validation and move on. It is, technically. But it is worth sitting with what actually went wrong, because the same shape turns up everywhere once you look for it.

Two sources of truth, one of them quiet

The database knows what depends on what. It knows because someone declared those relationships, and it enforces them whether or not anyone is watching. The application, meanwhile, keeps its own idea of eligibility, usually a query written the day the delete feature was built, covering the relationships that existed and mattered on that day.

Then the schema grows. A new table points at the old one. Nobody revisits the eligibility query, because nothing breaks in testing: the new relationship is empty in most test data, so the button keeps working. It only fails against real data, where real usage has quietly attached itself to the record. That is the uncomfortable part. The constraint is the only component that stayed accurate, and it is the one that gets reported as the fault.

I have come to think of an application-side eligibility check as a courtesy copy of a rule the database already owns. It exists to be kind to the user, not to be the authority. Treating it as the authority is how you end up with a button that lies.

What the user actually experiences

A server error on a delete is a strange thing to receive, because the user did nothing unusual. They saw an option, they took it, and it blew up. Reasonable people conclude the system is flaky, or that they did something wrong, or that someone has broken production. Support gets a message that says "delete doesn't work", which is almost useless as a starting point, because the honest answer is that it works fine, except for this record, for a reason the screen could have explained.

Compare that with a button that is disabled and says why. The record is in use, here is roughly where, detach it first if you really mean to. That is a thirty-second interaction with no ticket, no escalation and no doubt about the health of the system. The difference in cost is not in the code. It is in everyone who would otherwise have to investigate a failure that was never a failure.

There is also a quieter cost. Each unexplained error teaches people to distrust the interface a little more. They start asking someone else to do the delete, or they avoid cleanup altogether, and reference data accumulates clutter because trying to tidy it feels risky. A system people are afraid to use properly is a system with a hidden maintenance bill.

Where the check should live

My preference is to ask the data layer. If the question is whether a record is referenced anywhere, the most reliable answer comes from the relationships the database already declares, rather than from a hand-maintained list of tables in application code. Sometimes that means a purpose-built query or view that is reviewed whenever a foreign key is added. Sometimes it means attempting the operation inside a transaction and translating the constraint failure into a clear message instead of letting it escape as a generic error. The second option is less elegant, but it has one real advantage: it cannot go out of date, because it asks the thing that is actually enforcing the rule.

The best approach is usually both. Use the pre-check to shape the interface, so the control is disabled with an explanation. Keep the constraint as the backstop, and handle its failure gracefully for the case where the data changed between the screen loading and the click. Anyone who has watched two people work on the same records will know that case is not hypothetical.

Whatever the mechanism, the review habit matters more than the design. Adding a table that references an existing one should prompt a question: which screens currently decide whether the referenced thing can be changed or removed, and do they know about this? That question is cheap at the moment of adding the relationship and surprisingly expensive when it is first asked by a customer.

The commercial side of a small fix

None of this is glamorous, and it is easy to see why it sits in a backlog marked non-critical. A fix like this rarely moves a roadmap. But the trade-off is worth stating plainly. The change itself is small. What it removes is a recurring category of confusion, a class of support questions, and a small but real erosion of trust. Small interface honesty tends to be one of the better returns on engineering time, precisely because it is paid for once and repaid every time someone would otherwise have hit the wall.

There is a limit, though. I would not rebuild a whole dependency model because one button misbehaved. The proportionate response is to close this gap, ask whether the same pattern exists next to it, and leave a note for whoever adds the next relationship. Perfection here would cost more than the problem is worth.

A rule of thumb

When a screen offers an action, it should be offered because the system has checked that the action can succeed, using the same knowledge the system will use to enforce it. If the interface cannot know, it should attempt the action safely and explain the refusal. What it should never do is offer a choice with confidence and let the database deliver the bad news. A button is a small promise, and the people clicking it are entitled to expect it to be kept.

Matthew Ratcliffe, software developer and architect, Ballarat
Senior Software Engineer & Architect

20+ years across the technology stack — from greenfield builds to brownfield rescues. Based in Ballarat, VIC, focused on AI, healthcare and high-risk data systems. Full resume →

Share your thoughts