Blog

Errors as Design: Exceptions, Error Values and Result

How C# and Java exceptions, Go’s error values and Rust’s Result carry one failure through a service, what each compiler checks, what failure costs, measured, and where to handle errors, log them and turn them into responses.

Every function that talks to a disk, a network or a user can fail. How a language makes you deal with that shapes every layer of a service: which signatures change when a new failure appears, what a caller is forced to look at, what gets logged, and what the customer sees.

This part compares the four models in our languages: unchecked exceptions in C#, checked and unchecked exceptions in Java, error values in Go, and Result in Rust. We send the same failure through the same three layers in each, record what every compiler and runtime actually does in the cases people argue about, measure what a failure costs, and then turn to the design questions that matter more than the mechanism: which errors to handle, which to let crash, and where the boundary is.

Try this first

An order service saves each order to a database. Someone wrapped the call like this, “so a database hiccup can’t crash the service”:

try {
    repository.save(order);
} catch (Exception e) {
    log.warn("could not save order", e);
}
return Response.created(order.id());

The database is down for five minutes during a sale. What does each customer see? What does the business find out, and when? Write your answers down before reading on.

Kinds of failure, before kinds of mechanism

The customers in that example all saw “order placed”. The orders were never saved, and the only trace is a warning in a log. The handler didn’t crash, which was the goal, and it did something worse: it carried on as if nothing had failed.

That pattern isn’t rare. Ding Yuan and colleagues studied 198 randomly sampled, user-reported failures in Cassandra, HBase, HDFS, Hadoop MapReduce and Redis (OSDI 2014). Of the 48 failures with catastrophic consequences, “almost all (92%)” were “the result of incorrect handling of non-fatal errors explicitly signaled in software”. In 25%, the error was ignored, and “an error handler that only logs the error is also considered as ignoring the error.” The errors had been noticed: “In all but one case, the errors were checked by the developers.” The paper suggests one reason is that “the Java compiler forces developers to catch all the checked exceptions”, and the developers were “often simply sloppy in handling these errors”. Forcing a catch didn’t make the handling right.

So the first design question isn’t “exceptions or return values”. It’s what kind of failure you’re looking at. Several people have drawn the same line in different words:

Kind Examples What to do Who said it
Expected failure in the world file missing, network timeout, card declined, invalid input handle it: retry, fall back, or report it to the caller in terms they understand Lippert: “exogenous”; Rust Book: “recoverable”
Bug index out of range, null where the code assumed a value, broken invariant don’t catch it; stop, and let something outside restart cleanly Duffy: “A bug is a kind of error the programmer didn’t expect”; Lippert: “boneheaded”
Fatal out of memory, stack overflow nothing useful to do in-process Lippert: “fatal”
Awkward API int.Parse throwing on ordinary bad input use a non-throwing variant (TryParse) Lippert: “vexing”

Eric Lippert, then on the C# compiler team, gave the four buckets in “Vexing exceptions” (2008). His advice on bugs: “You should not catch them; doing so is hiding a bug in your code.” Joe Duffy, describing the error model of Microsoft Research’s Midori operating system (2016), put it more strongly: “Proceeding in the face of a bug is dangerous when you’re trying to build a robust system.” Midori tore the process down on any bug, a policy it called abandonment, and in its code, “Abandonment-based errors outnumbered recoverable errors by a ratio approaching 10:1.”

Joe Armstrong’s slogan for Erlang, “Let it crash”, is the same idea, and it’s often quoted without its other half. His thesis (2003) lists “Let some other process do the error recovery” first, and gives opening a missing file as a case where the programmer “might decide that this is not an error” and handle it. Crashing works when a supervisor restarts a small, isolated piece. It doesn’t mean “handle nothing”.

Explain it like I’m ten

A baker runs out of flour halfway through the morning. That’s annoying, but it happens: the baker phones the supplier, and tells customers the bread will be an hour late.

