Build a Task Management REST API

Build a type-safe REST API in Rust with Axum, SQLx, and PostgreSQL.

Introduction

30 Second Summary

Every app you use daily relies on a backend API to manage data. Whether it's a to-do list saving your tasks or a project board tracking progress, a well-designed API is the engine behind the scenes.

In this project, you will build a task management REST API in Rust using Axum for routing, SQLx for database queries, and Tokio for async execution.

What You'll Build

A production-style REST API serving five endpoints for creating, reading, updating, and deleting tasks, backed by a PostgreSQL database running in Docker.

By the end of this project, you'll have:

  • A Rust binary serving HTTP on port 3000 with typed routing and JSON serialization.
  • A PostgreSQL database running in Docker with compile-time checked SQL queries via SQLx.
  • A layered architecture with handlers, services, and repositories, plus structured error handling, input validation, and request logging.
  • Secret Mission: Add pagination and filtering to the list endpoint.

Are there any prerequisites?

You should be comfortable with basic Rust syntax including variables, functions, structs, enums, and match expressions. Experience building REST APIs in another language (Python, Node, Go) is also helpful.

Set Up the Project and Database

Every REST API needs two things: a project structure to organize your code, and a database to store data.

In this step, you will create a new Rust project with Cargo and spin up a PostgreSQL database inside a Docker container. By the end, your project will be connected and ready for code.

In this step, get ready to:
  • Create a new Rust project with Cargo.
  • Start a PostgreSQL database in Docker.
  • Connect your project to the database.
Create the Cargo project

Cargo is Rust's build system and package manager. The cargo new command scaffolds a project with the correct folder structure, a Cargo.toml manifest, and a starter main.rs file.

  • Open the integrated terminal in VS Code by pressing Cmd+` (macOS) or Ctrl+` (Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project and move into it by running these commands:
cargo new task-api --edition 2021
cd task-api

Why edition 2021?

Rust 1.96 defaults to edition 2024, which makes environment variable access (set_var) unsafe in multi-threaded code. Edition 2021 keeps things simpler for this project since we load environment variables at startup.

  • Confirm the project was created by running:
ls

You should see Cargo.toml and a src folder in the output.

  • Open the project folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Select the task-api folder on your Desktop, then click Open.

Don't see the task-api folder?

Make sure you ran cd ~/Desktop before cargo new. If you skipped it, the folder was created in your home directory instead. Run ls ~/Desktop/task-api to check, or re-run the commands above starting from cd ~/Desktop.

Help me find my task-api project folder

Set up PostgreSQL with Docker

Docker lets you run PostgreSQL in an isolated container without installing it directly on your machine. You define the database configuration in a docker-compose.yml file, and Docker handles the rest.

  • Make sure Docker Desktop is running. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar, type Docker Desktop, and press Enter to open it.

Already see the Docker whale icon?

If you see the Docker whale icon in your system tray (macOS menu bar or Windows taskbar), Docker Desktop is already running. You can skip re-opening it.

  • In VS Code's Explorer sidebar, right-click in the empty area below the file list and select New File.
  • Name the file docker-compose.yml and press Enter.
  • Paste the following configuration into docker-compose.yml:
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: taskdb
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

What does this configuration do?

This file tells Docker to run a PostgreSQL 16 container with a database called taskdb and default credentials (postgres/postgres).

The ports mapping exposes PostgreSQL on port 5432 so your Rust app can connect to it. The volumes section persists data between container restarts.

  • Save docker-compose.yml.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: taskdb
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:
  • Start the PostgreSQL container by running this command in your VS Code terminal:
docker compose up -d

You should see output indicating the container was created and started, ending with a line like Container task-api-postgres-1 Started.

Seeing a connection error or permission denied?

Make sure Docker Desktop is fully running (not just installed). Look for the whale icon in your system tray. If you just opened Docker Desktop, wait 10-15 seconds for it to finish starting up, then retry the command.

If port 5432 is already in use, another PostgreSQL instance may be running on your machine. Stop it first or change the port mapping in docker-compose.yml.

Help me troubleshoot Docker Compose errors

Connect your project to the database

SQLx needs a DATABASE_URL environment variable to know where your database lives. You store this in a .env file at the project root.

  • In VS Code's Explorer sidebar, right-click in the empty area and select New File.
  • Name the file .env and press Enter.
  • Paste the following line into .env:
DATABASE_URL=postgres://postgres:postgres@localhost:5432/taskdb

What is this connection string?

This URL tells SQLx how to connect to your PostgreSQL container. It follows the format postgres://user:password@host:port/database. The values match what you configured in docker-compose.yml.

  • Save .env.
  • Create the database by running this command in your terminal:
sqlx database create

If the command completes with no output, it worked. SQLx silently creates the taskdb database inside your running PostgreSQL container.

Getting a connection refused error?

This means SQLx cannot reach PostgreSQL. Verify your container is running by executing docker compose ps in your terminal. You should see the postgres service listed with a status of Up.

Also check that your .env file is saved and located in the task-api root folder (the same level as Cargo.toml).

Help me fix the sqlx database create connection error

Your project is scaffolded and connected to a live PostgreSQL database. Next up, you will add dependencies and build the core API skeleton that serves HTTP requests.

Build the Core API Skeleton

Your project folder is ready and PostgreSQL is running in Docker. Now it is time to turn this empty Cargo project into a working HTTP server.

In this step, you will declare the crates your API depends on, then write the application entry point that boots a Tokio async runtime, connects to PostgreSQL, and serves requests through Axum.

In this step, get ready to:
  • Declare all project dependencies in Cargo.toml.
  • Write the application entry point with structured logging, a database connection pool, and a health check route.
  • Verify the server responds to a request on port 3000.
Add project dependencies

Rust uses Cargo.toml to declare external crates your project needs. You will add the libraries that handle HTTP routing, async execution, database access, serialization, logging, and validation.

  • In VS Code, open Cargo.toml from the Explorer sidebar.
  • Find the [dependencies] line at the bottom of the file.
  • Replace everything from [dependencies] onward with the following:
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.9", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tower-http = { version = "0.6", features = ["trace"] }
validator = { version = "0.19", features = ["derive"] }
thiserror = "2"
dotenvy = "0.15"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }

What do these crates do?

  • axum is the HTTP framework built on top of Tower and Tokio. It handles routing, request extraction, and response generation.
  • tokio provides the async runtime that powers non-blocking I/O.
  • serde and serde_json handle converting Rust structs to and from JSON.
  • sqlx connects to PostgreSQL and runs compile-time checked SQL queries.
  • tracing and tracing-subscriber produce structured logs for every request.
  • tower-http adds middleware for request/response tracing.
  • validator validates incoming JSON against rules you define with derive macros.
  • thiserror makes defining custom error types ergonomic.
  • dotenvy loads environment variables from your .env file.
  • uuid and chrono provide unique IDs and timestamps for your task records.
  • Save Cargo.toml.

Seeing red underlines in Cargo.toml?

Make sure you kept the [package] section above [dependencies] untouched. Only replace from the [dependencies] line downward.

Double-check that every feature list uses square brackets with quoted strings, for example ["full"].

Ask for help: Why is my Cargo.toml showing errors after adding dependencies?

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

[package]
name = "task-api"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.9", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tower-http = { version = "0.6", features = ["trace"] }
validator = { version = "0.19", features = ["derive"] }
thiserror = "2"
dotenvy = "0.15"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
Write the application entry point

The entry point ties everything together. It loads environment variables, initializes structured logging, creates a database connection pool, and starts the Axum server with a single health check route.

  • Open src/main.rs in VS Code.
  • Select all the existing code and replace it with the following:
use axum::{routing::get, Router};
use sqlx::postgres::PgPoolOptions;
use std::net::SocketAddr;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

#[tokio::main]
async fn main() {
    // Load environment variables from .env file
    dotenvy::dotenv().ok();

    // Initialize structured logging with an environment-based filter
    tracing_subscriber::registry()
        .with(tracing_subscriber::EnvFilter::new(
            std::env::var("RUST_LOG").unwrap_or_else(|_| "task_api=debug,tower_http=debug".into()),
        ))
        .with(tracing_subscriber::fmt::layer())
        .init();

    // Read the database connection string from the environment
    let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");

    // Create a connection pool with a max of 5 connections
    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect(&database_url)
        .await
        .expect("Failed to connect to database");

    // Build the router with a health check endpoint
    let app = Router::new().route("/health", get(health_check));

    // Bind to localhost on port 3000 and start serving
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    tracing::info!("Listening on {}", addr);
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

// Handler that returns a simple OK response
async fn health_check() -> &'static str {
    "OK"
}

What does this code do?

  • dotenvy::dotenv().ok() reads your .env file and loads DATABASE_URL into the process environment. The .ok() silently ignores errors if the file is missing (useful in production where env vars are set externally).
  • The tracing_subscriber setup builds a layered logging system. EnvFilter lets you control log verbosity with the RUST_LOG variable. The default shows debug-level logs from your app and from tower-http.
  • PgPoolOptions::new().max_connections(5) creates a pool of reusable database connections so your server does not open a new TCP connection for every request.
  • Router::new().route("/health", get(health_check)) registers the health_check function to respond to GET /health. Axum routes requests to handler functions based on the HTTP method and path.
  • axum::serve(listener, app) begins accepting connections on port 3000 and dispatches them to the router.
  • Save src/main.rs.

Seeing errors from rust-analyzer?

rust-analyzer needs to download and index the new crates before it can resolve imports. Wait 30-60 seconds after saving Cargo.toml for the analysis to finish. You will see a progress indicator in the VS Code status bar.

If errors persist, make sure your Cargo.toml is saved and has no typos in feature names.

Ask for help: rust-analyzer is showing unresolved import errors in my main.rs after adding Axum and SQLx dependencies.

✔️ Awesome, I've got everything!

Great. Make sure you have saved both files before moving on.

ⓧ I'd like to double check the full code

use axum::{routing::get, Router};
use sqlx::postgres::PgPoolOptions;
use std::net::SocketAddr;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

#[tokio::main]
async fn main() {
    dotenvy::dotenv().ok();

    tracing_subscriber::registry()
        .with(tracing_subscriber::EnvFilter::new(
            std::env::var("RUST_LOG").unwrap_or_else(|_| "task_api=debug,tower_http=debug".into()),
        ))
        .with(tracing_subscriber::fmt::layer())
        .init();

    let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");

    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect(&database_url)
        .await
        .expect("Failed to connect to database");

    let app = Router::new().route("/health", get(health_check));

    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    tracing::info!("Listening on {}", addr);
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn health_check() -> &'static str {
    "OK"
}
Start the server and verify the health check

Time to compile and run the project. The first build downloads and compiles all dependencies, so it will take a few minutes. Subsequent builds are much faster.

  • In your terminal (make sure you are inside the task-api folder), start the server by running:
cargo run

Why does the first build take so long?

Cargo downloads every dependency crate and compiles them from source. With libraries like Axum, Tokio, and SQLx this adds up to hundreds of crates. After the first build, Cargo caches compiled artifacts and only recompiles what changes.

  • Wait for compilation to finish. You should see output ending with a line like:
Listening on 127.0.0.1:3000
  • Open a second terminal window. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) to open the VS Code Command Palette.
  • Type Terminal: Create New Terminal and press Enter.
  • In the new terminal, test the health endpoint by running:
curl http://localhost:3000/health
  • You should see OK printed in the terminal. This confirms your Axum server is running and connected to the database.

Connection refused or server not starting?

  • If cargo run panics with "Failed to connect to database", make sure your PostgreSQL container is still running. Run docker compose up -d in the task-api folder to restart it.
  • If curl returns "Connection refused", double-check that the server finished compiling and you see the "Listening" log line in the first terminal.
  • If the build fails with unresolved imports, revisit Cargo.toml and make sure the dependency names and feature flags match exactly.

Ask for help: My server will not start or curl returns an error when I try to hit the health endpoint.

Your API skeleton is alive. Next up, you will create the database schema and build the data models that represent tasks.

Create the Database Layer and Models

Your API skeleton is running and responding to health checks. Now it needs somewhere to store data.

In this step, you will create a PostgreSQL table for tasks using a migration, define Rust structs that map to database rows, and build a repository module that handles all SQL queries.

In this step, get ready to:
  • Create and run a database migration for the tasks table.
  • Define Rust model structs with serde and SQLx derives.
  • Implement a repository module with compile-time checked CRUD queries.
Create the tasks table migration

SQLx uses a migration system to manage database schema changes. Each migration is a .sql file with a timestamp prefix, so they always run in order.

  • Generate a new migration file by running this command in your terminal (make sure you are inside the task-api folder):
sqlx migrate add create_tasks_table

What does this command do?

The sqlx migrate add command creates a new file inside a migrations/ folder at your project root. The filename is prefixed with a timestamp (like 20260616120000_create_tasks_table.sql) so SQLx knows the order to run them.

  • You should see a new migrations/ folder appear in your project. Confirm by running:
