When starting out with Rust, it’s common to place all your code inside a single main.rs
file. While this works for small experiments, it quickly becomes messy and hard to maintain as your project grows. A well-structured project is crucial for keeping your code clean, scalable, and easy to navigate.
Rust encourages modularity through its package manager (Cargo) and module system, but beginners often struggle with how to properly organize files, create reusable libraries, or structure larger applications. Without a clear structure, you’ll run into issues like:
-
Long, unreadable files where unrelated logic is mixed together.
-
Poor separation of concerns, making it hard to test or refactor code.
-
Difficulty scaling the project when new features are added.
This tutorial will guide you through best practices for structuring Rust projects, from small command-line tools to scalable applications. We’ll cover how to split code into modules, organize folders, handle errors, manage dependencies, and even design a multi-crate workspace. Along the way, you’ll see concrete examples of structuring code that not only compiles but also remains maintainable over time.
By the end, you’ll know how to design Rust projects that are clean, modular, and future-proof.
Basic Rust Project Layout
Rust comes with a powerful tool called Cargo, which handles project creation, building, testing, and dependency management. When you create a new Rust project with Cargo, you’ll get a clean, standardized layout right out of the box.
Creating a New Project
To start a new project, run:
cargo new my_project
This will generate the following structure:
my_project/
├── Cargo.toml
├── Cargo.lock
└── src/
└── main.rs
Let’s break down these files and directories:
Cargo.toml
The Cargo.toml file is the heart of your project’s configuration. It defines:
-
Project metadata (name, version, authors).
-
Dependencies (external crates you want to use).
-
Build settings and features.
Example:
[package]
name = "my_project"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = "1.0"
Here we’ve added serde
as a dependency, which Cargo will fetch and manage automatically.
Cargo.lock
The Cargo.lock file is automatically generated after you build your project for the first time. It records the exact versions of dependencies used, ensuring reproducible builds.
-
For applications, you should commit
Cargo.lock
to version control. -
For libraries, you usually don’t commit it, so consumers can resolve dependencies themselves.
src/main.rs
The default entry point of a Rust binary project. By default, it looks like this:
fn main() {
println!("Hello, world!");
}
As your project grows, you’ll move most of the logic into separate modules or a lib.rs
file, leaving main.rs
as a thin entry point.
Splitting Code into Modules
In Rust, modules are the primary way to organize code into smaller, manageable pieces. Instead of cramming everything into main.rs
, you can group related functions, structs, and enums into separate files or directories. This improves readability and maintainability as your project grows.
Declaring a Module
Let’s say we have some helper functions in main.rs
:
fn main() {
let result = add(2, 3);
println!("Result: {}", result);
}
fn add(a: i32, b: i32) -> i32 {
a + b
}
This works, but it’s better to separate utility functions.
We can create a new file src/utils.rs
and move add
into it:
// src/utils.rs
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
Then in main.rs
, declare the module and use it:
mod utils;
fn main() {
let result = utils::add(2, 3);
println!("Result: {}", result);
}
Here’s what’s happening:
-
mod utils;
tells Rust to look for a file namedutils.rs
in thesrc/
directory. -
pub
makes the function visible outside of theutils
module.
Organizing with Submodules
As projects grow, you’ll want nested structures. For example, let’s say we want to manage users.
We can create a folder src/models/
with two files:
src/
├── main.rs
└── models/
├── mod.rs
└── user.rs
Inside user.rs
:
pub struct User {
pub id: u32,
pub name: String,
}
Inside mod.rs
:
pub mod user;
And in main.rs
:
mod models;
fn main() {
let user = models::user::User {
id: 1,
name: String::from("Alice"),
};
println!("User: {} with ID {}", user.name, user.id);
}
Best Practices for Modules
-
Use one file per module when possible (
utils.rs
,config.rs
, etc.). -
Use directories with
mod.rs
when grouping related modules (models/mod.rs
withmodels/user.rs
,models/product.rs
). -
Use
pub(crate)
to expose items only within the crate instead of making everythingpub
.
Library vs Binary Projects
When you create a new project with Cargo, it defaults to a binary project — meaning it compiles into an executable with a main.rs
entry point. But Rust also supports library projects, which produce reusable crates that can be used by other projects.
Understanding when to use main.rs
, lib.rs
, or both is key to building scalable applications.
Binary Projects (main.rs
)
A binary project has an entry point function:
// src/main.rs
fn main() {
println!("Hello, world!");
}
This is perfect for command-line tools, apps, or services where the program runs as a standalone executable.
Library Projects (lib.rs
)
A library project doesn’t have a main
function. Instead, it exposes functions, structs, and modules that other projects (or binaries) can reuse.
Example:
// src/lib.rs
pub fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
This can then be imported into another project or binary.
Combining main.rs
and lib.rs
In larger applications, it’s best to keep business logic inside lib.rs
and leave main.rs
as a thin wrapper (entry point).
For example, consider a CLI app:
src/
├── lib.rs
└── main.rs
lib.rs
:
pub fn run_app() {
println!("App is running!");
}
main.rs
:
use my_project::run_app;
fn main() {
run_app();
}
This setup has several advantages:
-
You can reuse the core logic in multiple binaries.
-
It makes your code easier to test (you can write unit tests against the library).
-
Keeps
main.rs
clean and focused on orchestration, not business logic.
When to Use Each
-
Only
main.rs
→ Simple apps, scripts, prototypes. -
Only
lib.rs
→ Pure libraries or crates intended for reuse. -
Both → Best practice for most real-world applications where you want separation of logic and entry points.
Organizing with Folders
As Rust projects grow, it’s important to organize files into well-defined folders. Cargo supports common conventions that help keep code, tests, and examples separate and easy to maintain.
Here’s a breakdown of the most commonly used directories:
src/
– Application Code
This is the main directory for your project’s source code.
-
main.rs
→ entry point for binary projects. -
lib.rs
→ reusable library code. -
Submodules → additional
.rs
files or folders (utils.rs
,models/
,services/
).
Example structure:
src/
├── main.rs
├── lib.rs
├── utils.rs
└── models/
├── mod.rs
└── user.rs
tests/
– Integration Tests
Rust supports two kinds of tests:
-
Unit tests (placed inside the same file as the code, usually with
#[cfg(test)]
). -
Integration tests (placed in a separate
tests/
folder).
Example:
tests/
└── integration_test.rs
integration_test.rs
:
use my_project::greet;
#[test]
fn test_greet() {
assert_eq!(greet("Alice"), "Hello, Alice!");
}
Run with:
cargo test
examples/
– Example Programs
This folder is for showcasing how your library or app can be used. Each file in examples/
compiles as its own binary.
examples/
└── hello.rs
hello.rs
:
use my_project::greet;
fn main() {
println!("{}", greet("World"));
}
Run with:
cargo run --example hello
benches/
– Benchmarks
Rust supports benchmarking with the criterion crate.
cargo add criterion
Place benchmark files inside benches/
.
benches/
└── my_benchmark.rs
my_benchmark.rs
:
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use my_project::greet;
fn bench_greet(c: &mut Criterion) {
c.bench_function("greet", |b| b.iter(|| greet(black_box("Alice"))));
}
criterion_group!(benches, bench_greet);
criterion_main!(benches);
Run with:
cargo bench
Other Optional Folders
-
migrations/
→ For database migrations (when using ORMs like Diesel). -
docs/
→ Extra documentation beyond Rustdoc. -
assets/
→ Configs, images, or data files.
Example Full Layout
my_project/
├── Cargo.toml
├── Cargo.lock
├── src/
│ ├── main.rs
│ ├── lib.rs
│ ├── utils.rs
│ └── models/
│ ├── mod.rs
│ └── user.rs
├── tests/
│ └── integration_test.rs
├── examples/
│ └── hello.rs
├── benches/
│ └── my_benchmark.rs
└── assets/
└── config.json
This structure keeps your project professional, modular, and ready for scaling.
Best Practices for Clean Code
Writing working code is only half the battle—writing clean, maintainable code is what makes your Rust project sustainable as it grows. Here are some best practices to follow when structuring your code.
1. Follow Rust Naming Conventions
Rust has consistent naming guidelines:
-
Modules & files →
snake_case
(e.g.,utils.rs
,user_profile.rs
). -
Functions & variables →
snake_case
(e.g.,calculate_total
,user_id
). -
Structs, Enums, Traits →
PascalCase
(e.g.,User
,OrderStatus
,Drawable
). -
Constants & Statics →
SCREAMING_SNAKE_CASE
(e.g.,MAX_CONNECTIONS
).
Example:
struct UserProfile {
user_id: u32,
user_name: String,
}
2. Keep Modules Small and Focused
Each module should have a single responsibility. Instead of a huge utils.rs
that does everything, break it down into smaller modules:
src/
├── utils/
│ ├── mod.rs
│ ├── math.rs
│ └── string.rs
utils/mod.rs
:
pub mod math;
pub mod string;
This keeps your codebase easy to navigate.
3. Use Visibility Wisely (pub
, pub(crate)
)
By default, everything in Rust is private. Use visibility modifiers carefully:
-
pub
→ public to the world. -
pub(crate)
→ accessible only within the current crate (good for internal APIs). -
pub(super)
→ accessible only to the parent module.
Example:
pub(crate) fn internal_helper() {
println!("Only available within this crate");
}
This prevents exposing unnecessary internal details.
4. Keep Functions Short and Focused
Functions should do one thing well. If a function grows too large, break it into smaller helper functions.
Bad (hard to read, multi-purpose):
fn process_user(id: u32, name: &str) {
println!("Creating user: {}", name);
// validation
if name.is_empty() {
panic!("Name cannot be empty");
}
// more logic...
}
Better (cleaner, testable):
fn validate_name(name: &str) {
if name.is_empty() {
panic!("Name cannot be empty");
}
}
fn process_user(id: u32, name: &str) {
println!("Creating user: {}", name);
validate_name(name);
// other logic...
}
5. Separate Concerns
Don’t mix business logic, configuration, and I/O in the same module. For example, in a web service:
-
models/
→ structs and data types. -
services/
→ business logic. -
handlers/
→ web request handling. -
config/
→ configuration and environment setup.
This makes the project easier to test and extend.
6. Write Tests Alongside Your Code
Use unit tests directly inside the module:
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
}
}
This keeps tests close to the implementation and helps maintain correctness.
Error Handling & Logging Structure
Rust’s type system makes error handling explicit, which helps prevent runtime surprises. Instead of ignoring errors, you’re encouraged to design a structured error-handling system and combine it with logging for better observability.
1. Centralized Error Types
Instead of returning Result<T, String>
, define a custom error type for your project. This makes your code more descriptive and easier to maintain.
Example using thiserror
:
// src/errors.rs
use thiserror::Error;
#[derive(Debug, Error)]
pub enum MyAppError {
#[error("Invalid input: {0}")]
InvalidInput(String),
#[error("Database error: {0}")]
DatabaseError(String),
#[error("Unknown error occurred")]
Unknown,
}
Usage in a function:
use crate::errors::MyAppError;
pub fn process_input(input: &str) -> Result<(), MyAppError> {
if input.is_empty() {
return Err(MyAppError::InvalidInput("Input cannot be empty".into()));
}
Ok(())
}
2. Simpler Error Handling with anyhow
For apps where you don’t need strict error typing, use anyhow
. It provides an easy way to bubble up errors without defining enums everywhere.
use anyhow::Result;
fn run() -> Result<()> {
let data = std::fs::read_to_string("config.json")?;
println!("Config: {}", data);
Ok(())
}
This is great for prototyping or CLI tools.
3. Logging with log
or tracing
Logging helps you debug and monitor your app.
Using log
+ env_logger
:
Add to Cargo.toml
:
[dependencies]
log = "0.4"
env_logger = "0.11"
main.rs
:
use log::{info, warn, error};
fn main() {
env_logger::init();
info!("Application started");
warn!("This is a warning");
error!("Something went wrong");
}
Run with:
RUST_LOG=info cargo run
Using tracing
(recommended for larger apps)
tracing
provides structured, async-friendly logging with spans.
Cargo.toml
:
[dependencies]
tracing = "0.1"
tracing-subscriber = "0.3"
main.rs
:
use tracing::{info, instrument};
use tracing_subscriber;
#[instrument]
fn calculate(x: i32, y: i32) -> i32 {
x + y
}
fn main() {
tracing_subscriber::fmt::init();
let result = calculate(2, 3);
info!("Calculation result: {}", result);
}
4. Project Structure for Errors & Logs
Example:
src/
├── main.rs
├── errors.rs
└── services/
└── calculator.rs
-
errors.rs
→ central place for custom error definitions. -
services/
→ business logic returningResult<T, MyAppError>
. -
main.rs
→ initializes logger and handles top-level errors.
✅ With centralized error handling and proper logging, your Rust apps will be more robust, debuggable, and production-ready.
Dependency Management
Rust has an incredible ecosystem of libraries (called crates), but without discipline, your project can quickly suffer from dependency bloat, version conflicts, and longer compile times. Managing dependencies well is key to keeping your project clean and efficient.
1. Keeping Cargo.toml
Clean
Your Cargo.toml
should list only the dependencies you actually need.
Bad (bloated, unused crates):
[dependencies]
serde = "1.0"
tokio = "1.40"
rand = "0.9"
regex = "1.11"
chrono = "0.4"
Better (minimal, focused):
[dependencies]
serde = "1.0"
tokio = { version = "1.40", features = ["full"] }
👉 Always remove unused crates to speed up compile times and reduce binary size.
2. Using Features to Reduce Bloat
Many crates have optional features you can enable or disable. Only enable what you need.
Example:
serde = { version = "1.0", features = ["derive"] }
This includes serde_derive
for serialization but avoids pulling in unnecessary extras.
3. Managing Versions
Cargo uses semantic versioning. Common patterns:
-
serde = "1.0"
→ any compatible1.x.y
version. -
serde = "1.0.188"
→ exact version. -
serde = ">=1.0.100, <2.0.0"
→ custom range.
For libraries, keep versions flexible to maximize compatibility.
For applications, pin exact versions for reproducibility (via Cargo.lock
).
4. Workspaces for Multi-Crate Projects
If your project grows into multiple components (e.g., CLI + library + API), you can use a Cargo workspace.
Workspace layout:
my_project/
├── Cargo.toml
├── cli/
│ └── Cargo.toml
├── core/
│ └── Cargo.toml
└── api/
└── Cargo.toml
Top-level Cargo.toml
:
[workspace]
members = ["cli", "core", "api"]
This setup allows:
-
Shared
Cargo.lock
→ consistent versions across crates. -
cargo build
→ builds everything in one go. -
Logical separation of responsibilities (core logic vs. API vs. CLI).
5. Security and Auditing
Cargo has built-in tools to keep dependencies secure:
cargo audit
This checks for known vulnerabilities in your dependencies. Install with:
cargo install cargo-audit
✅ With clean Cargo.toml
, minimal features and workspaces, your Rust project will stay lean, maintainable, and scalable.
Example: Building a Small, Scalable Project
To see how all the principles fit together, let’s build a simple CLI Todo app. It will have:
-
models/
→ to define the Todo struct. -
services/
→ to manage business logic. -
cli/
→ to handle command-line arguments. -
main.rs
→ a thin entry point.
Step 1: Project Layout
todo_app/
├── Cargo.toml
└── src/
├── main.rs
├── lib.rs
├── models/
│ ├── mod.rs
│ └── todo.rs
├── services/
│ ├── mod.rs
│ └── todo_service.rs
└── cli.rs
Step 2: Define the Model
src/models/todo.rs
:
#[derive(Debug)]
pub struct Todo {
pub id: u32,
pub title: String,
pub completed: bool,
}
src/models/mod.rs
:
pub mod todo;
Step 3: Create a Service Layer
src/services/todo_service.rs
:
use crate::models::todo::Todo;
pub struct TodoService {
todos: Vec<Todo>,
}
impl TodoService {
pub fn new() -> Self {
Self { todos: Vec::new() }
}
pub fn add(&mut self, title: &str) {
let id = (self.todos.len() + 1) as u32;
let todo = Todo {
id,
title: title.to_string(),
completed: false,
};
self.todos.push(todo);
}
pub fn list(&self) -> &Vec<Todo> {
&self.todos
}
pub fn complete(&mut self, id: u32) {
if let Some(todo) = self.todos.iter_mut().find(|t| t.id == id) {
todo.completed = true;
}
}
}
src/services/mod.rs
:
pub mod todo_service;
Step 4: Handle CLI Input
We’ll keep this simple using std::env
.
src/cli.rs
:
use std::env;
pub enum Command {
Add(String),
List,
Complete(u32),
Unknown,
}
pub fn parse_args() -> Command {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
return Command::Unknown;
}
match args[1].as_str() {
"add" if args.len() > 2 => Command::Add(args[2..].join(" ")),
"list" => Command::List,
"complete" if args.len() == 3 => {
if let Ok(id) = args[2].parse() {
Command::Complete(id)
} else {
Command::Unknown
}
}
_ => Command::Unknown,
}
}
Step 5: The Library Wrapper
src/lib.rs
:
pub mod models;
pub mod services;
pub mod cli;
Step 6: Main Entry Point
src/main.rs
:
use todo_app::{cli, services::todo_service::TodoService};
fn main() {
let command = cli::parse_args();
let mut service = TodoService::new();
match command {
cli::Command::Add(task) => {
service.add(&task);
println!("Added: {}", task);
}
cli::Command::List => {
for todo in service.list() {
println!("[{}] {} - {}",
todo.id,
todo.title,
if todo.completed { "Done" } else { "Pending" }
);
}
}
cli::Command::Complete(id) => {
service.complete(id);
println!("Marked task {} as complete", id);
}
cli::Command::Unknown => {
println!("Usage:");
println!(" todo_app add <task>");
println!(" todo_app list");
println!(" todo_app complete <id>");
}
}
}
Step 7: Running the App
Build the project:
cargo build
Run commands:
cargo run -- add "Learn Rust"
cargo run -- add "Build a CLI app"
cargo run -- list
cargo run -- complete 1
cargo run -- list
Output:
Added: Learn Rust
Added: Build a CLI app
[1] Learn Rust - Pending
[2] Build a CLI app - Pending
Marked task 1 as complete
[1] Learn Rust - Done
[2] Build a CLI app - Pending
✅ With this structure, we have a clean, modular Rust project where business logic, models, and CLI handling are neatly separated. This makes it easier to scale later (e.g., adding persistence, logging, or API support).
Conclusion
In this tutorial, we explored how to structure Rust projects effectively and apply best practices that lead to clean, scalable, and maintainable code. Starting from the basic project layout, we progressively refined the structure by splitting code into modules, understanding the difference between binary and library projects, organizing with folders, leveraging mod.rs
, and following naming conventions. We also covered workspace management, dependency best practices, and finished with a practical example of building a small but scalable project.
The key takeaways are:
-
Start simple, scale when needed – Don’t over-engineer your project structure, but be ready to refactor as the codebase grows.
-
Use modules and folders to separate concerns and make your codebase easier to navigate.
-
Leverage workspaces when your project grows into multiple crates (CLI, library, API, etc.).
-
Keep dependencies minimal and well-managed to avoid bloat, long compile times, and security issues.
-
Follow Rust idioms and conventions (naming, module usage,
Cargo.toml
hygiene) for consistency and readability.
By adopting these practices, you’ll set up your Rust projects for success, whether you’re building a small command-line tool or a large-scale system. A well-structured codebase not only improves productivity but also makes onboarding new contributors much smoother.
🚀 The more you practice structuring Rust projects, the more natural it will feel—and over time, you’ll develop an intuition for when and how to organize your code for clarity and scalability.
You can get the full source code on our GitHub.
That is just the basics. If you need more deep learning about the Rust language and frameworks, you can take the following cheap course:
- Rust Programming Language: The Complete Course
- Rust Crash Course for Absolute Beginners 2025
- Hands-On Data Structures and Algorithms in Rust
- Master Rust: Ownership, Traits & Memory Safety in 8 Hours
- Web3 Academy Masterclass: Rust
- Creating Botnet in Rust
- Rust Backend Development INTERMEDIATE to ADVANCED [2024]
Thanks!