Now suppose the recipe card has a typo and says “bake for 400 minutes”. If the baker follows the card, every loaf burns. The right move isn’t to keep baking and hope. It’s to stop, fix the card, and start that batch again.

And the worst baker of all writes “something went wrong” on a note, drops it in a drawer, and hands the customer an empty bag.

The precise version

  • Running out of flour is an expected failure: handle it by retrying, falling back, or telling the caller.
  • The wrong recipe is a bug: carrying on produces wrong results, so stop that unit of work and fix the code.
  • The note in the drawer is a log-only handler, which Yuan’s study counts as ignoring the error.
  • Where the analogy breaks: in software, the same failure can be either kind, depending on the code. A missing file is expected for a program looking for an optional settings file, and a bug if the installer was supposed to create it. That’s Armstrong’s point about the missing file: the programmer decides.

The four languages then give you different tools for the first two rows.

One failure through three layers

In each language, a small program has a handler that calls an order service, which calls a repository, and the database times out. The service holds a lock it must release whether or not the save works. The handler turns the failure into an HTTP status. (A timeout has one more twist: the insert may have committed before the answer was lost, so a client that retries needs an idempotency key, as in Part 4.)

the database times out: how the failure reaches the handler handler service repository

The lines are the output of the four programs below, which the series’ code verifiers run. The service releases its lock in every language: finally, defer or Drop.

C#: exceptions, all unchecked

try
{
    new OrderService().Place(42);
    Console.WriteLine("handler: 201 Created");
}
catch (TimeoutException e)
{
    Console.WriteLine("handler: 503 Service Unavailable");
    for (Exception? ex = e; ex is not null; ex = ex.InnerException)
    {
        Console.WriteLine($"  {ex.GetType().Name}: {ex.Message}");
    }
}

class OrderRepository
{
    public void Save(int orderId)
    {
        Console.WriteLine($"repository: insert order {orderId} timed out");
        throw new TimeoutException("database did not answer within 2 s");
    }
}

class OrderService
{
    private readonly OrderRepository _repository = new();

    public void Place(int orderId)
    {
        try
        {
            _repository.Save(orderId);
        }
        finally
        {
            Console.WriteLine("service: finally, release the order lock");
        }
    }
}

It prints:

repository: insert order 42 timed out
service: finally, release the order lock
handler: 503 Service Unavailable
  TimeoutException: database did not answer within 2 s

The service has no error-handling code at all, only cleanup. The exception unwinds through it, runs its finally, and reaches the first matching catch. Nothing in Place‘s signature says it can fail. That’s the trade: no noise in the middle layers, and no compiler help in finding which failures a caller should expect.

Three details that decide whether C# error handling works in practice, each checked in our lab on .NET 10:

  • Rethrow with throw;, not throw e;. Microsoft’s reference: “throw; preserves the original stack trace of the exception […] In contrast, throw e; updates the StackTrace property of e.” In our lab, the trace after throw; still named the method that threw; after throw e; it didn’t, and the compiler’s analyzer warned CA2200: Re-throwing caught exception changes stack information.
  • Unobserved task exceptions vanish. A Task that faults and is never awaited raises TaskScheduler.UnobservedTaskException when it’s garbage collected, and since .NET Framework 4.5, “by default, the process does not terminate. Instead, the exception is ignored after the event is raised”. Our lab’s “background write failed” raised the event, and the process kept running, but only because the lab forced garbage collections in a loop. In a real service the event fires whenever the task happens to be collected, or never, so it’s no substitute for awaiting. If nobody observes the task, that failure is gone.
  • async void crashes the process. An exception from an async void method “can’t be caught outside of that method”. Ours ended the process with Unhandled exception. System.InvalidOperationException: async void failed. Use async Task everywhere except event handlers. (In a UI app, the exception goes to the UI framework’s synchronization context first, which may have its own handler; in a console app or server, as here, the process ends.)