ls migrations/
  • You should see a file like YYYYMMDDHHMMSS_create_tasks_table.sql in the output. The timestamp portion will be different for you.
  • Open that migration file in VS Code and replace its contents with the following SQL:
CREATE TYPE task_status AS ENUM ('todo', 'in_progress', 'done');

CREATE TABLE tasks (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title TEXT NOT NULL,
    description TEXT,
    status task_status NOT NULL DEFAULT 'todo',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

What does this SQL do?

First, it creates a custom PostgreSQL ENUM type called task_status with three allowed values. This enforces valid states at the database level.

Then it creates the tasks table. Each task gets an auto-generated UUID as its primary key, a required title, an optional description, a status defaulting to todo, and automatic timestamps.

  • Save the migration file.
  • Run the migration against your database by running:
sqlx migrate run
  • You should see output confirming the migration was applied.

Migration failing?

  • Make sure your PostgreSQL Docker container is running. Run docker compose up -d from the task-api folder to restart it.
  • Check that your .env file has the correct DATABASE_URL value. It should be postgres://postgres:postgres@localhost:5432/taskdb.
  • If you see a syntax error, double check that the SQL in your migration file matches the code above exactly.

Help me debug why my SQLx migration is not running against my PostgreSQL database.

Define the Task model and DTOs

Rust does not have a built-in ORM. Instead, you define structs that represent database rows and request bodies. SQLx maps query results directly onto your struct fields at compile time.

  • Create a new file called src/models.rs in your project and add the following code:
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
use validator::Validate;

// Represents a full task row from the database
#[derive(Debug, Serialize, Deserialize, FromRow, Clone)]
pub struct Task {
    pub id: Uuid,
    pub title: String,
    pub description: Option<String>,
    pub status: String,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

// DTO for creating a new task (only required fields)
#[derive(Debug, Deserialize, Validate)]
pub struct CreateTask {
    #[validate(length(min = 1, message = "Title must not be empty"))]
    pub title: String,
    pub description: Option<String>,
}

// DTO for updating an existing task (all fields optional for partial updates)
#[derive(Debug, Deserialize, Validate)]
pub struct UpdateTask {
    #[validate(length(min = 1, message = "Title must not be empty"))]
    pub title: Option<String>,
    pub description: Option<String>,
    pub status: Option<String>,
}

What does this code do?

  • The Task struct represents a full database row. Serialize lets serde convert it to JSON for API responses. FromRow lets SQLx build it directly from a query result.
  • The status field is a String rather than a Rust enum. We cast the PostgreSQL enum to text in our queries so SQLx can handle it without a custom type implementation.
  • The CreateTask and UpdateTask structs are DTOs (Data Transfer Objects) for incoming request bodies. The #[validate] attribute ensures the title is never empty.
  • Fields wrapped in Option<String> are nullable. For UpdateTask, all fields are optional because a PATCH-style update only changes what the client sends.
  • Save src/models.rs.
  • Check that rust-analyzer shows no red underlines in the file. If you see errors on the import lines, make sure your Cargo.toml includes the chrono, uuid, and validator dependencies from the previous step.

Seeing unresolved import errors?

  • Rust-analyzer sometimes needs a moment to download and index new crates. Wait 10-20 seconds after saving Cargo.toml for the first time.
  • If errors persist, open the VS Code command palette with Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) and run rust-analyzer: Restart Server.

Help me fix unresolved import errors in my Rust models file.

✔️ Awesome, I've got everything!

Great. Make sure you have saved src/models.rs before moving on.

ⓧ I'd like to double check the full code

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
use validator::Validate;

#[derive(Debug, Serialize, Deserialize, FromRow, Clone)]
pub struct Task {
    pub id: Uuid,
    pub title: String,
    pub description: Option<String>,
    pub status: String,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

#[derive(Debug, Deserialize, Validate)]
pub struct CreateTask {
    #[validate(length(min = 1, message = "Title must not be empty"))]
    pub title: String,
    pub description: Option<String>,
}

#[derive(Debug, Deserialize, Validate)]
pub struct UpdateTask {
    #[validate(length(min = 1, message = "Title must not be empty"))]
    pub title: Option<String>,
    pub description: Option<String>,
    pub status: Option<String>,
}
Implement the repository module

The repository pattern isolates all database queries in one module. Your handlers will never write SQL directly. They call repository methods instead.

  • Create a new file called src/repository.rs and add the following code:
use crate::models::{CreateTask, Task, UpdateTask};
use chrono::Utc;
use sqlx::PgPool;
use uuid::Uuid;

// Encapsulates all database operations for tasks
pub struct TaskRepository {
    pool: PgPool,
}

impl TaskRepository {
    pub fn new(pool: PgPool) -> Self {
        Self { pool }
    }

    // Fetch all tasks ordered by newest first
    pub async fn list_tasks(&self) -> Result<Vec<Task>, sqlx::Error> {
        let tasks = sqlx::query_as!(
            Task,
            "SELECT id, title, description, status::TEXT as \"status!\", created_at, updated_at FROM tasks ORDER BY created_at DESC"
        )
        .fetch_all(&self.pool)
        .await?;
        Ok(tasks)
    }

    // Fetch a single task by its UUID
    pub async fn get_task_by_id(&self, id: Uuid) -> Result<Option<Task>, sqlx::Error> {
        let task = sqlx::query_as!(
            Task,
            "SELECT id, title, description, status::TEXT as \"status!\", created_at, updated_at FROM tasks WHERE id = $1",
            id
        )
        .fetch_optional(&self.pool)
        .await?;
        Ok(task)
    }

    // Insert a new task and return the created row
    pub async fn create_task(&self, dto: CreateTask) -> Result<Task, sqlx::Error> {
        let task = sqlx::query_as!(
            Task,
            "INSERT INTO tasks (title, description) VALUES ($1, $2) RETURNING id, title, description, status::TEXT as \"status!\", created_at, updated_at",
            dto.title,
            dto.description
        )
        .fetch_one(&self.pool)
        .await?;
        Ok(task)
    }

    // Update an existing task with partial data
    pub async fn update_task(&self, id: Uuid, dto: UpdateTask) -> Result<Option<Task>, sqlx::Error> {
        let existing = self.get_task_by_id(id).await?;
        if existing.is_none() {
            return Ok(None);
        }
        let existing = existing.unwrap();
        let title = dto.title.unwrap_or(existing.title);
        let description = dto.description.or(existing.description);
        let status = dto.status.unwrap_or(existing.status);
        let task = sqlx::query_as!(
            Task,
            "UPDATE tasks SET title = $1, description = $2, status = $3::task_status, updated_at = $4 WHERE id = $5 RETURNING id, title, description, status::TEXT as \"status!\", created_at, updated_at",
            title,
            description,
            status,
            Utc::now(),
            id
        )
        .fetch_optional(&self.pool)
        .await?;
        Ok(task)
    }

    // Delete a task and return whether a row was actually removed
    pub async fn delete_task(&self, id: Uuid) -> Result<bool, sqlx::Error> {
        let result = sqlx::query!("DELETE FROM tasks WHERE id = $1", id)
            .execute(&self.pool)
            .await?;
        Ok(result.rows_affected() > 0)
    }
}

What does this code do?

  • The TaskRepository struct holds a PgPool (a connection pool). Each method borrows &self to access the pool without taking ownership of it.
  • The query_as! macro checks your SQL against the live database schema at compile time. If a column name or type is wrong, the compiler tells you before you ever run the code.
  • The status::TEXT as "status!" pattern casts the PostgreSQL enum to a plain text string. The ! suffix tells SQLx this column is never NULL, which matches the NOT NULL constraint.
  • The update_task method fetches the existing row first, then merges only the fields the client sent. This gives you PATCH-style partial updates without overwriting unchanged fields with NULL.
  • Save src/repository.rs.

✔️ Awesome, I've got everything!

Great. Make sure you have saved src/repository.rs before moving on.

ⓧ I'd like to double check the full code

use crate::models::{CreateTask, Task, UpdateTask};
use chrono::Utc;
use sqlx::PgPool;
use uuid::Uuid;

pub struct TaskRepository {
    pool: PgPool,
}

impl TaskRepository {
    pub fn new(pool: PgPool) -> Self {
        Self { pool }
    }

    pub async fn list_tasks(&self) -> Result<Vec<Task>, sqlx::Error> {
        let tasks = sqlx::query_as!(
            Task,
            "SELECT id, title, description, status::TEXT as \"status!\", created_at, updated_at FROM tasks ORDER BY created_at DESC"
        )
        .fetch_all(&self.pool)
        .await?;
        Ok(tasks)
    }

    pub async fn get_task_by_id(&self, id: Uuid) -> Result<Option<Task>, sqlx::Error> {
        let task = sqlx::query_as!(
            Task,
            "SELECT id, title, description, status::TEXT as \"status!\", created_at, updated_at FROM tasks WHERE id = $1",
            id
        )
        .fetch_optional(&self.pool)
        .await?;
        Ok(task)
    }

    pub async fn create_task(&self, dto: CreateTask) -> Result<Task, sqlx::Error> {
        let task = sqlx::query_as!(
            Task,
            "INSERT INTO tasks (title, description) VALUES ($1, $2) RETURNING id, title, description, status::TEXT as \"status!\", created_at, updated_at",
            dto.title,
            dto.description
        )
        .fetch_one(&self.pool)
        .await?;
        Ok(task)
    }

    pub async fn update_task(&self, id: Uuid, dto: UpdateTask) -> Result<Option<Task>, sqlx::Error> {
        let existing = self.get_task_by_id(id).await?;
        if existing.is_none() {
            return Ok(None);
        }
        let existing = existing.unwrap();
        let title = dto.title.unwrap_or(existing.title);
        let description = dto.description.or(existing.description);
        let status = dto.status.unwrap_or(existing.status);
        let task = sqlx::query_as!(
            Task,
            "UPDATE tasks SET title = $1, description = $2, status = $3::task_status, updated_at = $4 WHERE id = $5 RETURNING id, title, description, status::TEXT as \"status!\", created_at, updated_at",
            title,
            description,
            status,
            Utc::now(),
            id
        )
        .fetch_optional(&self.pool)
        .await?;
        Ok(task)
    }

    pub async fn delete_task(&self, id: Uuid) -> Result<bool, sqlx::Error> {
        let result = sqlx::query!("DELETE FROM tasks WHERE id = $1", id)
            .execute(&self.pool)
            .await?;
        Ok(result.rows_affected() > 0)
    }
}

Now you need to tell Rust about these two new modules.

  • In src/main.rs, find the very top of the file (above the use statements).
  • Add these two module declarations as the first lines:
mod models;
mod repository;

What do these lines do?

In Rust, each .rs file is a module, but the compiler only includes modules that are declared with mod. These two lines tell the compiler to include src/models.rs and src/repository.rs as part of your crate.

  • Save all files.
  • Verify everything compiles by running:
cargo build
  • You should see Finished in the output with no errors. You may see warnings about unused code. That is expected because nothing calls the repository methods yet.

Why does cargo build need the database running?

The query_as! macro connects to your PostgreSQL database at compile time to verify that your SQL is valid and that column types match your Rust struct fields. Your .env file provides the DATABASE_URL that the macro reads during compilation.

This means your Docker PostgreSQL container must be running whenever you run cargo build. The payoff is that invalid SQL becomes a compile error, not a runtime crash.

Build failing with DATABASE_URL errors?

  • If you see an error mentioning DATABASE_URL or cargo sqlx prepare, your database container is likely stopped. Run docker compose up -d from the task-api folder.
  • If you see type mismatch errors, double check that your migration has been applied. Run sqlx migrate run again to confirm.
  • Make sure the .env file is in the task-api root (the same folder as Cargo.toml).

Help me debug why cargo build is failing with database connection errors.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: src/main.rs, src/models.rs, and src/repository.rs.

ⓧ I'd like to double check the full code

Here is src/main.rs with the new module declarations:

mod models;
mod repository;

use axum::{routing::get, Router};
use sqlx::postgres::PgPoolOptions;
use std::net::SocketAddr;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

#[tokio::main]
async fn main() {
    dotenvy::dotenv().ok();

    tracing_subscriber::registry()
        .with(tracing_subscriber::EnvFilter::new(
            std::env::var("RUST_LOG").unwrap_or_else(|_| "task_api=debug,tower_http=debug".into()),
        ))
        .with(tracing_subscriber::fmt::layer())
        .init();

    let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");

    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect(&database_url)
        .await
        .expect("Failed to connect to database");

    let app = Router::new().route("/health", get(health_check));

    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    tracing::info!("Listening on {}", addr);
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn health_check() -> &'static str {
    "OK"
}

Your database layer is complete. You have a migration that creates the tasks table, model structs that represent the data, and a repository that handles all SQL queries with compile-time safety. Next up, you will wire these into handlers and expose them as REST endpoints.

Build Handlers, Services, and Routes

Your database layer is ready. The repository knows how to talk to PostgreSQL, but nothing is listening for HTTP requests yet.

In this step, you will build the remaining layers of the API. A service layer sits between your handlers and repository, owning the business logic. Handlers extract data from incoming HTTP requests and return responses. Finally, the router maps URL paths to the correct handler function.

In this step, get ready to:
  • Create a service layer that delegates to the repository.
  • Write handler functions that parse requests and return JSON responses.
  • Wire up RESTful routes with request logging middleware.
Create the service layer

The service layer sits between handlers and the repository. It owns the database pool (wrapped in Arc for safe sharing across threads) and exposes methods matching each use case.

Why a service layer between handlers and repositories?

In a small project you could call the repository directly from handlers. But as your app grows, business logic (authorization checks, event publishing, multi-step transactions) needs a home that is not tied to HTTP. The service layer provides that home.

This pattern is called layered architecture. Each layer has one job and talks only to the layer below it.

  • Create a new file src/service.rs in the task-api project by right-clicking the src folder in the VS Code Explorer sidebar and selecting New File.
  • Add the following code to src/service.rs:
use crate::models::{CreateTask, Task, UpdateTask};
use crate::repository::TaskRepository;
use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid;

// Derive Clone so Axum can share this across request handlers
#[derive(Clone)]
pub struct TaskService {
    repo: Arc<TaskRepository>,
}

impl TaskService {
    // Wrap the repository in Arc for thread-safe shared ownership
    pub fn new(pool: PgPool) -> Self {
        Self {
            repo: Arc::new(TaskRepository::new(pool)),
        }
    }

    // Each method delegates to the repository
    pub async fn list_tasks(&self) -> Result<Vec<Task>, sqlx::Error> {
        self.repo.list_tasks().await
    }

    pub async fn get_task(&self, id: Uuid) -> Result<Option<Task>, sqlx::Error> {
        self.repo.get_task_by_id(id).await
    }

    pub async fn create_task(&self, dto: CreateTask) -> Result<Task, sqlx::Error> {
        self.repo.create_task(dto).await
    }

    pub async fn update_task(&self, id: Uuid, dto: UpdateTask) -> Result<Option<Task>, sqlx::Error> {
        self.repo.update_task(id, dto).await
    }

    pub async fn delete_task(&self, id: Uuid) -> Result<bool, sqlx::Error> {
        self.repo.delete_task(id).await
    }
}

What does this code do?

  • TaskService derives Clone because Axum requires shared state to be cloneable across request threads.
  • Arc<TaskRepository> gives multiple clones of TaskService shared access to a single repository instance without copying it.
  • Each public method is a thin wrapper that calls the corresponding repository function. Business logic (validation, authorization) would go here in a larger application.
  • Save src/service.rs.

You will not see a visible change yet. The next substep (handlers) uses this service.

Seeing red squiggles from rust-analyzer?

The file will not compile on its own because main.rs does not declare mod service yet. This is expected. The red underlines disappear once you update main.rs in the final substep.

help me understand why rust-analyzer shows errors in my service.rs file

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before continuing.

ⓧ I'd like to double check the full code

use crate::models::{CreateTask, Task, UpdateTask};
use crate::repository::TaskRepository;
use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid;

#[derive(Clone)]
pub struct TaskService {
    repo: Arc<TaskRepository>,
}

impl TaskService {
    pub fn new(pool: PgPool) -> Self {
        Self {
            repo: Arc::new(TaskRepository::new(pool)),
        }
    }

    pub async fn list_tasks(&self) -> Result<Vec<Task>, sqlx::Error> {
        self.repo.list_tasks().await
    }

    pub async fn get_task(&self, id: Uuid) -> Result<Option<Task>, sqlx::Error> {
        self.repo.get_task_by_id(id).await
    }

    pub async fn create_task(&self, dto: CreateTask) -> Result<Task, sqlx::Error> {
        self.repo.create_task(dto).await
    }

    pub async fn update_task(&self, id: Uuid, dto: UpdateTask) -> Result<Option<Task>, sqlx::Error> {
        self.repo.update_task(id, dto).await
    }

    pub async fn delete_task(&self, id: Uuid) -> Result<bool, sqlx::Error> {
        self.repo.delete_task(id).await
    }
}
Create the handler functions

Handlers are the HTTP boundary of your API. Each handler extracts data from the incoming request (JSON body, path parameters), calls the service, and returns an HTTP response with the correct status code.

  • Create a new file src/handlers.rs in the src folder using the same right-click method.
  • Add the following code to src/handlers.rs:
use crate::models::{CreateTask, UpdateTask};
use crate::service::TaskService;
use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::IntoResponse,
    Json,
};
use uuid::Uuid;

// List all tasks - GET /tasks
pub async fn list_tasks_handler(State(service): State<TaskService>) -> impl IntoResponse {
    match service.list_tasks().await {
        Ok(tasks) => (StatusCode::OK, Json(tasks)).into_response(),
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
    }
}

// Get a single task by ID - GET /tasks/:id
pub async fn get_task_handler(
    State(service): State<TaskService>,
    Path(id): Path<Uuid>,
) -> impl IntoResponse {
    match service.get_task(id).await {
        Ok(Some(task)) => (StatusCode::OK, Json(task)).into_response(),
        Ok(None) => StatusCode::NOT_FOUND.into_response(),
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
    }
}

// Create a new task - POST /tasks
pub async fn create_task_handler(
    State(service): State<TaskService>,
    Json(dto): Json<CreateTask>,
) -> impl IntoResponse {
    match service.create_task(dto).await {
        Ok(task) => (StatusCode::CREATED, Json(task)).into_response(),
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
    }
}

// Update an existing task - PUT /tasks/:id
pub async fn update_task_handler(
    State(service): State<TaskService>,
    Path(id): Path<Uuid>,
    Json(dto): Json<UpdateTask>,
) -> impl IntoResponse {
    match service.update_task(id, dto).await {
        Ok(Some(task)) => (StatusCode::OK, Json(task)).into_response(),
        Ok(None) => StatusCode::NOT_FOUND.into_response(),
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
    }
}

// Delete a task - DELETE /tasks/:id
pub async fn delete_task_handler(
    State(service): State<TaskService>,
    Path(id): Path<Uuid>,
) -> impl IntoResponse {
    match service.delete_task(id).await {
        Ok(true) => StatusCode::NO_CONTENT.into_response(),
        Ok(false) => StatusCode::NOT_FOUND.into_response(),
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
    }
}

What does this code do?

  • State(service): State<TaskService> extracts the shared TaskService from Axum's application state. Every handler gets its own clone.
  • Path(id): Path<Uuid> parses the :id segment from the URL into a typed Uuid. Axum rejects the request automatically if the segment is not a valid UUID.
  • Json(dto): Json<CreateTask> deserializes the request body into your DTO struct. If the JSON is malformed, Axum returns a 400 before your handler runs.
  • Each handler uses match on the service result and maps Ok/Err variants to appropriate HTTP status codes (200, 201, 204, 404, 500).

The impl IntoResponse return type lets each match arm return a different response shape while still satisfying Rust's type system.

  • Save src/handlers.rs.

You will not see a change yet. The router in main.rs connects everything in the next substep.

Confused by the State extractor pattern?

Axum's State extractor uses destructuring syntax. State(service) unwraps the inner value from the State wrapper so you can call methods on service directly.

help me understand Axum's State extractor pattern

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before continuing.

ⓧ I'd like to double check the full code

use crate::models::{CreateTask, UpdateTask};
use crate::service::TaskService;
use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::IntoResponse,
    Json,
};
use uuid::Uuid;

