Create your own
Lesson illustration

Handling Invalid Input and Domain Failures with C# Exceptions

Hello again. In the previous lesson, you used LINQ to prepare menu and receipt data without changing the underlying collections. That code assumed valid inputs and valid domain objects. Real applications cannot make that assumption: a customer can enter text where a quantity is expected, a menu ID may not exist, and a previously available item may become unavailable before it is added to an order.

This lesson separates those cases clearly. You will handle ordinary invalid console input with validation and retries, while using C# exceptions to protect the restaurant domain when a method cannot safely complete its contract. This distinction is important in junior .NET work: it keeps the console app reliable now and provides the same foundation that later becomes API validation and error responses.


Two different problems: input mistakes and domain failures

An exception is an object representing a failure during program execution. When code throws one, normal execution of the current method stops. The runtime looks for a compatible catch block higher in the call stack; if none exists, the application ends with an unhandled exception.

The crucial design question is not “Can I use try-catch here?” It is:

Is this an expected condition that my current layer can prevent or correct, or is it a failure that means this operation cannot continue safely?

For the restaurant console application, use this guideline:

SituationPreferred responseWhy
Customer enters "two" for quantityValidate with int.TryParse and ask againInvalid typing is expected in an interactive UI.
Customer enters 0 or -3Validate and ask againThe UI can explain the rule and request a correction.
Customer enters an unknown menu IDCheck with TryGetValue and show a messageA missing selection is an expected result, not a program crash.
Code calls Order.AddItem(item, 0)Throw ArgumentOutOfRangeExceptionThe caller violated the method’s contract.
Code attempts to add an unavailable itemThrow InvalidOperationExceptionThe operation is not allowed in the current domain state.
A database, file, or network operation fails laterHandle or propagate an exception at an appropriate boundaryThe current operation may be impossible to complete.

A domain object such as Order cannot prompt a user to try again. It should protect its own valid state and clearly signal when a caller asks it to do something invalid.

Study Microsoft’s guidance before implementing the policy. Focus on why exceptions indicate a method cannot fulfill its defined purpose, and why they should not drive ordinary program flow.

Creating and Throwing Exceptions - C# | Microsoft Learn

Read Microsoft Learn’s “Create and throw exceptions.” It establishes the professional distinction between invalid arguments, invalid object state, and ordinary control flow.

In the opening discussion, read from when to throw. Pay particular attention to the examples using ArgumentException and InvalidOperationException. Then continue in the “Things to consider when throwing exceptions” section. Read the recommended practices, especially the advice not to throw broad framework exceptions such as Exception, NullReferenceException, or IndexOutOfRangeException from your own code.


Validate console input without treating mistakes as exceptions

The image below shows a common beginner pattern: a while (true) loop calls int.Parse inside try-catch. A nonnumeric entry throws, the catch prints an error, and the loop retries.

The image shows a C# `while (true)` console-input loop that uses `int.Parse`, checks whether a number is even, and catches an invalid numeric entry before retrying.

This works technically, but it is not the preferred pattern for routine user input. A customer typing "one" instead of 1 is not exceptional; it is normal input that did not meet the required format. int.TryParse represents that situation directly with a bool, without throwing an exception.

Use a focused helper method for a positive order quantity:

static int? ReadPositiveQuantity()
{
    while (true)
    {
        Console.Write("Quantity (or press Ctrl+Z / Ctrl+D to stop): ");
        string? input = Console.ReadLine();

        if (input is null)
        {
            return null;
        }

        if (!int.TryParse(input, out int quantity))
        {
            Console.WriteLine("Enter a whole number, for example 1 or 2.");
            continue;
        }

        if (quantity <= 0)
        {
            Console.WriteLine("Quantity must be greater than zero.");
            continue;
        }

        return quantity;
    }
}

The method has three possible outcomes:

  1. It returns a positive int when the user enters valid input.
  2. It displays a specific message and retries for an invalid format or value.
  3. It returns null when console input closes, allowing the calling code to cancel cleanly.

A caller can use it like this:

int? quantity = ReadPositiveQuantity();

if (quantity is null)
{
    Console.WriteLine("Order entry cancelled.");
    return;
}

Console.WriteLine($"Adding {quantity} item(s) to the order.");

This is a useful application of nullable values from the earlier lessons: null means “no quantity was supplied because the interaction ended,” not “quantity zero.”

Check missing menu items explicitly

