Tutorials / Build a REST API with C# and .NET · Lesson 20 of 27
On this page
Exceptions and Stack Traces
Sooner or later, every program meets something nobody planned for: a list that is empty, a file that is missing, a disk that is full. When that happens, .NET stops what it is doing and prints a long block of text. Many beginners see that block as noise and scroll past it. It is the most useful message the program will ever give you, and this lesson teaches you to read it.
Our goal: say what an exception is, read a stack trace, and know where a try/catch belongs. This is a reading lesson: there is nothing to build or type. We follow a small program on paper. We ran it for you, so every output shown is real. Its classes are laid out like our API: a controller calls a service, and the service calls a calculator.
Here is the route:
- Start with a picture: a problem in an office, and who handles it.
- Look at a small program that works, and then crashes.
- Meet the exception that caused the crash, and see how it travels.
- Read the stack trace, line by line.
- Try a first fix,
return 0, and see why it only hides the bug. - Raise an exception of our own, with a clearer message.
- Catch an exception, and decide where that belongs.
This lesson leaves out custom exception classes, the finally keyword, and what a web API does with an exception nobody caught (errors the professional way).
A problem in an office
Before any code, a picture from everyday life. Imagine an office. A junior staff member is doing his job when he finds a problem that he is not authorised to fix. He does not guess, and he does not hide it. He writes a note of what he found and passes it to his supervisor.
The supervisor asks one question: is this my kind of problem, and can I fix it? If yes, she handles it, and the work carries on. If not, she passes the note up to the manager, who asks the same question. A small problem usually stops there. For example, a stock count that does not tally is a job for the manager, who can adjust the books. A really hard problem keeps climbing, up to the CEO, who has the authority to decide anything. If the staff member witnesses a crime in the shop, that goes all the way up, and the CEO decides what to do, such as calling the police. (If even the CEO cannot decide, the whole company stops for the day.)
Three things are worth noticing:
- The staff member knows about the problem, but he is the worst person to decide what to do about it. He does not know if the customer should be told, or the job retried, or dropped.
- Each person handles only the problems of their own kind and passes the rest up.
- Nobody hides the problem. The note travels, and everyone who passed it on is written on it.
A program is built the same way, as layers where each one calls the next. This lesson is about how a program says "not mine, passing it up", and what the note looks like.
A program with a bug
We need a program with a few layers, because a stack trace is a story about layers. We use the same three jobs as the API: a controller takes the request, a service does the work, and a calculator does the sums. (These are plain classes standing in for the real ones. There is no web server here, and no ASP.NET.)
Remember the office. The three classes are its people. PriceCalculator is the junior staff member who does the sums. OrderService is his supervisor. OrderController is the manager, who deals with the customer. And the code at the very top, Program.cs, is the CEO's level: it is where the orders come in, and where a problem that climbs this far finally has to be dealt with.
The program summarises an order: it works out the average price of the books in it. The first class is PriceCalculator.cs, our junior staff member. It is the same idea as the PriceCalculator of what is state and unit testing basics, with a new method:
public class PriceCalculator
{
public decimal AveragePrice(List<decimal> prices)
{
var total = prices.Sum();
return total / prices.Count;
}
}
pricesis a list of prices, a collection that grows as you add to it.AveragePriceadds up all the prices (Sum()) and divides by how many there are (Count). An average is the total divided by the count.
The second is OrderService.cs, the supervisor:
public class OrderService
{
private readonly PriceCalculator _calculator = new PriceCalculator();
public decimal AveragePrice(List<decimal> prices)
{
// business rules would go here: discounts, tax, and so on
return _calculator.AveragePrice(prices);
}
}
The service holds a calculator and asks it. The comment marks where real business rules would go, so the service has something to stand for. The third is OrderController.cs, the manager:
public class OrderController
{
private readonly OrderService _service = new OrderService();
public string Summary(List<decimal> prices)
{
var average = _service.AveragePrice(prices);
return $"Average price: {average}";
}
}
The controller asks the service and builds a sentence. (The $"..." form puts the value of average into the text where the braces are.)
Now a user sends an order to this program, and the program answers with the average price of its books. (A few lines in Program.cs play the user and print Done. at the end. We do not need to read them.) We try two different orders, one at a time, and look at what the console shows each time.
Case 1: a normal order
The user sends an order with three books. The payload, the data the user sends, is the list of their prices (JSON):
[39.90, 34.50, 45.60]
The console shows:
Average price: 40.00
Done.
It works. The three prices add up to 120.00, and 120.00 divided by 3 books is 40.00. Then the program prints Done. and ends normally.
Case 2: an unusual order
Now the user sends an order with no books at all. The payload is an empty list:
[]
The developer who wrote AveragePrice never thought of this case, so the code has a bug, and this order is the one that shows it. The console shows:
Unhandled exception. System.DivideByZeroException: Attempted to divide by zero.
at System.Decimal.DecCalc.VarDecDiv(DecCalc& d1, DecCalc& d2)
at System.Decimal.op_Division(Decimal d1, Decimal d2)
at PriceCalculator.AveragePrice(List`1 prices) in .../PriceCalculator.cs:line 6
at OrderService.AveragePrice(List`1 prices) in .../OrderService.cs:line 8
at OrderController.Summary(List`1 prices) in .../OrderController.cs:line 7
at Program.<Main>$(String[] args) in .../Program.cs:line 4
(The start of each file path is shortened to ....) Look at two things:
- No average, and no
Done.The program did not answer, and it did not carry on after the problem. It stopped. - The block of text under
Unhandled exceptionis the stack trace. We read it in a moment.
What an exception is
The unusual order has no books, so its count is zero, and a computer cannot divide by zero. The / could not do its job. So it did not return an answer. It raised an exception.
An exception is a method's way of saying "I cannot continue." Instead of returning a value, the method stops on the spot and hands a problem to whoever called it.
The same story in code
Each part of the office story has a name in code. An exception travels the same way as the note, from the method where it happened up through its callers:
| In the office | In the code |
|---|---|
| The staff member finds a problem he may not fix | A method hits something it cannot handle and throws an exception |
| He passes it up, with a note of what he found | The exception goes to the caller, with a message |
| The supervisor asks "is this my kind of problem?" If not, it goes up | catch (SomeType) catches only exceptions of that type. Any other passes through |
| Everyone who passed it on is written on the note | The stack trace: the trail of who it went through |
| Someone with the authority decides: tell the customer, retry, use another way | The catch where you can do something useful |
| Even the CEO cannot fix it, so the company stops | No catch anywhere: the program ends |
In code, the same story has three rules:
- The method that raised the exception stops right there. No line after it runs.
- The problem goes to the caller. The caller stops too, unless it has a
catchfor this kind of problem (coming below). - This repeats, up the chain of callers, until someone catches it. If nobody does, the program ends, which is what we just saw.
The stack trace
The text under Unhandled exception records the chain of calls the exception climbed through. To see why it is called a stack, picture how a program runs. Program calls Summary, and waits for it. Summary calls AveragePrice on the service, and waits for it. That calls AveragePrice on the calculator, and waits for it. Each call sits on top of the one that started it, like plates in a pile. The pile is called the call stack, and the printed list of the pile at the moment of the problem is the stack trace. In the office story it is the note, with the name of everyone it passed through. The newest call is at the top of the list, and the oldest is at the bottom.
You do not have to read it all. Read it in this order:
- The first line says what happened. It has the name of the exception,
DivideByZeroException, and a message for a person: "Attempted to divide by zero." Read this first. The name tells you the kind of problem, and the message tells you the detail. - Skip the lines that start with
System.They are .NET's own code, deep inside the thing that divides. They are almost never where the mistake is. - Find the first line that names our own file. Here it is
PriceCalculator.AveragePriceinPriceCalculator.cs, line 6. Open that file at that line. It is where .NET noticed the problem, and a good place to start looking:return total / prices.Count;. - Read down to see who called it. The service called the calculator, the controller called the service, and
Program.csline 4 called the controller. This is the story of how the program got into trouble: a user sent an order, the program asked for an average, and the order had no books.
A line number tells you where the program noticed the problem. The cause can be a little earlier, here a missing check for "is the order empty?". The trace points you at the right place, and thinking does the rest.
💡 Visual Studio shows the same list in its Call Stack window while it is stopped on an error, and the terminal shows it when you run a program with
dotnet run. (This is from its documentation. We did not run Visual Studio for this lesson.) The shape is the same in every .NET program, from a console app to a web API.
First fix: return 0
The bug is in PriceCalculator, so that is where we fix it. The quickest fix a developer thinks of is: if there are no books, return 0. Here is PriceCalculator.cs with that check added (// new marks the new lines):
public class PriceCalculator
{
public decimal AveragePrice(List<decimal> prices)
{
if (prices.Count == 0) // new
{ // new
return 0; // new
} // new
var total = prices.Sum();
return total / prices.Count;
}
}
Sending the unusual order again (the payload []) gives:
Average price: 0
Done.
The crash is gone, and the program carries on. Sending the normal order still gives Average price: 40.00. For a quick patch, that looks like a win.
Is it? The program now tells the user that the average price of their order is 0. That is not true. An average of nothing does not exist. The bug has not been fixed, only hidden. A customer reads "Average price: 0" and wonders if the books are free. Worse, other code may use that 0 later, in a report, a total or a discount, and the mistake will show up somewhere else, far from here, and much harder to find.
There is one case where return 0 is honest: when 0 really is the answer. The total of an empty order is 0, because adding up nothing gives nothing. An average of nothing is not 0. It has no answer. When a method has no honest answer, a second fix is better: stop, and say so.
Raise your own
The return 0 fix made the problem disappear. A second way is to say it loudly. The original message, Attempted to divide by zero, was correct, but it did not say what went wrong in the shop: the order is empty. A method can check the problem itself, and raise an exception with its own message. Raising one is called throwing, and you met it once in unit testing basics, where ApplyDiscount threw an error for a discount below zero. The staff member of our office is the calculator, so that is where the check belongs. It is also the proper fix for the bug. Here is PriceCalculator.cs with return 0 replaced by a throw. (The commented-out line only shows what was replaced. In a real project you would delete it, because git remembers.)
public class PriceCalculator
{
public decimal AveragePrice(List<decimal> prices)
{
if (prices.Count == 0)
{
// return 0; // replaced
throw new InvalidOperationException("The order has no books, so there is no average price."); // new
}
var total = prices.Sum();
return total / prices.Count;
}
}
throwraises the exception. The method stops there, as in the three rules.new InvalidOperationException("...")creates the exception object. Its type is the name, which says the kind of problem: this one means "this call is not valid in the current state of things". .NET has many types for common cases. The message in the brackets is a sentence for the developer who will read it.- We chose the check. Before, .NET raised the exception by accident, from inside the division. Now our own code does it on purpose, one step earlier, with words that describe the shop and not the arithmetic.
Sending the unusual order again (the order with no books) gives:
Unhandled exception. System.InvalidOperationException: The order has no books, so there is no average price.
at PriceCalculator.AveragePrice(List`1 prices) in .../PriceCalculator.cs:line 7
at OrderService.AveragePrice(List`1 prices) in .../OrderService.cs:line 8
at OrderController.Summary(List`1 prices) in .../OrderController.cs:line 7
at Program.<Main>$(String[] args) in .../Program.cs:line 4
Still a crash, and the report of the problem is better. The first line now says exactly what was wrong, and the System. lines have gone, because the exception starts in our code.
When to throw
Throw when a method cannot do its job and has no sensible answer to give. An average of nothing has no sensible answer. As the first fix showed, returning 0 is a lie that hides the bug.
Do not throw for an ordinary outcome. "There is no book with this id" is normal, since people ask for books that do not exist every day. That is why GetByIdAsync in the books service returns null, and the controller turns it into a 404. A missing book is not an emergency. An empty order in the middle of a summary is.
Catch it where you can do something
Crashing is sometimes the right thing. In a program that someone is using, it is not. We want the program to say "no summary" and keep going. That is the job of try/catch, and it is the supervisor's question in code: is this my kind of problem? The code that sends the orders is at the top of the chain, so that is where we wrap the call that sends the order. Everything else in Program.cs stays as it was:
try
{
Console.WriteLine(controller.Summary(order));
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Sorry, no summary: {ex.Message}");
}
try { ... }says "run this, and watch for exceptions".catch (InvalidOperationException ex) { ... }says "if an exception of this type comes out of thetry, run this block instead". The type is the supervisor's "my kind of problem": an exception of another type is not caught here, and keeps climbing.exis the exception object, andex.Messageis the sentence we wrote in the calculator.- When nothing goes wrong, the
catchblock is skipped. When an exception comes up, the rest of thetryblock is skipped, thecatchruns, and then the program carries on after the whole thing.
Sending the unusual order again gives:
Sorry, no summary: The order has no books, so there is no average price.
Done.
Done. is printed. The program told the user what was wrong and continued.
Where does the catch go?
We put it in Program.cs, at the very top of the chain, and not in the calculator, the service or the controller. That was a choice.
The rule is: catch an exception where you can do something sensible about it, such as tell the user, try again, or use a fallback. If you cannot do anything useful, do not catch. Let it climb to someone who can. The calculator could not know whether the program was a console, a web page or a nightly job, so it just throws. The service and the controller had nothing useful to add, so they let the exception pass through. Program knew its audience, a person at a console, and caught it.
Two ways to get it wrong
1. The silent catch. An empty catch swallows the problem. In the office, it is the supervisor who quietly makes the problem disappear and tells nobody. Suppose the catch in Program.cs were the one below, with nothing inside the braces:
catch (Exception)
{
}
The program would print only this:
Done.
The summary for the order has simply vanished. There is no crash and no message, and nobody will ever know it failed. This is worse than the crash, which at least told us something. (Exception is the type that matches every exception.) Never catch and say nothing. If you catch, do something: report it, or write it to a log, or both.
2. A try/catch in every method. It is tempting to wrap everything, so that nothing ever crashes. In the office, that is every employee improvising their own answer for the customer, so the customer hears something different each time. This is the habit of inventing your own error handling in every method. A web API has a better way: one place that catches, for the whole program.
What this means for the API
Compare the picture of the crash with a request in a web API. There, a request goes BooksController → BookService → database library, and a missing table makes the library raise an exception. It climbs back up through the service and the controller, just like ours, because neither can fix a missing table. In our demo the top of the chain was Program.cs. In a web API the top of the chain is ASP.NET, and nobody wrote a catch there. When an exception climbs all the way up, ASP.NET stops that one request and sends the client an error, a 500. The server keeps running. A web API can have one place, a global handler, that catches everything for the whole API: the CEO's level of the office, which tells every customer "sorry" in the same way.
Recap
- An exception is a method's way of saying "I cannot continue." The method stops on the spot, and the problem goes to its caller, then to that caller's caller, until someone catches it. If nobody does, the program ends.
- Think of an office: a junior staff member reports a problem he may not fix, and each person up the chain either handles it or passes it on, until someone has the authority.
- The call stack is the pile of methods that are waiting for each other. The stack trace is that pile, printed at the moment of the problem, like the note with the names of everyone it passed through.
- To read a stack trace: the first line says what happened, skip the
System.lines, start at the first line with our own file name and its line number, then read down to see who called it. - A quick
return 0for a case with no answer hides the bug and can mislead the user. It is honest only when 0 really is the answer, such as the total of nothing. throw new SomeException("message")raises an exception on purpose, with a message that describes the problem in the language of the shop.- Throw when a method cannot do its job and has no sensible answer. Do not throw for ordinary outcomes such as "no such book", which is
null. try/catchruns thetryblock, and if an exception of the named type comes out, runs thecatchblock and carries on. The type is the answer to "is this my kind of problem?"- Catch where you can do something sensible. Never swallow an exception silently.
Questions to think about
These have no answers here, on purpose. Try them from memory first.
- What does a method do when it cannot continue, instead of returning a value?
- In our program, why was
Done.never printed before we added thecatch? - When you read a stack trace, which line do you read first, and where do you start looking in your own code?
- Why does the
catchbelong inProgram.csand not inPriceCalculator? - A web API has no
Program.csthat we wrote at the top of the chain. Who catches an exception there, and what does the client get? (The next lesson answers this.)
Next lesson: errors the professional way. Many developers invent their own way to report errors. We look at the standard way, and make one handler for the errors nobody planned for.