pub async fn list_tasks_handler(State(service): State<TaskService>) -> impl IntoResponse {
    match service.list_tasks().await {
        Ok(tasks) => (StatusCode::OK, Json(tasks)).into_response(),
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
    }
}

pub async fn get_task_handler(
    State(service): State<TaskService>,
    Path(id): Path<Uuid>,
) -> impl IntoResponse {
    match service.get_task(id).await {
        Ok(Some(task)) => (StatusCode::OK, Json(task)).into_response(),
        Ok(None) => StatusCode::NOT_FOUND.into_response(),
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
    }
}

pub async fn create_task_handler(
    State(service): State<TaskService>,
    Json(dto): Json<CreateTask>,
) -> impl IntoResponse {
    match service.create_task(dto).await {
        Ok(task) => (StatusCode::CREATED, Json(task)).into_response(),
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
    }
}

pub async fn update_task_handler(
    State(service): State<TaskService>,
    Path(id): Path<Uuid>,
    Json(dto): Json<UpdateTask>,
) -> impl IntoResponse {
    match service.update_task(id, dto).await {
        Ok(Some(task)) => (StatusCode::OK, Json(task)).into_response(),
        Ok(None) => StatusCode::NOT_FOUND.into_response(),
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
    }
}

pub async fn delete_task_handler(
    State(service): State<TaskService>,
    Path(id): Path<Uuid>,
) -> impl IntoResponse {
    match service.delete_task(id).await {
        Ok(true) => StatusCode::NO_CONTENT.into_response(),
        Ok(false) => StatusCode::NOT_FOUND.into_response(),
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
    }
}
Wire up the router with RESTful routes