The same principle applies to a menu selection. If the console UI owns a dictionary keyed by menu item ID, test the lookup result rather than relying on an exception:

Console.Write("Menu item ID: ");
string? menuItemId = Console.ReadLine();

if (string.IsNullOrWhiteSpace(menuItemId))
{
    Console.WriteLine("A menu item ID is required.");
    return;
}

if (!menuById.TryGetValue(menuItemId, out MenuItem? item))
{
    Console.WriteLine("That menu item was not found.");
    return;
}

TryGetValue expresses a normal possibility: a key may be absent. Using the dictionary indexer, such as menuById[menuItemId], would throw KeyNotFoundException for an unknown key. That is usually the wrong tool when a user can simply select another item.

The important boundary is this: the console layer prevents predictable mistakes. The domain layer defends its rules anyway, because it may later be called from an API, a test, an import job, or another application service.


Choose the exception type that communicates the failure

C# has many built-in exception types. Start with these rather than inventing a custom exception for every business rule.

Exception typeUse it whenRestaurant example
ArgumentNullExceptionA required method argument is nullAddItem(null, 2)
ArgumentExceptionAn argument is invalid but not specifically a range issueA menu-item name is blank
ArgumentOutOfRangeExceptionA numeric or comparable argument is outside its permitted rangeQuantity is zero or negative
InvalidOperationExceptionThe requested action is not allowed in the current stateAdding an unavailable item to an order
FormatExceptionText cannot be parsed in a required formatint.Parse("two")
OverflowExceptionA parsed value is outside the type’s supported rangeParsing a number larger than int.MaxValue

There are two useful rules behind this table:

  • Pick the most specific built-in type that accurately describes the failure.
  • Do not deliberately throw Exception, NullReferenceException, or IndexOutOfRangeException from your own domain code. Those types either say too little or usually indicate a programming bug rather than a business rule.

For an invalid argument, include a useful message and the argument name. This makes debugging much easier:

public MenuItem(string menuItemId, string name, decimal unitPrice, bool isAvailable)
{
    if (string.IsNullOrWhiteSpace(menuItemId))
    {
        throw new ArgumentException(
            "Menu item ID is required.",
            nameof(menuItemId));
    }

    if (string.IsNullOrWhiteSpace(name))
    {
        throw new ArgumentException(
            "Menu item name is required.",
            nameof(name));
    }

    if (unitPrice < 0m)
    {
        throw new ArgumentOutOfRangeException(
            nameof(unitPrice),
            unitPrice,
            "Unit price cannot be negative.");
    }

    MenuItemId = menuItemId;
    Name = name;
    UnitPrice = unitPrice;
    IsAvailable = isAvailable;
}

The constructor cannot create a valid menu item when the ID, name, or price is invalid. Throwing is therefore appropriate.


Make the Order protect its own rules

Now apply the same idea to the aggregate that owns order lines. Even though your console UI validates a quantity before calling AddItem, the Order must not rely on that UI.

A safe version of AddItem can look like this:

public void AddItem(MenuItem item, int quantity)
{
    ArgumentNullException.ThrowIfNull(item);

    if (quantity <= 0)
    {
        throw new ArgumentOutOfRangeException(
            nameof(quantity),
            quantity,
            "Quantity must be greater than zero.");
    }

    if (!item.IsAvailable)
    {
        throw new InvalidOperationException(
            $"Menu item '{item.Name}' is currently unavailable.");
    }

    _lines.Add(new OrderLine(item, quantity));
}

Notice the order of operations:

  1. Guard clauses check every condition needed for a valid change.
  2. Only after all checks pass does the code add an OrderLine.

That structure avoids a partially changed order. If the method throws, the _lines.Add(...) call was never reached.

quantity is a method argument, so an invalid quantity is correctly represented by ArgumentOutOfRangeException. Availability is a state of the menu item at the time of the action, so InvalidOperationException communicates that “adding this item is currently not allowed.”

The console application can now combine input validation with domain protection:

int? quantity = ReadPositiveQuantity();

if (quantity is null)
{
    Console.WriteLine("Order entry cancelled.");
    return;
}

try
{
    order.AddItem(item, quantity.Value);
    Console.WriteLine($"{item.Name} added to the order.");
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message);
}
catch (ArgumentOutOfRangeException ex)
{
    Console.WriteLine(ex.Message);
}

The ArgumentOutOfRangeException should be unlikely because ReadPositiveQuantity already rejects invalid quantities. It remains useful because no UI validation is a substitute for a domain invariant. For example, another developer could call order.AddItem(item, 0) directly.

