Every C# developer eventually runs into this design question, usually right after a perfectly reasonable method starts returning chaos.
Maybe you have a service method that creates a customer. It can fail because the email address is invalid. It can fail because the customer already exists. It can fail because SQL Server is unavailable. It can fail because some external payment API timed out. And now you’re staring at the code wondering whether to throw an exception, return a Result<T>, add model validation, or quietly question your career choices.
This is not just a syntax decision. It is a design boundary decision.
The right answer depends on what kind of failure you are dealing with. Is it exceptional? Expected? User-facing? Domain-level? Infrastructure-related? The same word, “failure,” can describe very different things in an application, and treating them all the same is how codebases become difficult to reason about.
Let’s walk through the practical boundaries between exceptions, result objects, and validation errors in C#.
Not All Failures Are the Same
A common mistake is to treat every failure as either an exception or a return value. That usually leads to one of two extremes.
In one codebase, everything throws. Invalid user input? Throw. Business rule violation? Throw. Customer not found? Throw. The database is on fire? Also throw. Technically consistent, but not very expressive.
In another codebase, nothing throws. Every method returns a custom result type with flags, messages, error codes, nested error collections, and maybe a small emotional support enum. This avoids exceptions, but it often pushes infrastructure failures and programming errors into places where they do not belong.
The better approach is to classify the failure.
Some failures are part of normal application flow. Some are feedback to the user. Some represent business rules. Some are system problems. Some indicate a bug. Once you know which kind of failure you have, the implementation choice becomes much clearer.
Use Exceptions for Truly Exceptional Failures
Exceptions are still valuable in C#. They are not bad. They are just often overused.
Use exceptions when the failure is unexpected, exceptional, or infrastructural, and the current code cannot reasonably recover from it locally.
Good examples include:
- Database connection failures
- Network outages
- Disk access failures
- Misconfiguration
- Authentication provider errors
- Serialization failures caused by unexpected data
- Programming errors such as invalid internal state
In these cases, an exception clearly communicates: “Something happened that this code path was not designed to handle as normal flow.”
public async Task<Customer> GetCustomerAsync(Guid customerId)
{
var customer = await _db.Customers.FindAsync(customerId);
if (customer is null)
{
throw new CustomerNotFoundException(customerId);
}
return customer;
}
This example may look reasonable, but whether it is correct depends on context. If GetCustomerAsync is used internally where the customer should already exist, throwing may be appropriate. If this method backs an API endpoint where users commonly request missing resources, a result object may be better.
That is the key point: exceptions are about violated expectations, not merely negative outcomes.
An exception should usually mean one of these things:
This should not normally happen.
The caller cannot reasonably continue here.
The failure belongs to infrastructure or system behavior.
The code is in an invalid state.
What exceptions should not become is a replacement for if statements. If you expect a condition to happen regularly, it probably should not be modeled as an exception.
Exceptions are like fire alarms. Useful, important, and occasionally lifesaving. But if the fire alarm goes off every time someone makes toast, people stop trusting it.
Use Result Objects for Expected Business Outcomes
Result objects are useful when failure is an expected part of the operation and the caller is expected to make a decision based on that outcome.
For example, creating a user may fail because the email address is already registered. That is not an infrastructure failure. It is not a bug. It is a normal business outcome.
public sealed record Result<T>(
bool IsSuccess,
T? Value,
string? ErrorCode,
string? ErrorMessage)
{
public static Result<T> Success(T value) =>
new(true, value, null, null);
public static Result<T> Failure(string errorCode, string errorMessage) =>
new(false, default, errorCode, errorMessage);
}
A service method can then express business-level outcomes without throwing for routine cases.
public async Task<Result<Customer>> CreateCustomerAsync(CreateCustomerRequest request)
{
var emailExists = await _db.Customers
.AnyAsync(c => c.Email == request.Email);
if (emailExists)
{
return Result<Customer>.Failure(
"Customer.EmailAlreadyExists",
"A customer with this email address already exists.");
}
var customer = new Customer
{
Id = Guid.NewGuid(),
Name = request.Name,
Email = request.Email
};
_db.Customers.Add(customer);
await _db.SaveChangesAsync();
return Result<Customer>.Success(customer);
}
This is a good fit because the caller can reasonably do something with the result. An API controller might return 409 Conflict. A UI might show a friendly message. A workflow might choose another path.
The important distinction is that a result object should represent known outcomes, not random catastrophes.
var result = await customerService.CreateCustomerAsync(request);
if (!result.IsSuccess)
{
return Conflict(new
{
code = result.ErrorCode,
message = result.ErrorMessage
});
}
return CreatedAtAction(nameof(GetCustomer), new { id = result.Value!.Id }, result.Value);
This reads cleanly because “email already exists” is part of the domain conversation. It is not a surprise. It is an answer.
Use Validation Errors for User Input Problems
Validation errors are for input that fails before the business operation should even begin.
This is where ASP.NET Core model validation, data annotations, FluentValidation, or custom validation pipelines often come into play. These failures are usually user-facing and attached to specific fields or request properties.
public sealed class CreateCustomerRequest
{
public required string Name { get; init; }
public required string Email { get; init; }
}
Using FluentValidation, you might write:
using FluentValidation;
public sealed class CreateCustomerRequestValidator
: AbstractValidator<CreateCustomerRequest>
{
public CreateCustomerRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(100);
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress();
}
}
This kind of validation should generally happen at the edge of the application: controllers, endpoints, message handlers, background job input processing, or command handlers. The point is to reject malformed input before it reaches your core business logic.
A missing name, invalid email format, or overly long description is not usually a domain failure. It is request validation.
That said, there is an important nuance: validation and domain rules can overlap, but they are not the same thing.
“Email is required” is validation.
“Customer must be at least 18 years old to open this account” may be a domain rule.
“Account cannot be closed while invoices are unpaid” is definitely a domain rule.
Validation protects the boundary. Domain logic protects the business.
A Practical Comparison
Here is a simple way to think about the differences.