The router maps HTTP methods and paths to handler functions. You will also add Tower middleware for structured request logging.

  • Open src/main.rs and replace its entire contents with the following code:
mod handlers;
mod models;
mod repository;
mod service;

use axum::{routing::{delete, get, post, put}, Router};
use handlers::{
    create_task_handler, delete_task_handler, get_task_handler,
    list_tasks_handler, update_task_handler,
};
use service::TaskService;
use sqlx::postgres::PgPoolOptions;
use std::net::SocketAddr;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

#[tokio::main]
async fn main() {
    dotenvy::dotenv().ok();

    tracing_subscriber::registry()
        .with(tracing_subscriber::EnvFilter::new(
            std::env::var("RUST_LOG").unwrap_or_else(|_| "task_api=debug,tower_http=debug".into()),
        ))
        .with(tracing_subscriber::fmt::layer())
        .init();

    let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");

    // Create a connection pool with up to 5 connections
    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect(&database_url)
        .await
        .expect("Failed to connect to database");

    // Create the shared service from the pool
    let service = TaskService::new(pool);

    // Build the router with RESTful routes and logging middleware
    let app = Router::new()
        .route("/health", get(health_check))
        .route("/tasks", get(list_tasks_handler).post(create_task_handler))
        .route("/tasks/:id", get(get_task_handler).put(update_task_handler).delete(delete_task_handler))
        .layer(TraceLayer::new_for_http())
        .with_state(service);

    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    tracing::info!("Listening on {}", addr);
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn health_check() -> &'static str {
    "OK"
}

