Build a Simple Single-File Rust Web API

Essay - Published: 2026.03.18 | 7 min read (1,980 words)
build | create | rust

DISCLOSURE: If you buy through affiliate links, I may earn a small commission. (disclosures)

I've spent the last few months learning Rust as part of my 12 week programming retreat at Recurse Center. I came to Rust because it scored well in my missing programming language analysis and I heard rumors that it worked well as a high-level language.

Now a few months later, I can confidently say that Rust is a good high-level language if you stick to a few rules - see: High-Level Rust.

In this post we'll walk through a simple, single-file webapi written in Rust to help demonstrate some of these principles.

What We're Building

In this post we'll build a simple CRUD todo list API all in a single file:

  • 6 routes
  • ~175 lines
  • 1 file

The routes:

  • / - Returns Hello, World!
  • /todos
    • Get - Lists todos
    • Post - Creates a todo
  • /todos/{id}
    • Get - Gets the todo
    • Delete - Deletes the todo
  • /todos/{id}/complete Post - Completes the todo

Tech stack:

  • Rust - Has great community, ecosystem, performance, and expressive types though tends to score poorly on devx. High-Level Rust largely fixes that for app-layer builds.
  • Axum - The most popular Rust web framework, battle-tested and built by the Tokio team.
  • LightClone - A derive macro I built for enforcing cheap clones at compile time. Rust clones can be sneakily expensive compared to GC languages so this helps catch this at compile time.
  • parking_lot - A faster Mutex without poisoning - which basically just means it's a bit more ergonomic to work with than the standard library's mutex (and slightly faster, too).
  • UUID v7 - Gives us time-ordered unique IDs which can be useful for sorting on keys.
  • serde + serde_json - for JSON serialization / deserialization.
  • tokio - for async runtime. It's now the standard async runtime in Rust.

Approach: High-Level Rust.

  • Type-first domain modeling - leverage enums and types to precisely model the domain and make invalid states unrepresentable.
  • DDD + dependency injection - Use traits to pass in dependencies for dependency injection to make it easy to compose and test.
  • Functionalish logic - Immutable by default and prefer clones to direct mutations when it makes sense, using LightClone to enforce cheap clones at compile time

The result is code that reads like a high-level language (C#, Go) but gets most of the benefits of Rust - expressive types, fast native binary, excellent tooling, and growing community.

How it works

Data Models

With our type-first modeling approach, we should start with the types to describe our domain.

This is a simple todo list api so all it has are:

  • Todo - Represents a todo with an id, title, and whether it's completed or not
  • CreateTodo - A command for creating a todo that just has a title
#[derive(Clone, Serialize, LightClone)]
struct Todo {
    id: Uuid,
    title: Arc<str>,
    completed: bool,
}

#[derive(Deserialize)]
struct CreateTodo {
    title: String,
}

Todo:

  • Derives Clone so it's cloneable and LightClone so that we enforce its clones are cheap. This is why it uses an Arc<str> instead of a String - it uses a reference to a string so that clones are cheap (just copy the reference).
  • Serialize so serde handles serialization

CreateTodo

  • Deserialize so that serde handles deserialization
  • Note that we just keep a regular String here as we don't expect to need to clone this model

Error Handling

Error handling is a bit overkill for a simple app like this but I like to build most apps with enumerated error types as it really helps as systems grow and systems tend to grow vs shrink. I'm a fan of using Result types throughout my systems so that each layer understands the expect success and failure cases which helps with composability and correctness.

But this is still a simple app so we only really have one error type - a NotFound error.

We then implement the IntoResponse trait on TodoError so that Axum knows how it should turn that into a status code.

enum TodoError {
    NotFound(Uuid),
}

impl IntoResponse for TodoError {
    fn into_response(self) -> axum::response::Response {
        match self {
            TodoError::NotFound(id) => (
                StatusCode::NOT_FOUND,
                Json(serde_json::json!({ "error": format!("Todo {id} not found") })),
            )
                .into_response(),
        }
    }
}

Service Trait

In order to power our todo list app, we need a service that will actually do the CRUD actions. We're using a High-Level Rust approach so we're going to be using a trait to provide a type-first definition of the service before implementing it.

Our trait looks like this:

trait TodoService: Send + Sync {
    fn list(&self) -> Vec<Todo>;
    fn get(&self, id: Uuid) -> Result<Todo, TodoError>;
    fn create(&self, input: CreateTodo) -> Todo;
    fn complete(&self, id: Uuid) -> Result<Todo, TodoError>;
    fn delete(&self, id: Uuid) -> Result<Todo, TodoError>;
}

Some notes:

  • This pattern uses traditional Dependency Injection with interfaces. This is useful for swapping different implementations (in-memory vs Postgres) or more commonly for testing (real vs mock1 vs mock2).
  • Send + Sync is used to mark this service as thread safe. Axum handlers run across multiple threads via tokio so the compiler will error if any shared state is not safe to access from multiple threads. Send = safe to transfer between threads, Sync = safe to reference from multiple threads.

Having a trait like this is overkill for a single-file example but similar to errors I find that building apps like this from the start makes them much easier to scale when you do eventually need them.

Service Implementation

Now onto the implementation of our Service Trait with the InMemoryTodoService. We're doing in memory data store because it's simple and works for our usecase. For a real app you'll probably use a DB for storing data.

First we define the data InMemoryTodoService needs:

struct InMemoryTodoService {
    store: Mutex<Vec<Todo>>,
}

impl InMemoryTodoService {
    fn new() -> Self {
        Self {
            store: Mutex::new(Vec::new()),
        }
    }
}

This basically says we have a store which contains a list of Todos with vec<Todo>. We could use a Hashmap as well but doesn't matter for our example.

What is worth diving into is the usage of the Mutex. Vec<Todo> is not Sync by itself as it's not safe to share that data across threads - if multiple are mutating them at the same time, you get data races. Mutex<Vec<Todo>> IS Sync as it is safe to reference from multiple threads.

So here the compiler is forcing us to handle a possible data race condition. So it is noisier than code you may see in other languages but it does solve a very real potential bug.

impl TodoService for InMemoryTodoService {
    fn list(&self) -> Vec<Todo> {
        let store = self.store.lock();
        store.clone()
    }

    fn get(&self, id: Uuid) -> Result<Todo, TodoError> {
        let store = self.store.lock();
        store
            .iter()
            .find(|t| t.id == id)
            .cloned()
            .ok_or(TodoError::NotFound(id))
    }

    fn create(&self, input: CreateTodo) -> Todo {
        let mut store = self.store.lock();
        let todo = Todo {
            id: Uuid::now_v7(),
            title: input.title.into(),
            completed: false,
        };
        store.push(todo.lc());
        todo
    }

    fn complete(&self, id: Uuid) -> Result<Todo, TodoError> {
        let mut store = self.store.lock();
        let pos = store
            .iter()
            .position(|t| t.id == id)
            .ok_or(TodoError::NotFound(id))?;
        let completed = Todo {
            completed: true,
            ..store[pos].lc()
        };
        store[pos] = completed.lc();
        Ok(completed)
    }

    fn delete(&self, id: Uuid) -> Result<Todo, TodoError> {
        let mut store = self.store.lock();
        let pos = store
            .iter()
            .position(|t| t.id == id)
            .ok_or(TodoError::NotFound(id))?;
        Ok(store.remove(pos))
    }
}

The rest of this code is just implementation of our CRUD methods:

  • list - Grabs the mutex, returns the cloned store, and (implicitly) releases the mutex when the store goes out of scope
  • get - Grabs the mutex, filters the store to where the id matches the id param, clones it, and either returns Ok(cloned) or our TodoError if it wasn't found
  • create - Grabs the mutex, creates the Todo, adds it to the store, and returns the todo
  • complete - Grabs the mutex, finds the todo by id or returns NotFound error, clones the todo with completed = true, stores it back in the store, and returns Ok with the new value
  • delete - Grabs the mutex, finds the todo by id or returns NotFound error, and removes it from the store

Some of this looks a tad complicated but if you look at the chained functions similar to pipes in F#, LINQ in C#, or map / filter in JS / TS then it should look familiar.

Routing + API Handlers

Now that we have the core service implementation built, we need a way to route incoming http requests to the appropriate service implementation. For that, we define handlers which basically map incoming http requests to the service implementations and then spin up a router which maps routes to the appropriate handlers.

We spin up our InMemoryTodoService and pass it into our router as state which it will pass to each handler and it to each service call - effectively doing app composition at the root of our app.

We then spin up a listener on port 3000 and tell axum to serve the app.

async fn hello() -> &'static str {
    "Hello, World!"
}

