In multi-paradigm languages such as Python, C++, and Rust, it is not always easy to see what should be in the same file. In particular with Rust, it is not even necessary to keep data definitions (structs) in the same module/file as interface definitions (traits) or method implementations (impl blocks). This gives a lot of freedom and flexibility in structuring your code within a crate.
There are a couple of relevant constraints though:
Modules/files are the namespaces that determine visibility/encapsulation unless an item is made pub
/pub(crate)
or similar. Thus, closely related items will typically be in the same module.
Rust's unit tests are functions that are embedded into the module they test. This makes it convenient to test helper functions thoroughly, but also means that files tend to grow more quickly than in other languages where test suites are separated from the source code.
What I try to think about when writing a module is something like the Single Responsibility Principle. Not necessary “a [module] should have only one reason to change”. Instead, I tend to think more about the public interface offered by this module to other consumers. This interface should have a clear and cohesive theme.
Sometimes, this results in a fairly OOP-like approach where a module/file declares a pub struct, implements some inherent methods, implements various traits, and contains some unit tests. Sometimes, there is no single type but multiple related types, or a couple of functions that are closely related together. Sometimes, a module (especially a lib.rs
file) just serves as a facade for some internal modules.
If the module grows large there might be parts that are somehow separate from the other contents of the module. Those are good candidates to be extracted to a new module. Sometimes these parts belong to a different Bounded Context in the Domain-Driven Design sense. Sometimes I just want to encapsulate the contents of a data structure to make the code more robust.
For example, I have an application with one module that defines some error types that are used throughout the code. But rendering human-readable error messages is a thorny problem that requires tons of helper functions in order to provide useful context for errors. This was making it difficult to maintain an overview of the code, so I moved the helpers into a separate module. This module now makes a format_error()
function available in pub(crate)
scope, plus a handful of helper types. The module's internal complexity no longer gets in the way. Now that I look at the code, I could probably subdivide it further.
If we look at other ecosystems such as JavaScript, we sometimes see very small modules that only contain a single function or such. This would also be perfectly possible in Rust, but I don't think it is useful. First, JavaScript's tendency for small modules is often motivated by tree-shaking, but Rust has #[cfg(...)]
attributes for that. Second, very small Rust modules would likely result in overuse of pub(crate)
scope, thus negating the benefits of using modules for encapsulation. I think the perfect Rust module is 80–700 lines large, possibly more if there are thorough docstrings.
You also lamented the difficulty of tracking down code at times. Indeed, Java or C# make this very easy typically because files are named like ClassName.cs
. While file organization can help, this is ultimately more of a tooling problem, I believe. Potential strategies to help:
Use the capabilities of your IDE/editor, especially when powered by the Rust-Analyzer language server. Go-to-definition, find-references, or a sidebar with all declarations in the current file are super helpful. I rarely open files, and more often follow references/imports.
Try cargo doc --document-private-items
. This will build the API documentation for your code including all dependencies, and includes a fuzzy search function.
Sometimes, a brute-force search is very useful. On the command line, tools such as ack
, ripgrep
, or git grep
are helpful for recursive searches. Most IDEs have features to quickly search the current file or all files of a project. On Emacs, I absolutely love helm-swoop for narrowing down matches. Rust has a grep-friendly syntax where names of items are directly preceded by a keyword indicating the type, e.g. fn name
or enum Name
, the exception being impl blocks.