Chapter 17: Building Real Projects
It is time to turn your Rust skills into complete applications. This chapter walks through common project types and the practices that carry them from a blank repository to a shipped artifact. You will see how to structure a binary crate, add dependencies, keep configuration outside of code, test what matters, and publish with confidence.
CLI tools from zero to release
Command line tools are a great way to learn how Rust scales from small programs to robust utilities. You will start with a new binary crate, add a command line parser, handle I/O and errors, and finish by producing signed, reproducible release builds.
Project layout and cargo scaffolding
A binary project starts as a crate with a src/main.rs entry point. Keep logic in src/lib.rs where possible, so it can be tested and reused by the binary.
# Create a new binary crate
cargo new ripgrepish --bin
cd ripgrepish
# Minimal main that calls into a library function
// src/main.rs
use ripgrepish::run;
fn main() {
if let Err(e) = run() {
eprintln!("error: {e}");
std::process::exit(1);
}
}
// src/lib.rs
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
// real logic lives here
Ok(())
}
lib.rs makes it easy to expose a clean API and write unit tests without spinning up the binary each time.
Parsing arguments with clap
The clap crate provides structured parsing, validation, and usage text. Prefer derive based definitions so your help output stays in sync with your code.
// Cargo.toml (snippet)
[dependencies]
clap = { version = "4", features = ["derive"] }
// src/cli.rs
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(author, version, about)]
pub struct Args {
/// Search pattern
pub pattern: String,
/// Path to search (defaults to current directory)
#[arg(default_value = ".")]
pub path: String,
#[command(subcommand)]
pub cmd: Option<Cmd>,
}
#[derive(Subcommand)]
pub enum Cmd {
/// Generate shell completions
Completions { shell: String },
}
// src/main.rs (wire up)
mod cli;
use cli::Args;
fn main() {
let args = Args::parse();
// handle args.cmd and args.pattern …
}
I/O, errors, and logging
For pleasant diagnostics, combine anyhow or eyre with thiserror in library code and a lightweight logger in the binary. Structured logs help users and future you.
// Cargo.toml (snippet)
[dependencies]
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt"] }
// src/main.rs
fn main() {
tracing_subscriber::fmt::init();
if let Err(err) = ripgrepish::run() {
tracing::error!("fatal error: {err:?}");
std::process::exit(1);
}
}
Testing, benchmarking, and releasing
Automate quality and releases. Add fast unit tests, a few integration tests in tests/, and set up CI to run across platforms. Use cargo build --release for optimized binaries and attach them to a tagged release.
# Typical flow
cargo fmt -- --check
cargo clippy -- -D warnings
cargo test
cargo build --release
# Create v1.0.0 tag and attach binaries …
Web services with axum or actix-web
Rust is a strong fit for network services. You will pick a framework, define routes and handlers, apply middleware, and serve with an async runtime. The patterns are similar across frameworks; choose based on ergonomics and ecosystem fit.
Choosing axum vs actix-web
axum sits on tokio and tower with a focus on routing and middleware composition. actix-web offers a batteries included experience with an actor model at its core. Both are production ready.
Routing and handlers
Handlers are async functions that take typed extractors and return responses. Keep them small and delegate to services in your application layer.
// Cargo.toml (snippet for axum)
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
// src/main.rs (axum hello)
use axum::{routing::get, Router};
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/health", get(health))
.route("/api/items/…", get(list_items));
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn health() -> &'static str { "ok" }
async fn list_items() -> String { "[]" .to_string() }
Middleware, state, and errors
Use middleware for cross cutting concerns like logging, timeouts, authentication, and compression. Share state by injecting an Arc<AppState> and use a consistent error type for clean responses.
// Shared state
use std::sync::Arc;
#[derive(Clone)]
struct AppState {
// db pool, config, cache …
}
Serving static files and TLS
For assets, mount a static directory or place a reverse proxy in front. For TLS, terminate at a proxy or use rustls if the service will handle certificates itself.
Database access with SQLx and Diesel
Rust offers both query first and schema first approaches. SQLx executes raw SQL with compile time checking when a database URL is available. Diesel generates strongly typed queries from schema definitions.
Connecting and pooling
Create a connection pool during startup and pass it through application state. Prefer a small, bounded pool and instrument it to detect saturation.
// SQLx example
# Cargo.toml (snippet)
[dependencies]
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres"] }
// src/db.rs
use sqlx::{Pool, Postgres};
pub type PgPool = Pool<Postgres>;
pub async fn connect(url: &str) -> Result<PgPool, sqlx::Error> {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(url)
.await
}
Migrations
Version your schema with migrations that can be applied forward and backward. Treat the database as part of your codebase and keep migration files under version control.
# SQLx
cargo sqlx migrate add create_users
# creates migrations/…/up.sql and down.sql
Queries and type safety
With SQLx, write SQL directly and map rows to structs. With Diesel, build queries with combinators that mirror SQL and leverage Rust types for safety.
// SQLx typed fetch
use serde::Serialize;
#[derive(Serialize, sqlx::FromRow)]
struct User { id: i64, name: String }
pub async fn list(pool: &PgPool) -> Result<Vec<User>, sqlx::Error> {
sqlx::query_as::<User>("SELECT id, name FROM users ORDER BY id")
.fetch_all(pool)
.await
}
Testing against a real database
Use ephemeral databases or schemas for integration tests. Apply migrations during test setup and tear down afterwards for isolation.
// tests/db_it.rs
#[tokio::test]
async fn creates_user() {
// spin up test pool, run migrations, call functions …
}
Configuration and secrets
Keep configuration outside of code and separate secrets from non secret settings. Use layered sources such as defaults, configuration files, and environment variables. Do not commit secrets to version control.
Layered configuration
Load defaults first, then override with file based settings, and finally with environment variables. This order makes local development smooth while letting deployments adjust behavior.
// Example structure
#[derive(serde::Deserialize, Clone)]
pub struct Settings {
pub host: String,
pub port: u16,
pub database_url: String,
}
Environment variables and .env
During development, a .env file can populate variables. In production, inject environment variables from a secret store or deployment system.
# .env
HOST=127.0.0.1
PORT=8080
DATABASE_URL=postgres://…
Secret management
Use a dedicated secret manager when available. If you must read files at runtime, mount secrets as files with strict permissions and read them at startup.
Configuration validation
Validate configuration at startup and fail fast with a clear message if anything is missing or malformed. Keep the validation near the loading code for discoverability.
Packaging, versioning, and publishing
Reliable builds and clear versions help users trust your project. Package artifacts consistently, follow semantic versioning, and publish to the right channel for your audience.
Cargo.toml metadata and features
Populate package metadata and organize optional functionality with features. Good metadata improves discoverability and sets expectations for users.
# Cargo.toml (snippet)
[package]
name = "ripgrepish"
version = "1.0.0"
edition = "2021"
license = "MIT"
repository = "https://…"
description = "A tiny grep-like tool in Rust"
[features]
default = ["cli"]
cli = []
tls = []
Semantic versioning and change management
Use version numbers to communicate compatibility. Increment the patch when fixing bugs, the minor when adding compatible features, and the major when you make breaking changes. Keep a changelog that maps changes to versions.
Building and signing release artifacts
Produce release binaries for target platforms and attach checksums. Consider signing artifacts so users can verify integrity.
# Example targets
cargo build --release --target x86_64-unknown-linux-gnu
cargo build --release --target x86_64-pc-windows-msvc
cargo build --release --target aarch64-apple-darwin
shasum -a 256 target/…/ripgrepish > ripgrepish-….sha256
cross so your builds are reproducible on any host.
Publishing crates and binaries
If you are sharing a library, publish to crates.io. If you are shipping a CLI, create a tagged release with attached binaries and a short installation section that shows one or two commands. Provide a checksum file and instructions for verification.
# Library crate
cargo publish
# Binary release (outline)
git tag v1.0.0
git push origin v1.0.0
# attach artifacts and write release notes …
With these practices, your Rust projects move from experiments to dependable tools and services. The next chapters expand on testing strategies, performance tuning, and deployment patterns so you can operate your software with confidence.