Skip to content

The Rust Engineering Handbook / Chapter 38

Packages, Crates, Modules, Visibility, and the Prelude

Choose package, crate, module, visibility, re-export, and prelude boundaries by the contract they enforce rather than by directory shape.

Organization becomes a contract

Ownership, traits, representations, and failure policy describe what one component may do. A growing Rust system needs a second kind of precision: where one component ends.

Rust offers several boundaries that look similar in a file tree but have different costs:

  • a package is Cargo’s distribution and build-configuration unit, described by a manifest;
  • a crate is one compilation unit and root namespace produced from a package target;
  • a module is a namespace and privacy subtree inside a crate;
  • visibility controls which paths may use an item;
  • a re-export chooses the public path without exposing the implementation path;
  • a prelude is an import convenience, not an encapsulation mechanism.

The governing rule for this part is simple:

Introduce the cheapest boundary that enforces the contract you actually need.

A new directory is cheap but enforces nothing by itself. A module can hide implementation and curate paths without creating a new compiled artifact. A crate creates a harder dependency and compilation boundary, but brings manifests, feature interactions, versioning questions, build work, and a public API between crates. A package decides how targets are configured and distributed. Treating all three as “folders” either under-designs the API or fragments the system prematurely.

One package can produce several crates

The chapter fixture is one package named relay-service-structure-lab. Its manifest defines a library target named relay_core and a binary target named relayd:

[package]
name = "relay-service-structure-lab"
edition = "2024"

[lib]
name = "relay_core"

[[bin]]
name = "relayd"
path = "src/main.rs"

Each target compiles as a crate. src/lib.rs is the library crate root. src/main.rs is a separate binary crate root. Even though they share one package and dependency declaration, the binary does not gain private access to the library. It imports relay_core as an external crate and uses its public API.

Cargo packages may also contain example, integration-test, and benchmark targets. Those targets are crates too. This distinction explains behavior that otherwise seems arbitrary: a unit-test module nested inside the library can see private ancestors under Rust’s privacy rules; a file under tests/ compiles as a separate crate and sees the library the way an external caller does.

Do not split into packages merely to get two executables. One package can contain multiple binary targets. Conversely, two crates cannot form an import cycle even when they live in one workspace; the dependency graph must remain directed. The next chapter will decide when that stronger boundary earns its cost. Here the first move is usually a library target for reusable policy plus thin binary targets for process wiring.

A module tree is not a directory inventory

Modules organize names and privacy inside one crate. The crate root declares the tree:

mod api;
mod ingest;
mod store;

pub use api::{AcceptedRecord, IngestRequest};
pub use ingest::{IngestError, accept};

Files supply module bodies; declarations establish the architecture. Moving store.rs into a store/ directory may improve navigation without changing its conceptual boundary. Declaring every file pub mod exposes the filing cabinet as an API.

Choose modules around reasons to change and invariant ownership. In the fixture:

  • api owns validated request and stable result types;
  • ingest owns orchestration and translation to public domain errors;
  • store owns a private persistence representation;
  • the crate root owns the caller-facing path.

That arrangement can remain one crate while allowing internal refactoring. A layered directory tree that permits every module to reach through every other module is only visual layering. Use private modules, narrow functions, and restricted visibility to make dependency direction inspectable.

Paths state where a name is resolved; use brings a path into scope. Imports do not change visibility and do not copy an item. Prefer paths that reveal the relevant ownership boundary without encoding incidental depth everywhere. Within a module, crate:: is explicit from the crate root, super:: walks to a parent, and self:: begins locally. Excessive chains such as crate::adapters::persistence::internal::v2::Record in business logic signal that the public internal seam has not been curated.

Visibility is a reachability rule

Items are private by default, with language-defined exceptions such as public enum variants and associated items of public traits. A public item is externally usable only when its path is reachable: its ancestor modules must be accessible, or it must be available through a public re-export.

Rust’s restricted forms express several useful scopes:

Form Intended reach Typical job
private current module and descendants representation and helper details
pub(super) parent module sibling collaboration mediated by the parent
pub(crate) anywhere in this crate crate-wide internal service or constructor
pub(in path) named ancestor scope bounded subsystem seam
pub any caller that can reach the path supported external surface