A known state failure can still happen after valid input. A menu could be displayed as available, then marked unavailable by a staff action before the customer confirms the order. Catching that known failure at the console boundary lets the user receive a clear message and choose another item.


Catch deliberately, preserve diagnostic information

An exception has useful diagnostic information:

  • Type tells you what category of failure occurred.
  • Message describes the immediate reason.
  • Stack trace records the method calls leading to where it was thrown.
  • Inner exception preserves an original exception when a higher layer wraps it with additional context.

Watch this portion of IAmTimCorey’s exception-handling walkthrough. It demonstrates why a caught exception should lead to a deliberate response rather than simply allowing the program to continue with invalid data.

Understanding Exception Handling in C# - throw, Stack Traces, and more

Watch “Understanding Exception Handling in C# - throw, Stack Traces, and more” by IAmTimCorey. It shows an unhandled parsing failure, the information in its stack trace, and the risk of swallowing exceptions.

Watch the exception anatomy to see int.Parse fail and to identify the message, exception type, and stack trace. Then watch the catch critique; focus on why catching an exception and continuing with a default value can leave the application in an invalid state. Finally, skip to preserving the stack. Note the difference between throw;, which preserves the original stack trace, and throw ex;, which obscures where the failure originally occurred.

Do not swallow exceptions

This is dangerous:

try
{
    order.AddItem(item, quantity);
}
catch
{
}

The application has hidden a failure and gives no confirmation that the item was actually added. Empty catch blocks are a strong warning sign in code review.

A broad catch can be just as misleading:

try
{
    order.AddItem(item, quantity);
}
catch (Exception)
{
    Console.WriteLine("Invalid order.");
}

This incorrectly labels every failure as an invalid order, including programming defects that should be diagnosed. For example, a NullReferenceException caused by a bug would be hidden from you.

Instead:

  • Catch only exception types you can meaningfully handle at that point.
  • Place more specific catch blocks before broader ones.
  • If you merely need cleanup, prefer using for disposable resources; C# ensures cleanup even when an exception occurs.
  • If you catch an exception to log it or add necessary context, use throw; to let it continue with its original stack trace.

Do not catch an exception just to rethrow it unchanged. In that case, omit the try-catch entirely.

When should you create a custom exception?

Not yet for simple rules such as “quantity must be positive.” Built-in exceptions are clearer and familiar to .NET developers.

A custom exception becomes worthwhile when the rest of the application must identify and handle a domain-specific category differently from all standard exceptions. For now, InvalidOperationException is sufficient for an unavailable-item rule. In later API work, you will centralize how known failures become consistent client-facing responses instead of placing try-catch blocks in every endpoint.


Consolidate the restaurant console application

Make the change on a focused branch. Add the input helper and the domain guards, then manually verify the behavior before committing:

  • Enter two for quantity; the app should explain the expected format and retry.
  • Enter 0 and -1; the app should explain that quantity must be positive.
  • Enter a menu ID that does not exist; the app should display a clear message without crashing.
  • Attempt to add an unavailable menu item; Order.AddItem should reject the operation.
  • Call order.AddItem(item, 0) temporarily from code to confirm the domain guard works independently of console validation.
  • Confirm a rejected operation does not add a new order line.

Then preserve the work:

git switch main
git pull --ff-only
git switch -c feature/exception-safe-order-entry
git add src/Restaurant.Console
git commit -m "feat: validate order input and enforce domain rules"
git push -u origin feature/exception-safe-order-entry

A reviewer should be able to see two complementary protections in this commit: friendly input validation in the console layer and invariant enforcement in the domain model.


Summary

You now have a practical exception policy for the restaurant application:

  • Use TryParse, TryGetValue, conditions, and retry loops for expected invalid user input.
  • Use exceptions when a method cannot fulfill its contract or a domain operation is invalid in the current state.
  • Protect domain invariants inside domain methods even when the UI already validates input.
  • Prefer specific standard exceptions such as ArgumentOutOfRangeException and InvalidOperationException.
  • Catch only failures you can handle meaningfully; never silently swallow them.
  • Preserve original diagnostic information with throw; when propagation is still required.

Next, the course moves from the console application into ASP.NET Core APIs. You will begin by interpreting HTTP methods, status codes, headers, and JSON bodies—the transport layer through which a React client will eventually submit these same restaurant actions.

Can't find a good explanation? Sign up and we'll make it for you

Sign up