Result Pattern vs Exception Handling in .NET Core API – A Practical Guide
Learn when to return results and when to throw exceptions for cleaner, more reliable service layers
When building APIs with .NET Core, one of the common questions developers face is:
"Should I return a
Result<T>
object or throw exceptions when something goes wrong?"
In this article, we’ll explore both approaches with a real-world example: fetching a user by ID from a database. You’ll learn the pros and cons of each style, and when to use what.
🧩 The Scenario
We have a simple use case:
Fetch a user by ID and return a
UserDto
object. If the user doesn’t exist, handle the situation appropriately.
Let’s look at two ways to implement this: one with a Result<T>
wrapper, and one using exceptions.
✅ Option 1: Using Result<T>
Pattern
This pattern wraps your response in a Result<T>
object, which can represent either a success or a failure with a message.
🔧 Step 1: Define the Result<T>
class
public class Result<T>
{
public bool Success { get; set; }
public string? Message { get; set; }
public T? Data { get; set; }
public static Result<T> Ok(T data, string? message = null)
=> new() { Success = true, Data = data, Message = message };
public static Result<T> Fail(string message)
=> new() { Success = false, Message = message };
}
🧠 Step 2: Service Layer Implementation
public class UserService : IUserService
{
private readonly AppDbContext _dbContext;
public UserService(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<Result<UserDto>> GetUserByIdAsync(int id)
{
var user = await _dbContext.Users.FindAsync(id);
if (user == null)
return Result<UserDto>.Fail("User not found");
var userDto = new UserDto { Id = user.Id, Name = user.Name };
return Result<UserDto>.Ok(userDto);
}
}
🌐 Step 3: API Controller
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly IUserService _userService;
public UsersController(IUserService userService)
{
_userService = userService;
}
[HttpGet("{id}")]
public async Task<IActionResult> GetUser(int id)
{
var result = await _userService.GetUserByIdAsync(id);
if (!result.Success)
return NotFound(new { result.Message });
return Ok(result.Data);
}
}
🚨 Option 2: Using Exceptions
Alternatively, we can throw an exception if the user is not found and catch it either globally or at the controller level.
.
🧠 Step 1: Service Layer with Exception
public class UserService : IUserService
{
private readonly AppDbContext _dbContext;
public UserService(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<UserDto> GetUserByIdAsync(int id)
{
var user = await _dbContext.Users.FindAsync(id);
if (user == null)
throw new UserNotFoundException($"User with ID {id} not found.");
return new UserDto { Id = user.Id, Name = user.Name };
}
}
🚫 Step 2: Define the Custom Exception
public class UserNotFoundException : Exception
{
public UserNotFoundException(string message) : base(message) { }
}
⚙️ Step 3: Global Exception Middleware
public class ExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionMiddleware> _logger;
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task Invoke(HttpContext context)
{
try
{
await _next(context);
}
catch (UserNotFoundException ex)
{
context.Response.StatusCode = 404;
await context.Response.WriteAsJsonAsync(new { message = ex.Message });
}
catch (Exception ex)
{
context.Response.StatusCode = 500;
_logger.LogError(ex, "Unhandled Exception");
await context.Response.WriteAsJsonAsync(new { message = "Something went wrong." });
}
}
}
Register it in Program.cs
:
app.UseMiddleware<ExceptionMiddleware>();
🌐 Step 4: API Controller
[HttpGet("{id}")]
public async Task<IActionResult> GetUser(int id)
{
var user = await _userService.GetUserByIdAsync(id);
return Ok(user);
}
🧠 When Should You Use Each?
Use Result<T>
Pattern When:
You expect business-level errors like "User not found" or "Invalid input".
You want to return structured and consistent API responses.
You prefer a functional style of programming.
Use Exceptions When:
You’re dealing with unexpected errors (e.g., DB connection failure).
You want to use built-in exception logging and middleware.
You want to reduce clutter in controller logic.
🧾 Final Thoughts
Both patterns are valid — and often, a hybrid approach works best.
Use
Result<T>
for expected outcomes and business validation.Throw exceptions only for unexpected technical failures.
The goal is not just clean code — it’s reliable and predictable APIs.
✍️ If you found this helpful, follow me for more .NET insights and real-world patterns. Let's keep learning together!
When working with APIs in .NET, HttpClient
is the go-to utility for making HTTP calls. But what if you want to log requests and responses? Or add custom headers like correlation IDs or tokens? That’s where DelegatingHandler
comes in — a powerful but often underutilized tool in .NET.
I hope you found this guide helpful and informative.
Thanks for reading!
If you enjoyed this article, feel free to share it and follow me for more practical, developer-friendly content like this.