pub means visible, not stable, safe, documented, or wise to call. Those properties come from API policy and review. pub(crate) also should not become a default escape hatch: it expands coupling across the entire compilation unit. Start private, widen to the smallest scope supported by a real caller, and keep tests from dictating production visibility.

The fixture’s IngestRequest has private fields, a public validating constructor, and pub(crate) accessors used by implementation modules. Its normalizer is pub(super), allowing its parent to coordinate it without exposing the path to external callers. The storage module remains private even though some of its items are pub(crate). Visibility composes with ancestry.

This is a design tool, not a security sandbox. Code in the same process may have other authority, and public callers can still supply hostile input. Privacy prevents unsupported name access at compile time; it does not authenticate callers or isolate memory.

Re-exports let API shape differ from implementation shape

pub use gives an item a public path chosen by the exporting module. The fixture keeps api and ingest private, yet callers write:

use relay_core::{IngestRequest, accept};

They do not depend on relay_core::ingest::accept. That freedom matters. The implementation can move between internal modules while the crate-root path remains stable. A re-export is therefore an API decision, not import tidiness.

Avoid exporting the same type through many equally prominent paths. Duplicate paths complicate documentation, discovery, examples, and later deprecation. A crate root should present the common coherent surface; deeper public modules should exist when their namespaces carry real meaning or prevent name collision.

Re-exports do not erase type identity or dependency exposure. If a public function accepts a type from another crate, callers must be able to name and construct that type. The dependency has entered the effective API even when it is re-exported or hidden behind an alias. Its traits, versions, feature choices, and SemVer changes can constrain callers.

Prefer your own stable domain type when the third-party representation is incidental. Expose the dependency type when interoperability is the point and accept the coupling deliberately. Do not wrap every type reflexively: wrappers add conversions, documentation, and ecosystem friction. Record which dependencies are public API, test their supported versions, and avoid leaking a concrete backend from an abstraction that claims it can change.

Read the nested boundaries from outside inward

One Cargo package contains separate library and binary crate targets. The library owns a private module tree and exposes selected names through crate-root re-exports; the binary and an integration-test crate use only that public surface. Visibility bands distinguish private, parent, crate, and public reach, while a small opt-in prelude contains common stable names rather than the entire API.

Figure 38-1 is a nesting map and a visibility map. The package surrounds targets because its manifest configures them. The library and binary are peers as crates; the binary is not an inner module of the library. Modules sit inside the library crate. The integration test sits outside that crate and crosses its public boundary.

The amber re-export band is intentionally narrower than the module tree. External callers depend on the curated path, not on every internal name. The prelude is off to the side because importing it is optional caller behavior; it neither defines the module tree nor grants access through private boundaries.

The figure also marks the escalation cost. Adding a module creates an internal namespace/privacy boundary. Adding a crate creates a compilation and dependency edge. Use a crate when you need independent compilation policy, a non-cyclic dependency boundary, distinct platform or feature constraints, reusable distribution, or an API enforced between large components—not merely because a directory became long.

Preludes should optimize the common reading path

Rust code encounters multiple kinds of prelude. The language and standard library automatically make a defined set of names and traits available according to the edition. The standard-library prelude is why common types and traits work without explicit imports. Editions can change which standard names are included, which is one reason an edition belongs to a crate boundary rather than being a vague repository setting.

A library may also provide an explicit module conventionally named prelude:

pub mod prelude {
    pub use crate::{IngestRequest, RelayServiceExt};
}

Callers opt in with use relay_core::prelude::*;. This can help a framework whose common types and extension traits otherwise require repetitive imports. It can also hide where methods originate, increase name collisions, and make examples harder to read.

Keep a crate prelude small, unsurprising, and stable. Favor commonly required traits whose methods callers need, plus a few central types. Do not export the entire crate, backend-specific adapters, macros with broad effects, or names likely to collide. Documentation should show explicit imports in focused examples when provenance teaches the API; reserve glob-prelude imports for contexts where they genuinely improve the common path.