.NET’s design guidelines say “❌ DO NOT return error codes” and “✔️ DO report execution failures by throwing exceptions”, where failure means a member “cannot do what it was designed to do”. For failures that are routine, they add the Try-Parse pattern: “DO use the prefix “Try” and Boolean return type for methods implementing this pattern”, as int.TryParse does.

Java: checked and unchecked exceptions

import java.io.Serial;
import java.util.concurrent.TimeoutException;

class OrderUnavailableException extends Exception {
    @Serial
    private static final long serialVersionUID = 1L;

    OrderUnavailableException(String message, Throwable cause) {
        super(message, cause);
    }
}

class OrderRepository {
    void save(int orderId) throws TimeoutException {
        IO.println("repository: insert order " + orderId + " timed out");
        throw new TimeoutException("database did not answer within 2 s");
    }
}

class OrderService {
    private final OrderRepository repository = new OrderRepository();

    void place(int orderId) throws OrderUnavailableException {
        try {
            repository.save(orderId);
        } catch (TimeoutException e) {
            IO.println("service: wrap with context");
            throw new OrderUnavailableException("could not place order " + orderId, e);
        } finally {
            IO.println("service: finally, release the order lock");
        }
    }
}

void main() {
    try {
        new OrderService().place(42);
        IO.println("handler: 201 Created");
    } catch (OrderUnavailableException e) {
        IO.println("handler: 503 Service Unavailable");
        for (Throwable t = e; t != null; t = t.getCause()) {
            IO.println("  " + t.getClass().getSimpleName() + ": " + t.getMessage());
        }
    }
}

It prints:

repository: insert order 42 timed out
service: wrap with context
service: finally, release the order lock
handler: 503 Service Unavailable
  OrderUnavailableException: could not place order 42
  TimeoutException: database did not answer within 2 s

TimeoutException is a checked exception, so the compiler makes every method in the path either catch it or declare it with throws. The Java Language Specification defines the split by class: “The unchecked exception classes are the run-time exception classes and the error classes”, meaning RuntimeException, Error and their subclasses; everything else under Throwable is checked. The purpose, in the spec’s words: “This compile-time checking for the presence of exception handlers is designed to reduce the number of exceptions which are not properly handled.”

The service here doesn’t pass TimeoutException on. It wraps it in an exception that belongs to its own layer, keeping the original as the cause. The Throwable documentation explains why: letting the lower layer’s exception out “would tie the API of the upper layer to the details of its implementation”. If OrderRepository later moves from a database to an HTTP service, OrderService‘s contract doesn’t change.

What the compiler says when you forget, from our lab on JDK 25:

  • Remove throws IOException from a method that calls one that throws it: error: unreported exception IOException; must be caught or declared to be thrown.
  • Call that method through a method reference in a stream, map(Checked::read): error: incompatible thrown types IOException in functional expression. The java.util.function interfaces declare no checked exceptions, so streams and lambdas force you to wrap them. This is the most common daily friction with checked exceptions.

Why C# left them out. Anders Hejlsberg, C#’s lead designer, in a 2003 interview with Bill Venners and Bruce Eckel: “I see two big issues with checked exceptions: scalability and versionability.” Adding an exception to a throws clause “is a breaking change”, and exceptions accumulate as subsystems combine: “It just balloons out of control.” What developers then do: “They either say, “throws Exception,” everywhere; or […] they say, “try, da da da da da, catch curly curly.”” He didn’t call the idea bad, though: “there’s nothing wrong with the idea”, he said, “It’s just that particular implementations can be problematic.” And Java’s own specification says “it is typical to define most new exception classes as checked exception classes”. The Java Tutorial’s rule of thumb: “If a client can reasonably be expected to recover from an exception, make it a checked exception. If a client cannot do anything to recover from the exception, make it an unchecked exception.”

