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
UserDtoobject. 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.



Useful, Thanks