| Failure Type | Example | Best Fit | Why |
|---|---|---|---|
| Invalid user input | Email is missing or malformed | Validation errors | The user can fix the request |
| Expected business conflict | Email already exists | Result object | The operation had a known negative outcome |
| Domain rule violation | Cannot close account with unpaid invoices | Result object or domain-specific error | The rule is part of business behavior |
| Missing resource | Customer not found | Depends on context | Expected in APIs, exceptional in internal flows |
| Infrastructure failure | Database unavailable | Exception | Not a normal business outcome |
| Programming error | Required dependency is null | Exception | Indicates a bug or invalid state |
| External service timeout | Payment gateway unavailable | Exception or retry result | Usually infrastructure, sometimes modeled explicitly |
Notice that “not found” is not automatically one thing. In a public API, GET /customers/{id} returning no customer is expected. In an internal invoice calculation process where the customer ID came from a trusted database record, not finding the customer may indicate data corruption or a bug. Context matters.
The Boundary Between Validation and Domain Rules
One of the easiest mistakes to make is putting all rules into request validation because it feels convenient.
For example:
RuleFor(x => x.AccountId)
.MustAsync(AccountHasNoUnpaidInvoices)
.WithMessage("Account cannot be closed while invoices are unpaid.");
This might work technically, but it can blur boundaries. If the rule is central to the business, it probably belongs in the domain or application service layer, not only in an HTTP request validator.
Why? Because business rules need to be enforced consistently across every entry point. Today the operation comes from an API. Tomorrow it may come from a background job, admin tool, message queue, import process, or AI-powered helper that confidently tries to do the wrong thing at scale.
A better approach is to keep simple shape validation at the edge and business rules inside the operation.
public async Task<Result<Account>> CloseAccountAsync(Guid accountId)
{
var account = await _db.Accounts
.Include(a => a.Invoices)
.SingleOrDefaultAsync(a => a.Id == accountId);
if (account is null)
{
return Result<Account>.Failure(
"Account.NotFound",
"The account was not found.");
}
if (account.Invoices.Any(i => !i.IsPaid))
{
return Result<Account>.Failure(
"Account.HasUnpaidInvoices",
"The account cannot be closed while invoices are unpaid.");
}
account.Close();
await _db.SaveChangesAsync();
return Result<Account>.Success(account);
}
The validation layer can still check that accountId is present and well-formed. But the rule about unpaid invoices belongs with the business operation.
The Common Mistake: Throwing for Business Rules
A lot of C# applications start with something like this:
public async Task CloseAccountAsync(Guid accountId)
{
var account = await _db.Accounts
.Include(a => a.Invoices)
.SingleOrDefaultAsync(a => a.Id == accountId);
if (account is null)
{
throw new Exception("Account not found.");
}
if (account.Invoices.Any(i => !i.IsPaid))
{
throw new Exception("Account has unpaid invoices.");
}
account.Close();
await _db.SaveChangesAsync();
}
This is not terrible for a small application. Many successful systems started with worse. But as the codebase grows, this approach becomes harder to manage.
Which exceptions should be shown to users? Which should be logged as errors? Which should trigger alerts? Which should map to 400, 404, 409, or 500? If every negative outcome is an exception, your error handling layer has to become a mind reader.
A result-based version is more explicit.
public async Task<Result> CloseAccountAsync(Guid accountId)
{
var account = await _db.Accounts
.Include(a => a.Invoices)
.SingleOrDefaultAsync(a => a.Id == accountId);
if (account is null)
{
return Result.Failure(
"Account.NotFound",
"The account was not found.");
}
if (account.Invoices.Any(i => !i.IsPaid))
{
return Result.Failure(
"Account.HasUnpaidInvoices",
"The account cannot be closed while invoices are unpaid.");
}
account.Close();
await _db.SaveChangesAsync();
return Result.Success();
}
Here, the method tells the truth about what can happen. It does not pretend that every business rule violation is a system failure.
The Other Mistake: Turning Everything Into a Result
The opposite mistake is just as common in teams that have been burned by exception-heavy code. They create a result type, fall in love with it, and suddenly every method returns Result<T>.
public async Task<Result<Customer>> GetCustomerFromDatabaseAsync(Guid id)
{
try
{
var customer = await _db.Customers.FindAsync(id);
if (customer is null)
{
return Result<Customer>.Failure("Customer.NotFound", "Customer not found.");
}
return Result<Customer>.Success(customer);
}
catch (Exception ex)
{
return Result<Customer>.Failure("Database.Error", ex.Message);
}
}
This looks tidy, but it may hide a serious problem. A database failure is not the same category of failure as a missing customer. One is a business or application outcome. The other is an operational failure.
Catching broad exceptions and converting them into result objects can also damage observability. Your logging, metrics, retries, circuit breakers, and alerting may never see the real failure clearly. You end up with a polite little Result object standing in front of a production incident saying, “Nothing to see here.”
A better pattern is to let infrastructural exceptions flow to middleware, logging, retry policies, or higher-level handlers designed for operational failure.
public async Task<Result<Customer>> GetCustomerAsync(Guid id)
{
var customer = await _db.Customers.FindAsync(id);
if (customer is null)
{
return Result<Customer>.Failure(
"Customer.NotFound",
"The customer was not found.");
}
return Result<Customer>.Success(customer);
}
If the database connection fails, let it throw. That is not a customer result. That is a system problem.
Mapping Failures in ASP.NET Core APIs
In web APIs, this design question becomes very visible because every failure eventually needs an HTTP response.
A practical mapping might look like this:
| Application Outcome | HTTP Response |
|---|---|
| Validation error | 400 Bad Request or 422 Unprocessable Entity |
| Resource not found | 404 Not Found |
| Business conflict | 409 Conflict |
| Authentication required | 401 Unauthorized |
| Permission denied | 403 Forbidden |
| Infrastructure exception | 500 Internal Server Error or 503 Service Unavailable |
Your service layer should not need to know every HTTP detail, but it should expose enough meaning for the API layer to make the right decision.
For example:
public enum ErrorType
{
Validation,
NotFound,
Conflict,
Forbidden,
Failure
}
public sealed record Error(
string Code,
string Message,
ErrorType Type);
Then a result can carry structured error information:
public sealed record Result<T>
{
public bool IsSuccess { get; }
public T? Value { get; }
public Error? Error { get; }
private Result(bool isSuccess, T? value, Error? error)
{
IsSuccess = isSuccess;
Value = value;
Error = error;
}
public static Result<T> Success(T value) =>
new(true, value, null);
public static Result<T> Failure(Error error) =>
new(false, default, error);
}
Your controller or endpoint can then map the result intentionally.
private static IResult ToHttpResult<T>(Result<T> result)
{
if (result.IsSuccess)
{
return Results.Ok(result.Value);
}
return result.Error!.Type switch
{
ErrorType.NotFound => Results.NotFound(result.Error),
ErrorType.Conflict => Results.Conflict(result.Error),
ErrorType.Forbidden => Results.Forbid(),
ErrorType.Validation => Results.BadRequest(result.Error),
_ => Results.Problem(result.Error.Message)
};
}
This keeps your API behavior consistent without forcing every service method to throw exceptions for normal outcomes.
What About Performance?
Performance often enters this debate, usually wearing a cape and carrying a benchmark.
Yes, exceptions are relatively expensive compared to simple return values when they are thrown. But that is not the primary reason to avoid exceptions for expected outcomes. The bigger issue is semantic clarity.
If something happens thousands of times per minute as part of normal application behavior, it probably should not be modeled as an exception. Not because your app will instantly melt, but because your code is communicating the wrong thing.
Use exceptions when the program flow has truly gone off the rails. Use results when the track has a normal fork in it.
A Practical Decision Checklist
When deciding between exceptions, result objects, and validation errors, ask these questions:
- Can the user fix this input directly? Use validation errors.
- Is this a normal, expected outcome of the operation? Use a result object.
- Is this a business rule or domain policy? Use a result object or domain-specific error, and enforce it inside the application or domain layer.
- Is this caused by infrastructure, configuration, network, storage, or an unavailable dependency? Use an exception and handle it at an appropriate boundary.
- Does this indicate a programming bug or invalid internal state? Throw an exception.
- Would logging this as an application error create noise? It may be a result, not an exception.
- Would hiding this inside a result make production failures harder to detect? It should probably remain an exception.
That last pair is worth remembering. If it should not wake anyone up, it may not be an exception. If hiding it would prevent someone from fixing production, it probably is.
Keep the Boundaries Honest
A clean failure design usually has layers.
At the outer edge, validation handles request shape and user-correctable input. In the application layer, result objects represent expected business outcomes. In the infrastructure layer, exceptions represent system failures. At the top boundary, middleware, filters, or endpoint logic translate these outcomes into HTTP responses, logs, metrics, or user messages.
That does not mean every application needs a full architecture diagram and three committees to approve an error code. A small app can keep this simple. But even a small app benefits from honest boundaries.
For example:
HTTP Request
↓
Input Validation
↓
Application Service returns Result<T> for expected outcomes
↓
Infrastructure throws exceptions for unexpected failures
↓
API maps outcomes to HTTP responses
That flow is understandable. More importantly, it stays understandable six months later when another developer has to debug it at 4:37 PM on a Friday, which is legally when all production issues are required to appear.
Be Careful With Error Messages
One practical detail that deserves more attention is the difference between internal error details and user-facing messages.
Validation errors and many result object failures are safe to show to users because they are designed for that purpose:
The email address is required.
A customer with this email address already exists.
The account cannot be closed while invoices are unpaid.
Infrastructure exceptions usually are not safe to show directly:
SqlException: Cannot open database "ProdBillingDb" requested by the login.
That message may be useful in logs, but it does not belong in an API response or UI. For infrastructural failures, return a generic problem response and log the real exception internally.
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new
{
title = "An unexpected error occurred.",
status = 500
});
});
});
The user needs a useful response. The operator needs diagnostic detail. Those are different audiences.
Avoid One-Size-Fits-All Rules
There are teams that say, “Never use exceptions.” There are teams that say, “Always throw.” Both rules are too blunt for real software.
A better team guideline would sound more like this:
Use validation errors for bad input at the boundary. Use result objects for expected domain and application outcomes. Use exceptions for unexpected failures, infrastructure problems, and invalid program state.
That is not as catchy, but it is far more useful.
Also, be consistent within a boundary. If one application service returns Result<T> for business conflicts, similar services should probably do the same. If your API maps domain conflicts to 409 Conflict, do it consistently. Inconsistency is what makes error handling feel random, even when each individual decision seemed reasonable at the time.
Conclusion
The question is not really, “Should I use exceptions or result objects?”
The better question is: “What kind of failure is this, and who is supposed to respond to it?”
That framing changes the design. Exceptions are for exceptional or infrastructural failures. Result objects are for expected business outcomes. Validation errors are for user-correctable input problems. When you keep those boundaries clear, your C# code becomes easier to read, easier to test, easier to operate, and much easier to explain to the next developer who joins the project.
Good error design is not about avoiding failure. Failure is part of software. Good design makes failure understandable.
Key Takeaways
- Use validation errors for bad input that users or clients can fix.
- Use result objects for expected business outcomes and domain-level decisions.
- Use exceptions for unexpected failures, infrastructure problems, and invalid internal state.
- Do not throw exceptions for routine business flow.
- Do not hide operational failures inside result objects.
- Keep user-facing messages separate from internal diagnostic details.
- Be consistent within your application boundaries.