Cleanup that fails. try-with-resources closes resources in reverse order, and if closing throws after the body already threw, the close exception “is suppressed”: attached to the first exception, not thrown instead of it.

class Connection implements AutoCloseable {
    void query() {
        throw new IllegalStateException("query failed: connection reset");
    }

    @Override
    public void close() {
        throw new IllegalStateException("close failed: connection already broken");
    }
}

void main() {
    try (Connection connection = new Connection()) {
        connection.query();
    } catch (IllegalStateException e) {
        IO.println("caught: " + e.getMessage());
        for (Throwable suppressed : e.getSuppressed()) {
            IO.println("suppressed: " + suppressed.getMessage());
        }
    }
}

It prints:

caught: query failed: connection reset
suppressed: close failed: connection already broken

A hand-written finally that throws does the opposite: its exception replaces the original, and the real cause is lost.

One Java exception needs special care. When a blocking call throws InterruptedException, the thread’s interrupt status has been cleared. Brian Goetz: “The worst thing you can do with InterruptedException is swallow it”. If you can’t rethrow it, call Thread.currentThread().interrupt() before returning, so code higher up can still see that the thread was asked to stop.

Go: errors are values

package main

import (
	"errors"
	"fmt"
)

var ErrTimeout = errors.New("database did not answer within 2 s")

type OrderRepository struct{}

func (OrderRepository) Save(orderID int) error {
	fmt.Printf("repository: insert order %d timed out\n", orderID)
	return fmt.Errorf("insert order %d: %w", orderID, ErrTimeout)
}

type OrderService struct{ repository OrderRepository }

func (s OrderService) Place(orderID int) error {
	defer fmt.Println("service: deferred, release the order lock")
	if err := s.repository.Save(orderID); err != nil {
		fmt.Println("service: wrap with context")
		return fmt.Errorf("place order %d: %w", orderID, err)
	}
	return nil
}

func main() {
	err := OrderService{}.Place(42)
	switch {
	case err == nil:
		fmt.Println("handler: 201 Created")
	case errors.Is(err, ErrTimeout):
		fmt.Println("handler: 503 Service Unavailable")
		fmt.Println("  " + err.Error())
	default:
		fmt.Println("handler: 500 Internal Server Error")
	}
}

It prints:

repository: insert order 42 timed out
service: wrap with context
service: deferred, release the order lock
handler: 503 Service Unavailable
  place order 42: insert order 42: database did not answer within 2 s

In Go, a failure is an ordinary return value of the built-in error interface type. Nothing unwinds: every layer receives the error, decides, and returns. The Go FAQ explains the choice: “coupling exceptions to a control structure, as in the try-catch-finally idiom, results in convoluted code. It also tends to encourage programmers to label too many ordinary errors, such as failing to open a file, as exceptional.” Rob Pike’s 2015 post gave the idea its slogan: “Errors are values. Values can be programmed, and since errors are values, errors can be programmed.”

Since Go 1.13, fmt.Errorf with %w wraps an error, so errors.Is and errors.As can find it inside, “consider[ing] all the errors in a chain”. Each layer adds context to the message, and the handler still finds the sentinel ErrTimeout. Our lab shows the difference %w makes:

package main

import (
	"errors"
	"fmt"
)

var ErrTimeout = errors.New("database timeout")

type QueryError struct{ Table string }

func (e *QueryError) Error() string { return "query on " + e.Table + " failed" }

func main() {
	withW := fmt.Errorf("place order: %w", ErrTimeout)
	withV := fmt.Errorf("place order: %v", ErrTimeout)
	fmt.Println("same text:", withW.Error() == withV.Error())
	fmt.Println("wrapped with w, matches:", errors.Is(withW, ErrTimeout))
	fmt.Println("formatted with v, matches:", errors.Is(withV, ErrTimeout))

	joined := errors.Join(fmt.Errorf("insert: %w", &QueryError{Table: "orders"}), ErrTimeout)
	qe, ok := errors.AsType[*QueryError](joined)
	fmt.Println("found QueryError:", ok, qe.Table, "and timeout:", errors.Is(joined, ErrTimeout))
}

