Osy#betaRun it on Osyrin: osyrin.com

Reference / Function

try / catch / finally

try { … } catch (<ExceptionType> e) when (<filter>) { … } finally { … }

Handles a fault. C#'s syntax, including typed catches, catch filters and finally. The rows written inside a try block that throws are discarded — so a caught fault leaves your data where it was, not half-changed.

stable9 examples compiled by CIfunctionerrors

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 happenedYou catch it as
You threw (throw)the type you threw, any type it derives from, or Exception
A [[entity-constraints|constraint]] or [[entity-invariants|invariant]] was brokenValidationException
A concurrent write collided with yoursConflictException
An operation C# itself throws onthe .NET type C# throws — DivideByZeroException for an integer divided by zero, OverflowException past a type's range, InvalidOperationException for First()/Single() over nothing and Single() matching more than one row ([[query-single-row#faults]]), ArgumentException for a duplicate dictionary key, KeyNotFoundException, FormatException, InvalidCastException, JsonException for a text JsonSerializer.Deserialize cannot read (JsonSerializer)
A member read off null in a function body — row.Name, name.Trim(), list.Count with nothing thereNullReferenceException, on the server and in the browser; the message names the member and what was null ([[types-null-forgiving#null-read]])

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, the same exception type you could 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 try are discarded if it throws.
  • The rows written inside the catch are kept — the handler is not tarred with the failure it is handling.
  • Everything the function wrote before the try is untouched.

Every rule the platform can judge itself — [Required], [MaxLength], a property's [Unique], a zone or culture value, an invariant — is judged by the time the rows leave the try at the latest, so the nearest catch takes it and the rows go with it. A fault only the database can raise, when the rows are finally written — a composite [Unique(A, B)], two writers racing for one value — reaches the catch too, but the refused row is still waiting to be written: the catch repairs it, rethrows, or calls UnitOfWork.Discard(), and if it does none of these the function faults when it ends.

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());
}

A row the function read before the try and changed inside it is put back too — the change was written inside the block, wherever the row came from:

decimal TopUpThenThinkBetterOfIt(string code) {
  var order = Order.Single(o => o.Code == code);    // read BEFORE the try
  try {
    order.Total = order.Total + 1000m;               // changed INSIDE it
    throw new ValidationException("not today");
  }
  catch (ValidationException) { }
  return order.Total;                                // the change went with the fault
}
[Test]
void A_row_read_before_the_try_is_put_back_with_it() {
  Place("B1", 10m);
  UnitOfWork.Commit();
  Assert.Equal(10m, TopUpThenThinkBetterOfIt("B1"));
  UnitOfWork.Commit();
  Assert.Equal(10m, Order.Single(o => o.Code == "B1").Total);
}

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.

…but a cancelled run still runs your finally#

A run can be stopped from outside: a request times out, or a consumer of a stream<T> walks away — a browser closing a live list, a reader that has seen enough. That stop is not catchable either, for the same reason as above.

Your finally still runs. The two are different promises, and only one of them is about handling the failure:

stream<Reading> Live() {
  var handle = Device.Open();
  try {
    foreach (var r in handle.Read()) { yield return r; }
  }
  finally {
    handle.Close();        // runs even if the consumer stops asking after the first reading
  }
}

Two details worth knowing, because they are the difference between cleanup that works and cleanup that looks like it does:

The cleanup body does not run on the cancelled clock. If it did, the first statement in your finally would fail and the block would half-execute — a handle closed on some paths and not others. It gets a fresh, bounded window instead, so ordinary cleanup completes. The window is a few seconds: a finally cannot hold a stopped run open indefinitely, because something decided that run must stop.

A catch is still not entered. Cancellation reaches finally blocks and nothing else, so catch (Exception e) in the same try does not see it — which is what keeps while (true) { try { … } catch { } } from swallowing its own kill.

And the same rule as above applies to what the cleanup writes: a stopped run keeps nothing, so rows written in that finally are discarded with the rest. Releasing and notifying is what a cleanup body is for.

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 try block 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
  • Crossing the network — why a stream<T> producer is server-pinned, and what abandoning one means

Related

throw

Raises a fault. It ends the function immediately, and the rows the function wrote are discarded rather than…

function

A function is a top-level unit of work, written like a C# method — a return type, a name, typed parameters, a body. It…

constraints

The per-member rules the database enforces — Required, Unique, MaxLength/MinLength, Min/Max, Pattern, and the…

invariant

A row-level rule spanning several members, checked when the row is written. Use it when a constraint on one member is…

Assert

The assertions a test makes. Beyond the usual equality and null checks there are comparisons (Greater, Less, InRange)…