What changed in main.rs?

  • mod handlers and mod service register the two new modules so Rust can find them.
  • TaskService::new(pool) creates the service and hands it the database pool.
  • .route("/tasks", ...) and .route("/tasks/:id", ...) define the five CRUD endpoints on two path patterns.
  • .layer(TraceLayer::new_for_http()) adds tower-http middleware that logs every request and response automatically.
  • .with_state(service) injects TaskService as shared state so every handler can access it via the State extractor.
  • Save src/main.rs.
  • Return to your terminal (inside the task-api folder) and start the server by running:
cargo run

Wait for the compilation to finish. You should see a log line containing Listening on 127.0.0.1:3000 in the terminal output.

  • Open a second terminal tab and test the API by running:
curl -X POST http://localhost:3000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn Axum", "description": "Build a REST API"}'

You should see a JSON response containing your new task with an id, title, status set to todo, and timestamps.

  • Verify the list endpoint returns your task by running:
curl http://localhost:3000/tasks

You should see a JSON array containing the task you just created.

Seeing a compilation error?

  • If you see an error about mod service or mod handlers, make sure both src/service.rs and src/handlers.rs exist and are saved.
  • If you see a database connection error, confirm your Docker container is still running with docker compose ps in the task-api folder.
  • If the curl command returns Connection refused, wait for the Listening on log line to appear before sending requests.

help me debug a compilation or connection error in my Axum task API

✔️ Awesome, I've got everything!

Great. Make sure your server is still running for the next step.

ⓧ I'd like to double check the full code

mod handlers;
mod models;
mod repository;
mod service;

use axum::{routing::{delete, get, post, put}, Router};
use handlers::{
    create_task_handler, delete_task_handler, get_task_handler,
    list_tasks_handler, update_task_handler,
};
use service::TaskService;
use sqlx::postgres::PgPoolOptions;
use std::net::SocketAddr;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

