Error Handling in Rust and Axum

In the previous post, we began building a simple Dog Tricks API with Axum. The focus was on getting the basics in place: defining our trick model, wiring up handlers, and persisting data through a repository. It was a good starting point, but the implementation was still very naive, every request was assumed to succeed.
In real-world applications, things aren’t that simple. Clients might send bad input, reference missing resources, or hit your API with conflicting data. If we don’t handle these cases properly, the API either crashes or always responds with a generic 500 Internal Server Error. That’s frustrating for clients and makes debugging harder.
Before we add validation and error handling to our API, let’s step back and see how Rust deals with errors in general. This will give us the right foundation before we refactor our code.
How Rust Handles Errors
In many languages, errors are handled with exceptions: you throw them when something goes wrong, and they bubble up until someone catches them. Rust takes a different approach. Instead of exceptions, it models errors directly in the type system. That means every function that might fail has to explicitly say so in its return type.
At the core of this mechanism is the Result type, which is an enum provided by the standard library:
enum Result<T, E> {
Ok(T),
Err(E),
}
You can read this as: a result is either Ok containing some success value, or Err containing some error value. The generic parameters make this flexible: T is the type of the value you get when the operation succeeds, and E is the type of the error you get when the operation fails. Unlike exceptions, errors can’t be silently ignored, because the compiler forces you to handle both cases.
Returning a plain string as an error sometimes works in simple examples, but it doesn’t scale well. A string could contain anything, and the compiler can’t help you understand what kinds of errors to expect. A more idiomatic and powerful way is to create custom error types. In Rust, this is usually done with enums, where each variant represents a distinct error case. For our API, we’ll define a TrickError:
use uuid::Uuid;
#[derive(Debug)]
enum TrickError {
NotFound(Uuid),
Validation(String),
}This approach has several advantages. The compiler enforces that every error case is handled. The code becomes more self-documenting, since a Result<Trick, TrickError> immediately tells you what can go wrong. And because variants can carry data, the error is not only precise but also informative, NotFound(Uuid) carries the missing ID, while Validation(String) carries a helpful message.
For larger applications, it’s common to also implement the std::error::Error trait (and std::fmt::Display) so the errors integrate nicely with the rest of the ecosystem. Display is especially important because it defines the human-readable form of your error, this is what shows up in log messages or when you print the error. Without it, you’d only get the raw Debug output, which is usually less clear.
use std::error::Error;
use std::fmt;
use std::fmt::Display;
impl Error for TrickError {}
impl Display for TrickError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TrickError::NotFound(id) => write!(f, "Trick with id {} not found", id),
TrickError::Validation(msg) => write!(f, "Validation failed: {}", msg),
}
}
}Introducing a Service Layer
In our first version, handlers talked directly to the repository. That approach works fine in very small examples, but it quickly becomes problematic as soon as we add validation or more complex business logic. The handler code gets cluttered with concerns that don’t really belong to the HTTP layer, while the repository should stay focused on persistence only.
To address this, we introduce a service layer that sits between the handlers and the repository. The service is responsible for domain logic such as validation, while the handlers take care of HTTP details and the repository deals with data storage. This separation of concerns keeps each layer simpler and makes the code easier to maintain.
Here is the first version of the TrickService. At this point, it doesn’t do any validation yet and simply delegates all calls to the repository:
use crate::trick_models::{Trick, TrickCreateInput, TrickReplaceInput};
use crate::trick_repository::TrickRepository;
use uuid::Uuid;
pub struct TrickService {
trick_repository: TrickRepository,
}
impl TrickService {
pub fn new(trick_repository: TrickRepository) -> Self {
Self { trick_repository }
}
pub async fn create(&self, input: TrickCreateInput) -> Trick {
self.trick_repository.create(input).await
}
pub async fn replace(&self, id: Uuid, input: TrickReplaceInput) -> Trick {
self.trick_repository.replace(id, input).await
}
pub async fn find_all(&self) -> Vec<Trick> {
self.trick_repository.find_all().await
}
pub async fn find_by_id(&self, id: Uuid) -> Option<Trick> {
self.trick_repository.find_by_id(id).await
}
pub async fn delete_by_id(&self, id: Uuid) {
self.trick_repository.delete_by_id(id).await
}
}
At this stage, the service doesn’t provide much benefit beyond indirection, but it sets us up nicely for the next step: adding validation logic into a dedicated place.
Adding Validation
The real benefit of the service layer becomes clear once we start adding validation. Instead of pushing these checks into the handlers or repositories, we can keep them in one place where they belong. For our API, we want to make sure that a trick has a non-empty title when it is created, and when a trick is replaced we also want to ensure that the referenced trick actually exists.
To achieve this, we add two validation methods inside the service. One checks the input for creating a trick, the other verifies both existence and validity when replacing. Each of these methods returns a Result<(), TrickError>, which means they either succeed silently or fail with a specific domain error. By using the ? operator, we can propagate those errors without boilerplate code: if validation fails, the service method returns early with an error; if it succeeds, execution continues.
Here’s how the service looks after introducing validation and updating the method return types to Result<Trick, TrickError>:
use crate::trick_models::{Trick, TrickCreateInput, TrickError, TrickReplaceInput};
use crate::trick_repository::TrickRepository;
use uuid::Uuid;
pub struct TrickService {
trick_repository: TrickRepository,
}
impl TrickService {
pub fn new(trick_repository: TrickRepository) -> Self {
Self { trick_repository }
}
pub async fn create(&self, input: TrickCreateInput) -> Result<Trick, TrickError> {
self.validate_create(&input)?;
Ok(self.trick_repository.create(input).await)
}
pub async fn replace(&self, id: Uuid, input: TrickReplaceInput) -> Result<Trick, TrickError> {
self.validate_replace(id, &input).await?;
Ok(self.trick_repository.replace(id, input).await)
}
pub async fn find_all(&self) -> Vec<Trick> {
self.trick_repository.find_all().await
}
pub async fn find_by_id(&self, id: Uuid) -> Result<Trick, TrickError> {
self.trick_repository
.find_by_id(id)
.await
.ok_or(TrickError::NotFound(id))
}
pub async fn delete_by_id(&self, id: Uuid) {
self.trick_repository.delete_by_id(id).await
}
fn validate_create(&self, data: &TrickCreateInput) -> Result<(), TrickError> {
if data.title.trim().is_empty() {
return Err(TrickError::Validation(
"title must not be empty".to_string(),
));
}
Ok(())
}
async fn validate_replace(&self, id: Uuid, data: &TrickReplaceInput) -> Result<(), TrickError> {
self.find_by_id(id).await?;
if data.title.trim().is_empty() {
return Err(TrickError::Validation(
"Title must not be empty".to_string(),
));
}
Ok(())
}
}Error handling in the handlers
Our TrickService always returns a Result<Trick, TrickError>. In Axum, a handler can also return a Result<T, E>. The only requirement is that the error type E implements the IntoResponse trait. This trait tells Axum how to turn your custom error into an HTTP response. Once we implement it for TrickError, our handlers can simply return the service result, and Axum will automatically convert any error into a proper JSON response with the right status code.
Before we can make use of this, we need to adjust our handlers. Instead of calling the repository directly, they now work with the TrickService, which encapsulates validation and domain rules. Their return types also change from plain values like Trick to Result<Json<Trick>, TrickError>. That way, any error from the service is passed straight back to Axum, which will handle the conversion into an HTTP response.
To see how this looks from the outside, imagine a client requests a trick that does not exist. Instead of a generic 500, the API now responds with a clear and structured error:
{
"status_code": 404,
"message": "Trick with id 123e4567-e89b-12d3-a456-426614174000 not found"
}We achieve this by defining a small ApiError struct and implementing IntoResponse for TrickError. Each error variant is mapped to a status code and a message, which is then wrapped into a JSON response:
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
#[derive(Serialize)]
struct ApiError {
status_code: u16,
message: String,
}
impl IntoResponse for TrickError {
fn into_response(self) -> Response {
let (status_code, message) = match self {
TrickError::NotFound(id) => (
StatusCode::NOT_FOUND,
format!("Trick with id {id} not found"),
),
TrickError::Validation(message) => {
(StatusCode::BAD_REQUEST, message)
}
};
let json_error = Json(ApiError {
status_code: status_code.as_u16(),
message,
});
(status_code, json_error).into_response()
}
}This way, clients always receive consistent JSON error responses and we keep the conversion from domain errors to HTTP errors in one central place.
Conclusion
In this article, we focused on making our API more robust by introducing proper error handling and validation. Along the way, we also added a simple service layer, which keeps the handlers lean and provides a central place for validation and business logic. Instead of returning plain values, the API now consistently works with results, allowing us to model both successful outcomes and domain-specific failures such as validation errors or missing tricks.
We changed the handlers to depend on the trick service rather than the repository, and their return types now reflect this error-aware design. By implementing the IntoResponse trait for TrickError, Axum can automatically convert domain errors into structured JSON responses with the appropriate HTTP status codes.
The outcome is an API that communicates failures in a clear and consistent way, while maintaining a cleaner separation of responsibilities. You can find the complete code example for this article in my GitHub repository: dog-tricks-api-error-handling.