It prints:

same text: true
wrapped with w, matches: true
formatted with v, matches: false
found QueryError: true orders and timeout: true

The text is identical; only what code can inspect changes. Go 1.20 added errors.Join and multiple %w, so errors form a tree, and Go 1.26 added the generic errors.AsType, which the package documentation now prefers over errors.As.

Wrapping has a design cost that the Go team spells out: “wrapping an error makes that error part of your API. If you don’t want to commit to supporting that error as part of your API in the future, you shouldn’t wrap the error.” If your service’s callers can match your database driver’s errors, you can’t change drivers without breaking them. Wrap errors you promise; for implementation details, format with %v, or return your own error.

What Go doesn’t check, from our lab with Go 1.26:

  • Ignoring an error compiles silently. os.Remove("does-not-exist.txt") as a bare statement, and f, _ := os.Open(...), both built, and go vet reported nothing. The compiler doesn’t catch them. The errcheck linter reports unchecked calls like the first, and assignments to _ like the second only with its -blank flag. The Code Review Comments page: “Do not discard errors using _ variables.”
  • A panic in another goroutine ends the program. Go does have panic and recover, which unwind like exceptions, but recover only works in a deferred function on the same goroutine. Ours had recover in main, a goroutine panicked with “worker failed”, and the process exited with status 2. A recover in main can’t save you. Go’s net/http server recovers for its handlers: if a handler panics, the server “recovers the panic, logs a stack trace to the server error log” and closes that connection. A goroutine the handler starts itself isn’t covered. Recovering doesn’t make a bug safe, though: shared state may be half-updated. Recover at a boundary, such as a request or a worker, to report the failure and end that unit of work, not to carry on.

Effective Go limits panic to “truly exceptional” cases: “real library functions should avoid panic”. And in June 2025, after three proposals of its own and “literally hundreds” from the community, the Go team announced that “For the foreseeable future, the Go team will stop pursuing syntactic language changes for error handling.” The if err != nil style is staying.

Rust: Result and ?

use std::error::Error;
use std::fmt;

#[derive(Debug)]
enum RepositoryError {
    Timeout,
}

impl fmt::Display for RepositoryError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            RepositoryError::Timeout => write!(f, "database did not answer within 2 s"),
        }
    }
}

impl Error for RepositoryError {}

#[derive(Debug)]
struct PlaceOrderError {
    order_id: u32,
    source: RepositoryError,
}

impl fmt::Display for PlaceOrderError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "could not place order {}", self.order_id)
    }
}

impl Error for PlaceOrderError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        Some(&self.source)
    }
}

fn save(order_id: u32) -> Result<(), RepositoryError> {
    println!("repository: insert order {order_id} timed out");
    Err(RepositoryError::Timeout)
}

struct OrderLock;

impl Drop for OrderLock {
    fn drop(&mut self) {
        println!("service: drop, release the order lock");
    }
}

fn place(order_id: u32) -> Result<(), PlaceOrderError> {
    let _lock = OrderLock;
    save(order_id).map_err(|source| {
        println!("service: wrap with context");
        PlaceOrderError { order_id, source }
    })?;
    Ok(())
}

fn main() {
    match place(42) {
        Ok(()) => println!("handler: 201 Created"),
        Err(e) => {
            let status = match e.source {
                RepositoryError::Timeout => "503 Service Unavailable",
            };
            println!("handler: {status}");
            let mut current: Option<&dyn Error> = Some(&e);
            while let Some(err) = current {
                println!("  {err}");
                current = err.source();
            }
        }
    }
}

It prints:

repository: insert order 42 timed out
service: wrap with context
service: drop, release the order lock
handler: 503 Service Unavailable
  could not place order 42
  database did not answer within 2 s

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.