#[tokio::main]
async fn main() {
    dotenvy::dotenv().ok();

    tracing_subscriber::registry()
        .with(tracing_subscriber::EnvFilter::new(
            std::env::var("RUST_LOG").unwrap_or_else(|_| "task_api=debug,tower_http=debug".into()),
        ))
        .with(tracing_subscriber::fmt::layer())
        .init();

    let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");

    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect(&database_url)
        .await
        .expect("Failed to connect to database");

    let service = TaskService::new(pool);

    let app = Router::new()
        .route("/health", get(health_check))
        .route("/tasks", get(list_tasks_handler).post(create_task_handler))
        .route("/tasks/:id", get(get_task_handler).put(update_task_handler).delete(delete_task_handler))
        .layer(TraceLayer::new_for_http())
        .with_state(service);

    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    tracing::info!("Listening on {}", addr);
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn health_check() -> &'static str {
    "OK"
}

Your API now handles all five CRUD operations. Next up, you will add structured error handling and input validation so the API returns helpful error messages instead of generic 500 responses.

Add Error Handling and Validation

Your API works, but its error responses are bare status codes with no useful information for the client. A POST with invalid data returns a generic 500. A GET for a missing task returns a 404 with no message explaining what went wrong.

In this step, you will build a custom error type that maps every failure to the correct HTTP status code and a structured JSON body. You will also wire up input validation so invalid requests never reach the database.

In this step, get ready to:
  • Define a custom error enum with proper HTTP status mapping.
  • Add input validation to the create and update handlers.
  • Test the error flow with invalid requests.
Define the AppError enum

Rust's type system lets you represent every possible error as an enum variant. By implementing Axum's IntoResponse trait on that enum, each variant automatically converts into the right HTTP status code and JSON body.

  • Create a new file called src/errors.rs in VS Code.
  • Add the AppError enum with its imports by pasting this code:
use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;
use thiserror::Error;

// Define all possible API errors as enum variants
#[derive(Debug, Error)]
pub enum AppError {
    #[error("Resource not found")]
    NotFound,
    #[error("Validation error: {0}")]
    ValidationError(String),
    #[error("Internal server error")]
    InternalError(#[from] sqlx::Error),
}

What does this code do?

The #[derive(Error)] macro from thiserror auto-generates the std::error::Error trait implementation. Each #[error("...")] attribute defines the human-readable message for that variant.

The #[from] attribute on InternalError generates a From<sqlx::Error> implementation automatically. This lets you use the ? operator on any database call and have the error convert into AppError::InternalError without manual mapping.

  • Below the enum, add the IntoResponse implementation that maps each variant to an HTTP response:
// Convert each error variant into an HTTP response
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match &self {
            // 404 for missing resources
            AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
            // 422 for invalid input
            AppError::ValidationError(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg.clone()),
            // 500 for unexpected database errors (hide internals from client)
            AppError::InternalError(_) => (
                StatusCode::INTERNAL_SERVER_ERROR,
                "Internal server error".to_string(),
            ),
        };
        // Return a JSON object with an "error" key
        let body = Json(json!({ "error": message }));
        (status, body).into_response()
    }
}

Why hide internal errors?

The InternalError branch returns a generic message instead of the raw database error. Leaking internal details (table names, query structure) is a security risk. The real error still gets logged via tracing for debugging.

  • Save src/errors.rs.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/errors.rs before continuing.

ⓧ I'd like to double check the full code

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum AppError {
    #[error("Resource not found")]
    NotFound,
    #[error("Validation error: {0}")]
    ValidationError(String),
    #[error("Internal server error")]
    InternalError(#[from] sqlx::Error),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match &self {
            AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
            AppError::ValidationError(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg.clone()),
            AppError::InternalError(_) => (
                StatusCode::INTERNAL_SERVER_ERROR,
                "Internal server error".to_string(),
            ),
        };
        let body = Json(json!({ "error": message }));
        (status, body).into_response()
    }
}
  • In src/main.rs, find the module declarations at the top of the file:
mod handlers;
mod models;
mod repository;
mod service;
  • Add mod errors; above mod handlers; so the block looks like this:
mod errors;
mod handlers;
mod models;
mod repository;
mod service;
  • Save src/main.rs.

Seeing a compile error about unresolved import?

Make sure you spelled the module declaration exactly as mod errors; and that your file is named errors.rs inside the src/ directory. Rust's module system expects the filename to match the module name.

help me fix a module resolution error in my Rust project

Integrate validation in the handlers

Now you will update the handler functions to return Result<impl IntoResponse, AppError> instead of bare impl IntoResponse. This lets you use the ? operator to propagate errors cleanly.

  • Replace the entire contents of src/handlers.rs with the updated imports and the first two handlers:
use crate::errors::AppError;
use crate::models::{CreateTask, UpdateTask};
use crate::service::TaskService;
use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::IntoResponse,
    Json,
};
use uuid::Uuid;
use validator::Validate;

// List all tasks, propagating database errors with ?
pub async fn list_tasks_handler(
    State(service): State<TaskService>,
) -> Result<impl IntoResponse, AppError> {
    let tasks = service.list_tasks().await?;
    Ok((StatusCode::OK, Json(tasks)))
}

// Get a single task, converting None to a 404
pub async fn get_task_handler(
    State(service): State<TaskService>,
    Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
    let task = service.get_task(id).await?.ok_or(AppError::NotFound)?;
    Ok((StatusCode::OK, Json(task)))
}

What changed here?

The return type is now Result<impl IntoResponse, AppError>. When the service returns an Err(sqlx::Error), the ? operator uses the #[from] conversion to turn it into AppError::InternalError automatically.

The .ok_or(AppError::NotFound)? chain on get_task_handler converts an Option<Task> into a Result. If the task does not exist, it short-circuits with a 404.

  • Below get_task_handler, add the create and update handlers with validation:
// Create a task after validating the input
pub async fn create_task_handler(
    State(service): State<TaskService>,
    Json(dto): Json<CreateTask>,
) -> Result<impl IntoResponse, AppError> {
    // Validate before hitting the database
    dto.validate()
        .map_err(|e| AppError::ValidationError(e.to_string()))?;
    let task = service.create_task(dto).await?;
    Ok((StatusCode::CREATED, Json(task)))
}

// Update a task after validating the input
pub async fn update_task_handler(
    State(service): State<TaskService>,
    Path(id): Path<Uuid>,
    Json(dto): Json<UpdateTask>,
) -> Result<impl IntoResponse, AppError> {
    dto.validate()
        .map_err(|e| AppError::ValidationError(e.to_string()))?;
    let task = service.update_task(id, dto).await?.ok_or(AppError::NotFound)?;
    Ok((StatusCode::OK, Json(task)))
}

How does validation work?