A project-local prelude used internally deserves the same restraint. A grab bag of utilities makes dependencies invisible and every module appear coupled to everything. Imports are useful architectural evidence. Do not erase that evidence for a few lines of typing.

Facade crates are versioned promises

A facade crate re-exports selected APIs from several underlying crates through one coherent entry point. It can simplify onboarding, align versions, and hide a workspace’s internal decomposition. It is appropriate when downstream users want one product surface while maintainers need several internal crates.

The costs are real:

  • the facade must coordinate feature behavior and versions across dependencies;
  • re-exported dependency types remain part of compatibility analysis;
  • documentation can become an index of links instead of a coherent guide;
  • dependency weight may surprise callers if the facade enables too much by default;
  • every alternate import path complicates deprecation.

Do not create a facade to make a fractured architecture look unified. First define the supported concepts and dependency direction. A facade should be a deliberate compatibility layer, not a barrel file that publicly exports every symbol found in a workspace.

For an application with one library and thin binaries, the package library itself often serves as the internal facade. It lets integration tests and binaries exercise the same supported surface. Split a separately published facade only when distribution and compatibility policy require it.

Tests reveal the boundary you actually built

Unit tests placed as child modules can inspect private implementation. Use them for algorithms, representation invariants, and failure injection that external callers cannot express. Integration tests under tests/ compile as separate crates and can use the library’s public API. Use them to prove the API is sufficient without privileged reach-through.

If an integration test needs an internal constructor, first ask whether a real caller needs it. If yes, design a supported public test seam or public constructor. If no, move the test beside the implementation or build the state through public behavior. Making an item pub solely to appease an external test creates accidental compatibility surface.

The fixture demonstrates both views. A nested unit test calls the private normalization function. The integration test imports relay_core::prelude::* and crate-root functions; attempts to name relay_core::store or relay_core::ingest::normalize would fail privacy checking. The binary has the same external view, which prevents process wiring from depending on library internals.

Compile-fail tests can preserve negative boundary claims, but ordinary compilation already checks every used path. When privacy itself is a central public guarantee, a UI test or documentation test marked compile_fail can make the rejected path visible. Avoid brittle assertions on full compiler wording; error codes and essential path facts are more durable.

Boundary shape affects build and operations

Module boundaries disappear inside one crate’s compilation unit. That permits broad compiler analysis and simple type sharing, but a change anywhere in the crate can invalidate work for the crate and its downstream dependents. A new crate may permit more parallel or incremental work when its public interface stays stable, yet it also adds metadata loading, code generation units, linking inputs, and another dependency edge. Measure representative clean and incremental builds before splitting for speed.

Crate boundaries also affect configuration. Features are selected for packages and influence their targets; conditional compilation can make public surfaces differ across builds. A module does not get an independent feature universe merely because it has its own file. If two components truly require incompatible dependency versions, target policies, no_std constraints, or platform support, a crate boundary may express that difference. If they only need internal privacy, a module is cheaper and easier to evolve.

Operational ownership rarely maps one-to-one to crates. A team can own a module through review policy, or several teams can contribute to one public crate. Conversely, putting each team in a crate does not remove coordination when types and release schedules remain coupled. Use CODEOWNERS, review rules, API tests, and dependency direction as organizational tools; do not ask Cargo alone to solve governance.

Observability deserves boundary discipline too. A reusable library should not install a global subscriber, panic hook, allocator, or process exit policy merely because it is the “core” crate. Libraries return structured information and accept explicit capabilities; binaries own process wiring, configuration, signals, global telemetry setup, and exit behavior. Thin binaries are not logic-free—they own policy that is inherently process-scoped.

Security review follows the supported surface, not just pub. Build scripts, proc macros, native dependencies, unsafe internals, and feature-selected code can carry authority without appearing in a runtime API. Chapter 38’s visibility map answers who can name an item; later Cargo and supply-chain chapters will answer what code enters the build and with which capabilities. Keep those questions separate so a small public API is not mistaken for a small attack surface.