async fn list_todos(State(service): State<Arc<dyn TodoService>>) -> Json<Vec<Todo>> {
    Json(service.list())
}

async fn get_todo(
    State(service): State<Arc<dyn TodoService>>,
    Path(id): Path<Uuid>,
) -> Result<Json<Todo>, TodoError> {
    service.get(id).map(Json)
}

async fn create_todo(
    State(service): State<Arc<dyn TodoService>>,
    Json(input): Json<CreateTodo>,
) -> (StatusCode, Json<Todo>) {
    let todo = service.create(input);
    (StatusCode::CREATED, Json(todo))
}

async fn complete_todo(
    State(service): State<Arc<dyn TodoService>>,
    Path(id): Path<Uuid>,
) -> Result<Json<Todo>, TodoError> {
    service.complete(id).map(Json)
}

async fn delete_todo(
    State(service): State<Arc<dyn TodoService>>,
    Path(id): Path<Uuid>,
) -> Result<Json<Todo>, TodoError> {
    service.delete(id).map(Json)
}

// Main

#[tokio::main]
async fn main() {
    let service: Arc<dyn TodoService> = Arc::new(InMemoryTodoService::new());

    let app = Router::new()
        .route("/", get(hello))
        .route("/todos", get(list_todos).post(create_todo))
        .route("/todos/{id}", get(get_todo).delete(delete_todo))
        .route("/todos/{id}/complete", post(complete_todo))
        .with_state(service);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Listening on http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}

Next

So that's a simple single-file web API with Rust and Axum coming in at around 175 lines.

I'll admit that it's definitely more verbose in some areas than a similar app in e.g. C#, F#, or TypeScript and it requires you to think more about memory and threading by default. But I'd also say it forces you to be a bit more robust as well - thinking about threading and data races and how you want your memory laid out at the outset.

This has short term overhead in implementation but long term can catch many types of bugs that might go by implicitly otherwise. Plus with agentic engineering, you largely don't have to remember all the exact syntax and deal with the verbosity, you can just set the patterns you want to see and have those implemented everywhere.

If you want to get access to the full example project source code so you can clone it and run it yourself, check it out in the HAMY LABS Code Example repo which is available to HAMINIONs Members. HAMINIONs Members get access to the full source code from this guide as well as dozens of others and early access to content and discounts on projects - plus you support me in continuing these experiments and sharing my learnings.

If you want to get started building your own web apps in Rust, checkout CloudSeed Rust which helps you ship production-ready webapps in minutes. This is what I use to start all my Rust web projects and implements all the best practices I've found for building with High-Level Rust.

If you liked this post you might also like:

Want more like this?

The best way to support my work is to like / comment / share this post on your favorite socials.

Built with CloudSeed Rust