Writing My First Rust Backend: What I Learned
Writing My First Rust Backend: What I Learned
I recently decided to build my first backend in Rust using Axum and SQLx. Coming from other languages, I had no idea how to structure a Rust project properly. I started by putting everything in one file, which quickly became a mess. After a lot of trial and error (and some frustrating debugging sessions), I finally figured out a structure that actually works. I wanted to share what I learned so others don't have to struggle as much as I did.
π§± 1. Figuring Out Project Structure
When I first started, I had no idea how to organize my Rust backend. I tried putting everything in main.rs, which got messy really fast. After reading some examples and experimenting, I discovered this structure that actually makes sense:
- src
- main.rs
- config.rs
- db.rs
- routes
- mod.rs
- user.rs
- health.rs
- handlers
- mod.rs
- user.rs
- models
- mod.rs
- user.rs
- error.rs
What I learned about each part
- main.rs β I learned to keep this super minimal - it should only start the app, no business logic.
- config.rs β I discovered I should load all my environment variables here, like the database URL.
- db.rs β This is where I set up my database connection pool (I'll explain why this matters later).
- routes/ β I put all my API routes here, organized by feature.
- handlers/ β These are my controller functions - the actual logic for each route.
- models/ β I keep all my data structures here (like User, Post, etc.).
- error.rs β I learned to centralize all my error handling in one place - this saved me so much debugging time.
Once I organized things this way, everything became way clearer. Now when I need to add a new feature, I know exactly where everything goes.
π§© 2. Understanding Rust Modules (This Confused Me at First)
Rust modules were really confusing when I first started. I kept getting compilation errors because I didn't understand how to use code from other files. Here's what I figured out:
mod xyz;
This tells Rust to load a file named xyz.rs. It's like saying "hey, I want to use code from this file."
pub mod xyz;
The pub makes the module visible to other files. Without it, only the current file can use it. I spent way too long debugging why my imports weren't working - turns out I forgot the pub keyword!
use crate::xyz::abc;
This imports a specific function or struct from another file so I can use it directly.
π§ How I Set It Up
In my main.rs, I write:
mod config;
mod db;
mod routes;
use crate::config::AppConfig;
use crate::db::DbPool;
use crate::routes::create_router;
What this does:
- I'm telling Rust: "This file needs config, db, and routes modules."
- Then I'm importing the specific things I need from each module so I can use them.
It took me a while to get this right, but once I understood it, everything clicked.
π 3. Learning About Environment Variables
I almost made a huge mistake - I was about to hardcode my database password directly in the code! Thankfully, I read about environment variables before committing that. I learned that you should NEVER hardcode secrets.
I created a .env file like this:
DATABASE_URL=postgres://user:password@localhost:5432/mydb
APP_HOST=127.0.0.1
APP_PORT=3000
Then in my config.rs, I learned to load them like this:
dotenvy::dotenv().ok();
let database_url = std::env::var("DATABASE_URL")?;
What this does: It loads my .env file and reads the DATABASE_URL variable. Simple, but it keeps my secrets safe and makes it easy to switch between dev and production environments. This was a game-changer for me.
ποΈ 4. Setting Up the Database with SQLx
I chose SQLx for my database layer, and here's why it's great:
- Type-safe SQL queries (Rust catches my mistakes at compile time - this saved me from so many bugs!)
- It's asynchronous and really fast
- It integrates perfectly with Axum
Here's how I set up my connection pool:
pub async fn create_pool(url: &str) -> anyhow::Result<DbPool> {
Ok(sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(url)
.await?)
}
What I learned: I'm creating a pool of 5 database connections that get reused across requests. At first, I was creating a new connection for every request, which was super slow. Once I learned about connection pooling, my API got way faster. This was one of those "aha!" moments.
π§ 5. Writing My First Handlers
Handlers are just functions that run when someone hits one of my API routes. When I first started, I didn't understand how Axum's handler system worked. Here's an example of what I learned:
pub async fn list_users(
State(pool): State<DbPool>,
) -> Result<Json<Vec<UserResponse>>, AppError> {
Let me break down what I figured out:
State(pool)β I'm getting the database pool from Axum's global state. This is how I access my DB connection. It took me a while to understand how Axum's state system works.Json<Vec<UserResponse>>β I'm returning a JSON array of users. Axum automatically serializes this for me, which is pretty cool.AppErrorβ If something goes wrong, I return a clean error instead of crashing the whole server. This was important to learn early on.
β οΈ 6. Learning Error Handling the Hard Way
At first, I was writing match statements everywhere to handle errors, and it got messy really fast. My code was full of error handling boilerplate. Then I discovered centralized error handling, which changed everything.
Here's the AppError enum I created:
pub enum AppError {
Db(sqlx::Error),
Any(anyhow::Error),
}
Then I learned to convert these errors into clean API responses:
Json({ "message": "Database error" })
This way, I have one place where I manage all my API errors. If I need to change how errors are formatted, I only change it in one spot. This saved me so much time and made my code way cleaner.
π 7. Key Things I Learned Along the Way
Here are the most important things I discovered while building my first Rust backend:
β Keep main.rs minimal
I learned to only use it to start the server and load config. All the actual logic should live elsewhere. This makes the codebase way easier to navigate.
β Write thin controllers
I discovered that my handlers should be thin - they just:
- Parse the incoming request
- Call the actual service functions
- Return the result
This makes them easy to test and reason about. I was putting too much logic in my handlers at first.
β Always use a DB connection pool
I learned the hard way - never create new connections for each request. The pool reuses connections, which is way faster. My first version was super slow because of this.
β Centralize environment variable handling
I load all my env vars once in config.rs and pass them around. No scattered std::env::var() calls everywhere. This was a lesson I learned after my code got messy.
β Separate SQL queries into their own functions
I keep my SQL queries in separate functions, not inline in handlers. This makes the code cleaner and way easier to test. I refactored this after my handlers got too long.
β Use tracing for logging
I discovered the tracing crate for logs instead of println!. It's much better for debugging in production. I wish I had known about this from the start!
π― What I Learned Overall
Building my first Rust backend was challenging, but really rewarding. These patterns helped me go from a messy, unorganized codebase to something that's actually maintainable. If you're just getting started with Rust backends like I was, I hope sharing my experience helps you avoid some of the mistakes I made.
The key lesson I learned: start simple and refactor as you go. Don't try to implement everything at once - I definitely made that mistake early on and it made everything harder than it needed to be!