Summary#
try / catch / finally is C#'s, and it behaves the way you expect — with one addition that is the reason to reach
for it here: a try block that throws discards the rows it wrote.
So a caught fault does not leave you holding half-applied changes to clean up. The block either happened or it did not:
entity Order {
[Required, Unique, MaxLength(20)] string Code;
decimal Total;
invariant Total >= 0; // broken → a catchable ValidationException
// No `security { }` block means denied to everyone, so every example declares its grants.
security { allow create, read, update when IsAuthenticated || IsAnonymous; }
}
entity AuditLine {
[Required, MaxLength(200)] string Message;
security { allow create, read when IsAuthenticated || IsAnonymous; }
}
string Place(string code, decimal total) {
try {
var order = new Order { Code = code, Total = total };
}
catch (ValidationException e) {
var line = new AuditLine { Message = $"rejected {code}: {e.Message}" };
return "rejected"; // the bad Order is gone; this AuditLine is kept
}
return "placed";
}Signature#
try {
…
}
catch (<ExceptionType> e) when (<filter>) { // the type, the name and the `when` are each optional
…
}
catch { // a bare catch-all
…
}
finally { // always runs — including on return, break and continue
…
}A try must be followed by a catch or a finally (or both). A lone try is a compile error.
Description#
What you can catch#
Three different things raise a fault, and only the first is yours:
| What happened | You catch it as |
|---|---|
| You threw (throw) | the type you threw — or Exception |
| A [[entity-constraints|constraint]] or [[entity-invariants|invariant]] was broken | ValidationException |
| A concurrent write collided with yours | ConflictException |
| A division by zero, a member read on a null | only Exception — these have no specific type |
The second row is the one that changes how you write code. A broken invariant is not a database error you have to
recognise by its text — the platform raises it as an ordinary ValidationException, in the same closed vocabulary as
the ones you throw yourself. Your validation rules live on the entity, where they are enforced for every writer,
and a caller that wants to handle a violation rather than fail on it just catches it.
The rollback is per-block, not per-function#
A function is transactional — a fault it does not catch discards everything it wrote (function). A
try block is the same idea, scoped to the block:
- The rows written inside the
tryare discarded if it throws. - The rows written inside the
catchare kept — the handler is not tarred with the failure it is handling. - Everything the function wrote before the
tryis untouched.
That is what makes the pattern above honest: the rejected Order cannot linger, and the AuditLine recording the
rejection is still there to read. Proved, not asserted:
[Test]
void A_broken_invariant_arrives_as_a_ValidationException() {
Assert.Equal("rejected", Place("A1", -5m)); // the invariant `Total >= 0` was broken
Assert.Empty(Order.ToList()); // the try block's row did NOT survive
Assert.Single(AuditLine.ToList()); // …and the catch block's row DID
}
[Test]
void An_acceptable_row_is_simply_written() {
Assert.Equal("placed", Place("A2", 10m));
Assert.Single(Order.ToList());
Assert.Empty(AuditLine.ToList());
}Catching by type, in order#
Clauses are tried in source order, and the first one whose type matches wins. Exception is the base of all of
them, so a catch (Exception e) matches anything — which makes it the last clause you write, never the first:
string Describe(string code, decimal total) {
try {
var order = new Order { Code = code, Total = total };
return "placed";
}
catch (ValidationException e) { return $"invalid: {e.Message}"; } // the row broke a rule
catch (ConflictException e) { return $"conflict: {e.Message}"; } // someone else got there first
catch (Exception e) { return $"failed: {e.Message}"; } // anything else at all
}The caught value carries a Message — the text of the throw, or the platform's explanation of the rule you broke
— and a Type.
when filters a catch#
A when clause decides whether this handler is the right one, without catching and rethrowing to find out. The
caught value is in scope inside the filter, so you can look at the message before committing to handle it:
string Refund(string code, decimal amount) {
try {
var order = Order.Single(o => o.Code == code);
if (amount <= 0m) { throw new ValidationException("refund must be a positive amount"); }
order.Total = order.Total - amount; // may leave Total negative → breaks the invariant
return "refunded";
}
catch (ValidationException e) when (e.Message.StartsWith("refund")) {
return "declined"; // MY refusal — the one case this function knows how to answer for
}
// the broken invariant is ALSO a ValidationException — but its message is not mine, the filter
// does not match it, and it keeps travelling out to the caller
}Both faults here are ValidationException. The filter is what tells them apart — so filter on a message you
threw. The platform writes its own text for a rule it enforces on your behalf, and that text is its to change;
matching on it would couple your control flow to wording you do not own.
An unmatched fault is not swallowed: it travels up to the caller, and if nobody catches it the function faults and discards its writes.
[Test]
void The_filtered_catch_handles_the_case_it_recognises() {
Place("A9", 100m);
Assert.Equal("declined", Refund("A9", -5m)); // my message → filtered in, handled
Assert.Equal(100m, Order.Single(o => o.Code == "A9").Total); // …and the declined refund changed nothing
}
[Test]
void A_fault_the_filter_does_not_match_keeps_travelling() {
Place("A9", 100m);
// Refunding 500 from 100 leaves Total at -400, which breaks `invariant Total >= 0`. Same exception TYPE,
// but not my message — so the `when` skips it, Refund does NOT return "declined", and the fault leaves.
Assert.Throws<ValidationException>(() => Refund("A9", 500m));
Assert.Equal(100m, Order.Single(o => o.Code == "A9").Total); // the try block's write went with the fault
}finally always runs — but it cannot outlive a fault#
finally runs on the way out however you leave: off the end, on a return, and on a break or continue that
leaves a loop from inside it.
string Attempt(string code) {
try {
if (code == "") { throw new ValidationException("a code is required"); }
return "ok"; // …the finally still runs on this path
}
finally {
var line = new AuditLine { Message = $"attempted '{code}'" };
}
}Now the part that catches people, and it follows from the transaction rather than from finally. If the fault
escapes the function, the function's whole transaction is discarded — and the finally block is part of the
function, so the rows it wrote are discarded too. The block runs; its writes do not survive:
[Test]
void On_the_normal_path_the_finally_write_is_kept() {
Assert.Equal("ok", Attempt("A3"));
Assert.Single(AuditLine.ToList()); // the function returned, so its transaction committed
}
[Test]
void When_the_fault_escapes_even_the_finally_write_is_discarded() {
Assert.Throws<ValidationException>(() => Attempt(""));
Assert.Empty(AuditLine.ToList()); // NOT one row: the fault took the whole function's writes with it
}So finally is not where you record that something failed. A row written there survives only when the function
goes on to succeed — exactly the case where there was nothing to record.
To persist a record of a failure, catch it: a caught fault means the function completes normally, so the catch
block's writes commit (which is what the Place example at the top of this page relies on). If it must be recorded
even when the fault escapes, use Log.Error — a log line is not a row, and a rollback cannot take
it back.
The one thing you cannot catch#
A runaway function — one that recurses without end, or loops without end — is stopped by the platform, and that
stop is not catchable. A while (true) { try { … } catch { } } cannot swallow its own kill signal and keep going.
This is deliberate: the limit exists to protect everything else running on the platform, so it cannot be something an app can opt out of by wrapping it in a handler.
The caveat: UnitOfWork.Commit() is a real write#
Everything above is about rows that have not been persisted yet — the ordinary case, because a server function persists when it returns (function).
If you call UnitOfWork.Commit() explicitly in the middle of a function, that is a real write. A fault afterwards does not
un-write it. The rollback covers what has accumulated since the commit, not what the commit already made durable.
An explicit mid-function UnitOfWork.Commit() is therefore a decision to give up all-or-nothing for what came before it — which
is occasionally what you want, and never what you want by accident.
See also#
- throw — raising one, and the closed set of types
- function — the function is the transaction; the
tryblock is a smaller one inside it - invariant · constraints — the rules whose violation arrives as
ValidationException - Assert —
Assert.Throws<T>, for proving a function refuses what it should