Modules

Rust splits code by module and crate, with visibility controlled per-item. The rules are small and the conventions are tight, so codebases look more similar to each other than in most languages.

Modules: one file = one module

The simplest case:

src/lib.rs
pub mod catalog;
pub mod error;
mod internal;

This file declares three modules:

  • pub mod catalog; — a public module, contents in src/catalog.rs (or src/catalog/mod.rs). External callers can use crate::catalog::....
  • pub mod error; — public, contents in src/error.rs.
  • mod internal; — private (the default), contents in src/internal.rs. Only this crate can use it.

Multi-file modules

If a module grows beyond one file, you create a subdirectory:

src/
└── catalog/
    ├── mod.rs         # The module entry point.
    ├── memory.rs      # Submodule: catalog::memory
    └── sqlite.rs      # Submodule: catalog::sqlite

mod.rs re-exports or declares submodules. Modern Rust also supports the "no mod.rs" style:

src/
├── catalog.rs         # The module entry (replaces mod.rs).
└── catalog/
    ├── memory.rs      # Submodule
    └── sqlite.rs

Both work. Pick one per project and stick with it.

Visibility: private by default

pub struct Config { ... }            // public
struct Internal { ... }              // private to this module
pub(crate) struct CrateLocal { ... } // visible anywhere in this crate
pub(super) struct ParentOnly { ... } // visible in the parent module

pub(crate) is the workhorse for "shared within this crate, hidden from outside." Use it liberally. It is the equivalent of "package-private" in Java.

Re-exports: control your public API surface

src/lib.rs
mod internal;
mod helpers;
 
pub use internal::PublicType;
pub use helpers::do_thing;

This is the canonical Sail pattern. Internal modules are private, then pub use re-exports just the things callers should see. The result: callers say mycrate::PublicType, not mycrate::internal::PublicType.

Imports: use

use std::collections::HashMap;
use std::sync::Arc;
use crate::error::CatalogError;
use crate::error::CatalogResult;
 
// Grouped
use std::collections::{HashMap, HashSet, BTreeMap};
 
// Wildcard (use sparingly; only for preludes)
use crate::prelude::*;
 
// Renamed
use std::io::Result as IoResult;

use paths can be relative (crate::, super::, self::) or absolute (std::, tokio::, serde::).

Crates: Rust's unit of compilation

A crate is everything that compiles into one library or binary. Each Cargo.toml with [package] defines one crate. Workspaces group many crates.

Cargo.toml
[package]
name = "my-crate"
version = "0.1.0"
edition = "2021"
 
[dependencies]
serde = "1"
tokio = { version = "1", features = ["full"] }

The crate's name becomes how others depend on it: my-crate = "0.1" in their Cargo.toml, then use my_crate::Thing; in their code (Rust converts hyphens to underscores).

Conventions you should expect

ConventionUsed byWhy
lib.rs is tiny: mod and pub use onlyAll idiomatic cratesPublic surface should be a manifest, not a body of logic
error.rs defines one error enum per crateSail and most production codeErrors are crate-local contracts
mod tests { ... } at the bottom of each fileStandardTests next to code, behind #[cfg(test)]
prelude.rs for re-exports the consumer commonly wantsDiesel, Bevy, some othersOptional convenience
pub(crate) fn helper(...) for shared internalsSail and most production codeReduces noise in the public API

Workspace dependencies

In a workspace, you can centralize dep versions:

Cargo.toml (workspace root)
[workspace]
members = ["crates/*"]
 
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }

Then each member crate writes:

crates/foo/Cargo.toml
[dependencies]
serde = { workspace = true }
tokio = { workspace = true }

This is how Sail keeps version drift under control across 36 crates. Worth adopting in any multi-crate project.

Trait re-exports

A common subtlety: to use a trait's methods, the trait must be in scope. So crates often re-export traits in a prelude:

pub use std::io::{Read, Write};

Now callers can use mycrate::prelude::*; and reach for .read_to_string(...) without separately importing std::io::Read.