Chapter 09
Modules, Crates & Project Organization
Our server is 200+ lines in one file. Time to split it into modules, learn visibility rules, and bring in external crates.
Modules, Visibility, and File Layout
A module is a namespace - a way to group related code and control what's visible to the outside. You've already been using modules: std::net, std::io, std::collections are all modules in the standard library.
You can define a module inline with mod:
mod http {
pub struct Request {
pub method: String,
pub path: String,
}
pub fn parse(raw: &str) -> Option<Request> {
// ...
None
}
// This function is private - only code inside mod http can call it
fn validate_path(path: &str) -> bool {
path.starts_with('/')
}
}
fn main() {
// Access with the module path
let req = http::parse("GET / HTTP/1.1\r\n\r\n");
// Or bring names into scope
use http::Request;
let req = Request { method: "GET".into(), path: "/".into() };
// This won't compile - validate_path is private
// http::validate_path("/");
}Visibility Rules
Everything in Rust is private by default. The pub keyword makes items visible outside their module:
(no keyword) - private
Only accessible within the same module and its children. This is the default.
pub - fully public
Accessible from anywhere that can see the module.
pub(crate) - crate-public
Accessible anywhere within the same crate, but not from other crates. Useful for internal APIs.
pub(super) - parent-public
Accessible from the parent module only. Useful for helpers shared between sibling submodules.
pub. You must mark each field individually: pub method: String. This lets you expose a struct for construction while keeping some fields private - great for enforcing invariants through constructors.Modules as Files
Inline modules work for small things, but we want separate files. Rust maps modules to the filesystem with two conventions:
Convention 1 (single file):
src/
├── main.rs ← declares: mod request;
└── request.rs ← module contents
Convention 2 (directory with submodules):
src/
├── main.rs ← declares: mod http;
└── http/
├── mod.rs ← module root (declares submodules)
├── request.rs
└── response.rsThe key rule: mod request; in main.rs tells the compiler "look for request.rs (or request/mod.rs) in the same directory." The mod declaration is what creates the module - the file just provides the contents.
Splitting Your Server into Modules
Let's reorganize the server from Chapter 8 into a clean module structure. Here's the target layout:
webserver/
├── Cargo.toml
└── src/
├── main.rs ← entry point, server loop
├── error.rs ← ServerError type
├── request.rs ← Method, Request, parsing
├── response.rs ← Response, serialization
├── router.rs ← Router, route table
└── handler.rs ← route handlers (home, about, etc.)Let's build each file. Start with main.rs - it declares all modules and contains only the server loop:
mod error;
mod handler;
mod request;
mod response;
mod router;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use error::ServerError;
use request::Request;
use router::Router;
fn process_request(
stream: &mut TcpStream,
router: &Router,
) -> Result<response::Response, ServerError> {
let mut buffer = [0u8; 4096];
let n = stream.read(&mut buffer)?;
if n == 0 {
return Err(ServerError::ParseError("empty request".to_string()));
}
let raw = String::from_utf8_lossy(&buffer[..n]);
let request = Request::parse(&raw)?;
println!("{} {}", request.method, request.path);
router.route(&request)
}
fn handle_connection(mut stream: TcpStream, router: &Router) {
let response = match process_request(&mut stream, router) {
Ok(resp) => resp,
Err(e) => {
eprintln!("Error: {}", e);
e.to_response()
}
};
if let Err(e) = stream.write_all(&response.to_bytes()) {
eprintln!("Write failed: {}", e);
}
let _ = stream.flush();
}
fn main() {
let router = handler::build_router();
let addr = "127.0.0.1:8080";
let listener = TcpListener::bind(addr).expect("failed to bind");
println!("Listening on http://{}", addr);
for stream in listener.incoming() {
match stream {
Ok(stream) => handle_connection(stream, &router),
Err(e) => eprintln!("Accept failed: {}", e),
}
}
}Notice how main.rs reads now - it's just the server loop. All the types and logic are in dedicated modules. The mod declarations at the top tell the compiler which files to include.
Now the error module:
use std::fmt;
use std::io;
use crate::response::Response;
#[derive(Debug)]
pub enum ServerError {
Io(io::Error),
ParseError(String),
NotFound(String),
}
impl fmt::Display for ServerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ServerError::Io(e) => write!(f, "I/O error: {}", e),
ServerError::ParseError(msg) => write!(f, "Parse error: {}", msg),
ServerError::NotFound(what) => write!(f, "Not found: {}", what),
}
}
}
impl From<io::Error> for ServerError {
fn from(e: io::Error) -> Self {
ServerError::Io(e)
}
}
impl ServerError {
pub fn to_response(&self) -> Response {
let (status, reason, body) = match self {
ServerError::Io(e) => (500, "Internal Server Error",
format!("<h1>500</h1><p>Server error: {}</p>", e)),
ServerError::ParseError(msg) => (400, "Bad Request",
format!("<h1>400</h1><p>Bad request: {}</p>", msg)),
ServerError::NotFound(what) => (404, "Not Found",
format!("<h1>404</h1><p>{} not found</p>", what)),
};
let mut resp = Response::new(status, reason, &body);
resp.add_header("Content-Type", "text/html; charset=utf-8");
resp
}
}A few things to notice:
use crate::response::Response- thecratekeyword refers to the root of your project. It's how modules reference each other.pub enum ServerError- the enum and its variants need to bepubso other modules can use them.pub fn to_response- methods needpubtoo, or they're only callable from withinerror.rs.
crate:: is the absolute path from the crate root. super:: goes up one module level (like .. in a filesystem). self:: refers to the current module (rarely needed). Use crate:: for cross-module imports - it's clearer than relative paths.The request module:
use std::collections::HashMap;
use std::fmt;
use crate::error::ServerError;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Method {
Get,
Post,
}
impl Method {
pub fn parse(s: &str) -> Result<Method, ServerError> {
match s {
"GET" => Ok(Method::Get),
"POST" => Ok(Method::Post),
other => Err(ServerError::ParseError(
format!("unsupported method: {}", other)
)),
}
}
}
impl fmt::Display for Method {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Method::Get => write!(f, "GET"),
Method::Post => write!(f, "POST"),
}
}
}
pub struct Request {
pub method: Method,
pub path: String,
pub headers: HashMap<String, String>,
}
impl Request {
pub fn parse(raw: &str) -> Result<Request, ServerError> {
let mut lines = raw.split("\r\n");
let request_line = lines.next()
.ok_or_else(|| ServerError::ParseError("empty request".into()))?;
let mut parts = request_line.split_whitespace();
let method = Method::parse(
parts.next()
.ok_or_else(|| ServerError::ParseError("missing method".into()))?
)?;
let path = parts.next()
.ok_or_else(|| ServerError::ParseError("missing path".into()))?
.to_string();
let headers: HashMap<String, String> = lines
.take_while(|line| !line.is_empty())
.filter_map(|line| {
let (key, value) = line.split_once(':')?;
Some((key.trim().to_lowercase(), value.trim().to_string()))
})
.collect();
Ok(Request { method, path, headers })
}
}The response module:
pub struct Response {
pub status: u16,
reason: &'static str,
headers: Vec<(String, String)>,
body: String,
}
impl Response {
pub fn new(status: u16, reason: &'static str, body: &str) -> Self {
Response {
status, reason,
headers: vec![],
body: body.to_string(),
}
}
pub fn html(body: &str) -> Self {
let mut resp = Self::new(200, "OK", body);
resp.add_header("Content-Type", "text/html; charset=utf-8");
resp
}
pub fn add_header(&mut self, name: &str, value: &str) {
self.headers.push((name.to_string(), value.to_string()));
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = format!(
"HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n",
self.status, self.reason, self.body.len()
);
for (k, v) in &self.headers {
out.push_str(&format!("{}: {}\r\n", k, v));
}
out.push_str("\r\n");
out.push_str(&self.body);
out.into_bytes()
}
}Notice that reason, headers, and body are not pub. Outside code must use the constructors (new, html) and methods (add_header) - they can't reach into the struct and mess with the internals. Only status is public because we read it for logging.
The router:
use std::collections::HashMap;
use crate::error::ServerError;
use crate::request::{Method, Request};
use crate::response::Response;
pub type Handler = fn(&Request) -> Result<Response, ServerError>;
pub struct Router {
routes: HashMap<(Method, String), Handler>,
}
impl Router {
pub fn new() -> Self {
Router { routes: HashMap::new() }
}
pub fn add(&mut self, method: Method, path: &str, handler: Handler) {
self.routes.insert((method, path.to_string()), handler);
}
pub fn route(&self, request: &Request) -> Result<Response, ServerError> {
let key = (request.method.clone(), request.path.clone());
match self.routes.get(&key) {
Some(handler) => handler(request),
None => Err(ServerError::NotFound(
format!("{} {}", request.method, request.path)
)),
}
}
}And finally the handlers:
use crate::error::ServerError;
use crate::request::{Method, Request};
use crate::response::Response;
use crate::router::Router;
pub fn build_router() -> Router {
let mut router = Router::new();
router.add(Method::Get, "/", home);
router.add(Method::Get, "/about", about);
router
}
fn home(_req: &Request) -> Result<Response, ServerError> {
Ok(Response::html(
"<h1>Welcome!</h1><p><a href=\"/about\">About</a></p>"
))
}
fn about(_req: &Request) -> Result<Response, ServerError> {
Ok(Response::html(
"<h1>About</h1><p>A modular Rust webserver.</p>"
))
}The handler functions are private - only build_router is pub. The handlers are registered as function pointers in the routing table, so they're called through the router, not directly. This keeps the public API minimal.
Using External Crates from crates.io
Rust's ecosystem lives on crates.io, the official package registry. Adding a crate is a two-step process: add it to Cargo.toml, then use it in your code.
You can add crates manually or with the cargo add command:
# Add a crate from the command line
cargo add chrono
# Add with specific features
cargo add serde --features derive
# Add as a dev dependency (only for tests)
cargo add --dev assert_cmdThis modifies your Cargo.toml:
[package]
name = "webserver"
version = "0.1.0"
edition = "2021"
[dependencies]
chrono = "0.4"
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
assert_cmd = "2"The next cargo build downloads the crate and all its dependencies. Then you use it like any module:
use chrono::Local;
fn log_request(method: &str, path: &str) {
let now = Local::now().format("%Y-%m-%d %H:%M:%S");
println!("[{}] {} {}", now, method, path);
}crates.io and check lib.rs (the unofficial docs site) when looking for crates. docs.rs hosts auto-generated documentation for every published crate. The download count and "last updated" date are good signals for whether a crate is maintained.Cargo.toml: Dependencies, Features, and Versions
Version numbers in Cargo.toml follow semver with a twist - Cargo uses a caret requirement by default:
| Specifier | Meaning | Example range |
|---|---|---|
| "1.2.3" | Compatible with 1.2.3 (same as ^1.2.3) | ≥1.2.3, <2.0.0 |
| "0.4" | Compatible with 0.4.x | ≥0.4.0, <0.5.0 |
| "=1.2.3" | Exactly this version | 1.2.3 only |
| "~1.2" | Patch-level updates only | ≥1.2.0, <1.3.0 |
Features
Features are optional pieces of a crate's functionality. They keep compile times down by only including what you need:
# Enable serde's derive macros
serde = { version = "1", features = ["derive"] }
# Tokio with specific runtimes
tokio = { version = "1", features = ["rt-multi-thread", "net", "macros"] }
# Disable default features, enable only what you need
some-crate = { version = "2", default-features = false, features = ["json"] }Cargo.lock
When you first build, Cargo resolves all versions and writes the exact versions to Cargo.lock. This file ensures everyone building your project gets the same dependencies.
- Applications (binaries): commit
Cargo.lockto git. You want reproducible builds. - Libraries: don't commit it. Let downstream users resolve their own compatible versions.
Our webserver is an application, so we commit Cargo.lock.
A Clean Project Structure
As a project grows, conventions help navigate it. Here's the standard layout for a Rust project:
webserver/
├── Cargo.toml ← manifest
├── Cargo.lock ← pinned dependency versions
├── src/
│ ├── main.rs ← binary entry point
│ ├── lib.rs ← library root (optional, for shared code)
│ ├── error.rs ← error types
│ ├── request.rs ← HTTP request parsing
│ ├── response.rs ← HTTP response building
│ ├── router.rs ← routing table
│ └── handler.rs ← route handlers
├── tests/
│ └── integration.rs ← integration tests
├── benches/
│ └── parsing.rs ← benchmarks
└── examples/
└── simple.rs ← example programsKey conventions:
src/main.rsis the binary entry point. If your project is also a library,src/lib.rsis the library root - other crates can depend on it.tests/contains integration tests. Each file is compiled as a separate crate that can only access yourpubAPI.examples/are standalone programs using your library. Run them withcargo run --example simple.benches/are benchmarks, run withcargo bench(Chapter 24).
main.rs and lib.rs. The binary imports from the library: use webserver::router::Router;. This lets you test your server logic as a library while keeping the binary thin - just startup and the server loop. We'll use this pattern when we add tests in Chapter 19.Re-exports for a Clean Public API
If you have a lib.rs, you can re-export key types so users don't need to know your internal module structure:
mod error;
mod request;
mod response;
mod router;
pub mod handler;
// Re-export the important types at the crate root
pub use error::ServerError;
pub use request::{Method, Request};
pub use response::Response;
pub use router::Router;
// Now users can write:
// use webserver::Request;
// instead of:
// use webserver::request::Request;pub use re-exports an item - it makes it available at a different path without moving the code. This is how the standard library works: std::io::Error is actually defined deep in a submodule but re-exported at std::io.
Exercise
- Split the Chapter 8 server into the module structure shown in this chapter. Verify it compiles with
cargo check. Watch for missingpubkeywords - the compiler will tell you exactly which items need to be public. - Add
cargo add chronoand timestamp each log line inmain.rs. Import it withuse chrono::Local;. - Create a
src/lib.rsthat re-exports the key types. Move the server loop frommain.rsinto apub fn run(addr: &str)function inlib.rs, and havemain.rscall it. - Try making
Response::bodyprivate and adding apub fn body(&self) -> &strgetter. Why might you prefer a getter over a public field? - Add a new handler module
src/handler/api.rsusing the directory convention. Makehandlera directory withmod.rsthat re-exports both the page handlers and the API handlers.
What's Next
The codebase is now organized: each concern in its own file, visibility carefully controlled, and a clean dependency graph between modules. This is the structure we'll build on for the rest of the guide.
In the next chapter, we'll learn traits and generics - how to write code that works with many types. We'll define a Handler trait to replace our function pointer, making the routing system more flexible and composable.