The dto.validate() call runs all the #[validate(...)] rules you defined on CreateTask and UpdateTask in models.rs. If the title is empty, validate() returns an Err(ValidationErrors) which gets converted to AppError::ValidationError with .map_err().

Validation happens before any database call, so invalid data never touches your PostgreSQL instance.

  • Below update_task_handler, add the delete handler:
// Delete a task, returning 404 if it doesn't exist
pub async fn delete_task_handler(
    State(service): State<TaskService>,
    Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
    let deleted = service.delete_task(id).await?;
    if deleted {
        Ok(StatusCode::NO_CONTENT.into_response())
    } else {
        Err(AppError::NotFound)
    }
}
  • Save src/handlers.rs.
  • Confirm the project compiles by running:
cargo build

You should see a successful compilation with no errors.

Seeing a type mismatch error?

The most common cause is a missing use validator::Validate; import at the top of handlers.rs. Without it, the .validate() method is not in scope.

Also check that mod errors; is declared in main.rs before mod handlers;. Module order matters for Rust's resolution.

help me fix a compile error in my Axum handlers after adding AppError

✔️ Awesome, I've got everything!

Great. Make sure cargo build succeeds before continuing.

ⓧ I'd like to double check the full code

use crate::errors::AppError;
use crate::models::{CreateTask, UpdateTask};
use crate::service::TaskService;
use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::IntoResponse,
    Json,
};
use uuid::Uuid;
use validator::Validate;

pub async fn list_tasks_handler(
    State(service): State<TaskService>,
) -> Result<impl IntoResponse, AppError> {
    let tasks = service.list_tasks().await?;
    Ok((StatusCode::OK, Json(tasks)))
}

pub async fn get_task_handler(
    State(service): State<TaskService>,
    Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
    let task = service.get_task(id).await?.ok_or(AppError::NotFound)?;
    Ok((StatusCode::OK, Json(task)))
}

pub async fn create_task_handler(
    State(service): State<TaskService>,
    Json(dto): Json<CreateTask>,
) -> Result<impl IntoResponse, AppError> {
    dto.validate()
        .map_err(|e| AppError::ValidationError(e.to_string()))?;
    let task = service.create_task(dto).await?;
    Ok((StatusCode::CREATED, Json(task)))
}

pub async fn update_task_handler(
    State(service): State<TaskService>,
    Path(id): Path<Uuid>,
    Json(dto): Json<UpdateTask>,
) -> Result<impl IntoResponse, AppError> {
    dto.validate()
        .map_err(|e| AppError::ValidationError(e.to_string()))?;
    let task = service.update_task(id, dto).await?.ok_or(AppError::NotFound)?;
    Ok((StatusCode::OK, Json(task)))
}

pub async fn delete_task_handler(
    State(service): State<TaskService>,
    Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
    let deleted = service.delete_task(id).await?;
    if deleted {
        Ok(StatusCode::NO_CONTENT.into_response())
    } else {
        Err(AppError::NotFound)
    }
}
Test the full error flow

Time to verify that invalid requests produce structured JSON error responses with the correct status codes.

  • Start the server by running:
cargo run
  • Open a second terminal and send an invalid POST request with an empty title:
curl -s -w "\nHTTP Status: %{http_code}\n" -X POST http://localhost:3000/tasks -H "Content-Type: application/json" -d '{"title": ""}'

You should see a 422 status code with a JSON error body containing a validation message about the title.

  • Send a GET request for a non-existent UUID:
curl -s -w "\nHTTP Status: %{http_code}\n" http://localhost:3000/tasks/00000000-0000-0000-0000-000000000000

You should see a 404 status code with a JSON body containing {"error":"Resource not found"}.

What should I see?

The validation error response looks like {"error":"Validation error: ..."} with an HTTP 422 status. The not-found response looks like {"error":"Resource not found"} with an HTTP 404 status. Both return structured JSON instead of empty bodies.

Getting a connection refused error?

Make sure cargo run completed startup in the first terminal. Look for the Listening on 127.0.0.1:3000 log line before sending curl requests from the second terminal.

help me debug why my Axum server isn't responding to curl requests

Your API now speaks a consistent error language. Every failure path returns structured JSON with the right status code. Next up, you will extend this foundation with pagination and filtering in the Secret Mission.

Secret mission

Add Pagination and Filtering

Your API currently dumps every task in a single response. Real-world APIs serve paginated results with optional filters. In this mission, you'll add page, per_page, and status query parameters to the list endpoint, backed by SQLx's QueryBuilder for dynamic SQL construction.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with Docker, so there are no ongoing cloud costs.

Resources you used:

  • Docker container running PostgreSQL (free, local).
  • The task-api project folder with your Rust source code and compiled binary.
  • The taskdb database and its data (stored inside the Docker volume).

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with your API.

  • Your PostgreSQL container stays running and available on port 5432.
  • You can restart your API at any time with cargo run from inside the task-api folder.
  • Docker Desktop will use some memory while the container is active.

Pause - I'll come back to this later

Shut down the running containers to free up memory. Your data and project files stay intact.

  • Stop the running API server by pressing Ctrl+C in the terminal where it's running.
  • Stop the PostgreSQL container by running this command from your task-api folder:
docker compose stop
  • When you're ready to resume, run docker compose start from the same folder. Your data will still be there.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes the Docker container, its volumes, and your local project files.

  • Stop the running API server by pressing Ctrl+C in the terminal where it's running.
  • Remove the PostgreSQL container and all its data by running this from your task-api folder:
docker compose down -v
  • Navigate up to the parent directory and delete the project folder.
  • On macOS/Linux:
cd ..
rm -rf task-api
  • On Windows:
cd ..
rmdir /s /q task-api
  • Optionally, uninstall sqlx-cli if you no longer need it:
cargo uninstall sqlx-cli

That's a Wrap!

That's a Wrap!

Nice work. You just built a production-style REST API in Rust from scratch, complete with typed routing, compile-time checked database queries, and structured error handling.

You've learned how to:

  • Build a REST API with Axum 0.8.9 using typed routing, extractors, and a layered handler/service/repository architecture with shared state via Arc.
  • Connect to PostgreSQL using SQLx 0.9.0 with compile-time checked queries, input validation via the validator crate, and custom error handling with thiserror and Axum's IntoResponse trait.
  • Add structured request logging with tracing and tower-http's TraceLayer, plus environment-based configuration with dotenvy and Docker-managed infrastructure.
  • Secret Mission: Add pagination with LIMIT/OFFSET and optional status filtering using SQLx's QueryBuilder.

Ready to quiz yourself?