Introduction
As your Rust codebase grows from a single binary to a production system handling thousands of requests, organizing your code becomes critical. Modules and crates are the foundational tools Rust gives us to structure projects, and understanding them is what every Rust developer should know before tackling real-world systems.
In this article, we'll explore how to use mod to declare modules, leverage pub for visibility control, understand what crates truly are, and see how workspaces help teams manage multiple interdependent packages. Whether you're building a microservice, a CLI tool, or a shared library, these concepts will keep your code maintainable and your dependencies clean.
mod declares modules
A module in Rust is a namespace that organizes code and controls visibility. Think of it as a folder structure for your logic. The mod keyword declares a module, and it can live either inline in your code or in a separate file.
The most common approach in production systems is file-based modules. If you declare mod user; in main.rs, Rust looks for a file called user.rs (or user/mod.rs for nested structures). This keeps files focused and readable.
// src/main.rs
mod user;
mod order;
fn main() {
user::create_account("alice@example.com");
}
// src/user.rs
pub fn create_account(email: &str) {
println!("Creating account for {}", email);
}
For nested modules, you can use the mod { } syntax directly or organize them hierarchically. In a real e-commerce backend, you might have:
// src/main.rs
mod api;
mod models;
mod db;
// In api module, further organize
mod api/handlers.rs
mod api/middleware.rs
mod api/routes.rs
Each file becomes a logical boundary. Modules also help with code organization during refactoring—when you extract functionality into a new module, you establish clear contracts about what that module exports and depends on.
pub controls visibility
By default, everything in Rust is private. If you want something accessible outside its module, you mark it pub. This is deliberate: it forces you to think about your API surface.
There are several visibility levels. The most common are:
pub— visible from anywhere that has access to the modulepub(crate)— visible only within the current cratepub(super)— visible only in the parent modulepub(in path::to::module)— visible only to a specific module- No
pub— private to the module
In production systems, being explicit about visibility prevents accidental dependencies. Imagine a payment processing module—you want only the public handler exposed, not internal helper functions:
// src/payment/mod.rs
pub fn process_payment(amount: f64, customer_id: u64) -> Result<String, PaymentError> {
validate_amount(amount)?;
charge_customer(customer_id, amount)?;
Ok("Payment processed".to_string())
}
// Private helpers, not exposed to the rest of the codebase
fn validate_amount(amount: f64) -> Result<(), PaymentError> {
if amount <= 0.0 {
Err(PaymentError::InvalidAmount)
} else {
Ok(())
}
}
fn charge_customer(customer_id: u64, amount: f64) -> Result<(), PaymentError> {
// Call your payment gateway
Ok(())
}
Using pub(crate) is particularly useful in larger projects. It signals that a function is internal infrastructure, available to other modules in the same crate but not to external consumers. This prevents API surface bloat and makes refactoring safer.
Crates are packages
A crate is a compilation unit—the smallest piece of code Rust can compile into a library or binary. A crate can be a library (exposing types and functions for others to use) or a binary (a runnable application). Most projects have exactly one crate, but large systems often split into multiple crates.
Every crate has a manifest file, Cargo.toml, that declares dependencies, metadata, and build configuration. When you run cargo new, you create a single-crate project. That crate's name matches your package name:
// Cargo.toml
[package]
name = "payment-service"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1.35", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
The crate root is src/lib.rs for libraries or src/main.rs for binaries. Code in src/lib.rs becomes the public API of your library crate. If you publish to crates.io, this is what others will depend on.
In production, you often build a library crate with core business logic and a separate binary crate that uses it:
// src/lib.rs
pub mod models;
pub mod services;
pub use services::PaymentService;
// src/main.rs (binary)
use payment_service::PaymentService;
#[tokio::main]
async fn main() {
let svc = PaymentService::new();
// Start HTTP server, etc.
}
Splitting into multiple crates becomes essential when you need to share code across services or when you want clear boundaries. A team building a trading platform might have core-models, order-service, and risk-engine` as separate crates with explicit dependencies.
Workspaces group crates
A workspace is a set of multiple crates that share a single Cargo.lock file and build artifacts. This is invaluable for polyrepo-style development where you want related services to stay in sync.
Create a workspace by defining a Cargo.toml at the root with a [workspace] section:
// Cargo.toml (workspace root)
[workspace]
members = [
"core-models",
"payment-service",
"order-service",
"shared-utils",
]
[workspace.dependencies]
tokio = { version = "1.35", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
sqlx = { version = "0.7", features = ["runtime-tokio-native-tls", "postgres"] }
Each member crate has its own Cargo.toml, but the workspace root's Cargo.toml declares shared dependencies. This prevents version mismatches across services and simplifies dependency upgrades—you update once at the workspace level.
// payment-service/Cargo.toml
[package]
name = "payment-service"
version = "0.1.0"
[dependencies]
core-models = { path = "../core-models" }
shared-utils = { path = "../shared-utils" }
tokio = { workspace = true }
serde = { workspace = true }
In a workspace, you can run cargo build at the root to compile all crates, or cargo build -p payment-service to build a single member. Tests, linting, and formatting apply workspace-wide, which keeps quality standards consistent.
We've seen teams use workspaces to organize:
- Microservices: Each service is a binary crate; shared types live in a library crate.
- SDKs and clients: Core SDK logic in one crate, CLI tools and examples in others.
- Monorepos: Multiple independent applications sharing utilities, keeping build times reasonable and dependencies explicit.
Common mistakes
Over-nesting modules. While you can nest modules arbitrarily, deeply nested structures make imports verbose and code harder to navigate. Keep module depth reasonable—usually 2–3 levels max. If you find yourself writing use crate::api::handlers::http::routes::, consider flattening.
Exposing internals accidentally. It's easy to mark functions pub without thinking. Private is the default for good reason. Before making something public, ask: is this part of our stable API? If you expose too much, refactoring later breaks users of your library.
Mixing module files and directories inconsistently. Rust allows both user.rs (single file) and user/mod.rs (directory with mod.rs) for modules. Mixing styles in the same project causes confusion. Pick one convention and stick to it. Most teams prefer user/mod.rs for anything larger than 200 lines, as it scales better.
Creating crates without clear purpose. Every crate adds complexity—separate compilation, separate versioning, separate CI. Only split into multiple crates if you have a reason: code reuse, explicit dependency boundaries, or independent deployment. A single library crate with well-organized modules is often simpler.
Circular dependencies between crates. Workspaces make it easy to create circular imports. Always think of your crate graph as a DAG (directed acyclic graph). If A depends on B, then B must not depend on A. Use a shared types crate if you need to avoid cycles.
Key takeaways
- Use
modto organize code into logical namespaces; prefer file-based modules as your codebase grows. - Leverage
pub,pub(crate), and other visibility modifiers to define clear API boundaries and prevent accidental coupling. - Understand that a crate is a compilation unit and package; split into multiple crates only when you have clear separation of concerns.
- Use workspaces to manage multiple related crates, keeping dependencies in sync and maintaining consistent quality across the project.
- Default to private; make things public deliberately after considering long-term API stability.
- Keep module hierarchies shallow and consistent; use a single convention for module file organization across your project.






Comments
Loading comments…