Documentation should mirror the public contract. Re-exported items need discoverable rustdoc paths, examples should import the supported surface, and hidden internal modules should not be the only place explaining invariants callers rely on. If the public API cannot be documented without describing private layout, the abstraction boundary may be leaking conceptually even when privacy checking passes.

Boundary mistakes that look tidy

A crate per directory. Build overhead, dependency edges, manifests, and version policy multiply without independent contracts. Begin with private modules; split only when a stronger boundary pays.

One giant crate with pub(crate) everywhere. Any subsystem can reach every representation. Narrow module visibility and introduce internal interfaces before deciding whether a crate split is necessary.

pub mod for navigation. The filesystem layout becomes a public promise. Keep implementation modules private and re-export intentional names.

A universal prelude. Call sites hide trait provenance and accidental coupling. Export only the stable common vocabulary, and keep explicit imports in teaching and boundary code.

A private dependency in public clothing. A public signature leaks a backend type through an alias or generic bound. Own a domain type or document and version the dependency as public API.

Integration tests with secret doors. Features or public testing modules expose internals merely for tests. Use unit tests, test-support crates with explicit scope, or public behavioral seams.

Binary owns the domain. Logic lives in main.rs, so integration tests and other binaries cannot reuse it without process-level testing. Move reusable contracts to the package’s library crate and keep binary wiring thin.

Facade as wildcard. Every internal crate is glob-re-exported. Downstream code binds to internal decomposition. Curate one product vocabulary and keep implementation paths non-public.

Review organization as dependency policy

For each proposed boundary, ask:

  • Is this a package, crate, module, or merely a directory, and which tool enforces it?
  • Which invariant or reason to change does the boundary own?
  • Can a private module satisfy the need before adding a crate?
  • Which target is each crate root, and which dependencies does it compile with?
  • Does the binary use only the library’s public API?
  • Is every pub, pub(crate), or pub(super) justified by a named caller?
  • Are public paths curated with re-exports rather than inherited from storage layout?
  • Which third-party types or traits appear in public signatures?
  • Does the prelude contain only common stable vocabulary?
  • Do integration tests prove external usability while unit tests retain private coverage?
  • Would moving files change architecture, or only navigation?
  • What future split does this module structure enable without promising it prematurely?

Exercise: reorganize without unnecessary crates

Level: Architecture refactor. A flat package has main.rs, twelve sibling files, three binaries that copy validation logic, a utils.rs imported everywhere, database row types in public functions, and integration tests that require several internals to be pub.

Deliver:

  1. a target inventory naming the package, library crate, binary crates, integration-test crates, and crate roots;
  2. a module tree grouped by invariant ownership rather than file count;
  3. a visibility table for at least twelve important items, with the narrowest justified scope and named callers;
  4. a crate-root re-export surface whose paths remain stable if persistence modules move;
  5. a decision for every exposed database type: intentional public dependency or conversion to an owned domain type;
  6. a prelude containing no more than the common types and extension traits justified by three representative callers;
  7. a test placement plan separating private invariant tests from public integration behavior;
  8. one proposed future crate split, plus evidence that it is not yet required or a concrete reason it is required now.

Implement the refactor in dependency order and keep all three binaries thin. Reject a solution that creates a crate for every top-level module or fixes test access by making the implementation public.

Durable conclusions

  • A package configures and distributes targets; each target compiles as a crate; modules organize names and privacy within a crate.
  • The binary and library targets of one package are separate crates, so the binary uses the library’s public surface.
  • Visibility is path reachability, not a claim of stability, safety, or authorization.
  • Re-exports let supported API paths remain stable while private implementation modules move.
  • Third-party types in public signatures create real dependency coupling even when paths are aliased or re-exported.
  • A crate prelude is an opt-in convenience surface and should remain smaller than the API.
  • Unit tests can inspect private ancestors; integration tests are external crates and validate public usability.
  • Filesystem shape is navigation. Add modules and crates only for contracts they enforce.

The package now has a coherent internal and external surface. The next decision is more expensive: when should modules become crates in a workspace, and how do compile time, cyclic-dependency prevention, feature propagation, ownership, and release policy change when they do?

Sources and verification notes