
1. The Hook
Every Rust project starts the same way.
cargo new my_projectOne crate, one src/main.rs, and honestly, it feels great. For the first few weeks, it is great.
Then the project grows. You add a database layer. Then an HTTP client for some external API. Then business logic to tie everything together. Then shared models that both your database code and your API code need. Then a handful of utility functions that don’t quite belong anywhere, so they land in a utils.rs that quietly becomes a junk drawer. Then background workers, because nothing in production stays synchronous forever.
All of it lives inside src/.
At some point — usually right when the project starts to matter — a question shows up that a single crate can’t really answer:
When should you split your project into multiple crates?
This is my answer, built from restructuring a real production system: a blockchain event indexer. I’ll be upfront that the blockchain part isn’t the point here. It’s just the example I know best. What actually matters is the architecture — how a Rust workspace turned a growing monolith into a set of independent, testable, reusable crates, and what I’d do differently if I started over.
2. The Problem With a Single Crate
A single-crate project doesn’t fail loudly. It fails slowly, through friction that piles up until complaining about the codebase just becomes part of the standup.
Growing compile times. In a single crate, the compiler treats your whole codebase as one compilation unit. Change one function in a file nobody important depends on, and you still wait for everything downstream to rebuild. The bigger the crate, the longer every incremental build gets.
Tight coupling. Without boundaries enforced by the compiler — only folders — nothing stops your business logic from reaching into database internals, or your HTTP layer from quietly importing a worker’s private state. The compiler won’t say a word. Your future self will.
Circular dependencies. Modules inside a single crate can reference each other in ways that would be flat-out illegal across crate boundaries. This feels convenient right up until you try to pull one piece of logic out and realize it’s tangled up with five other modules that all import each other.
Difficult testing. Testing one specific concern — say, your database layer — often drags in parts of the system that have nothing to do with what you’re actually testing, simply because everything shares one compilation unit.
Poor code ownership. On a team, “who owns this file” turns into a git-blame exercise instead of an architectural fact. There’s no clean line that says “this is infrastructure” versus “this is product logic.”
Hard to reuse components. Want your database layer or your models in a second binary — a CLI tool, a second service, a test harness? You either copy-paste code or drag the entire application into a dependency just to borrow one struct.
None of these problems are fatal on their own. Together, they’re the reason large single-crate Rust projects start to feel heavier than the language itself usually feels.
3. Why a Workspace
Rust’s answer to this is the workspace — a way to group multiple crates under one root Cargo.toml, sharing a single Cargo.lock and a single target/ directory, while each crate still compiles as its own independent unit.
This isn’t just a style preference — it changes the compiler’s unit of work. Instead of one giant crate that recompiles as a whole, you get several smaller crates the compiler can build and cache independently. Touch your HTTP client crate, and your database crate doesn’t rebuild at all.
The practical benefits:
- **Shared **Cargo.lock — every crate resolves dependencies against the same lockfile, so you never end up with two crates silently pulling incompatible versions of the same library.
- Shared dependency management — common dependencies can be declared once at the workspace level and inherited, instead of duplicated (and slowly drifting) across every crate’s Cargo.toml.
- Independent compilation units — each crate builds on its own, so incremental builds only touch what actually changed.
- Enforced boundaries — crate boundaries are compiler-enforced, not just a folder convention. If crate A doesn’t depend on crate B, A simply cannot see B’s internals.
- Easier long-term maintenance — responsibilities map onto physical crates, so the codebase stays legible to someone joining six months from now.
A workspace won’t magically produce good architecture on its own. What it gives you is a tool to enforce an architecture you’ve already decided on — and that’s really the whole value.
4. My Project Structure
Here’s the shape I landed on for the indexer, after living through exactly the pain described above:
indexer/
├── Cargo.toml # workspace root
├── crates/
│ ├── shared/ # database, network, common models, utils
│ ├── app-core/ # application crate: business logic & workers
│ └── ... # room for future cratesThe workspace root Cargo.toml doesn't hold any application code. Its only job is to declare workspace members and, optionally, workspace-level dependency versions that member crates inherit. It's infrastructure, not logic.
crates/shared is the foundation layer. It has no idea what the application actually does — no knowledge of specific business events, specific workflows, or specific integrations. It just knows how to talk to a database, how to make network calls, what the common data models look like, and a small set of genuinely shared utilities.
crates/app-core is where the real product lives: the business rules, the event collectors that pull data in, the background workers that process it, the Redis-backed caching, the cron-scheduled jobs. This crate depends on shared. shared does not — and must not — depend on it.
That trailing ... matters more than it looks. Once the pattern exists, adding a second application crate (a new integration, a CLI tool, an admin service) just means creating a new crate under crates/ and depending on shared. You're not refactoring the foundation to make room for it — it was already built to be reused.
5. Separating Responsibilities
The real design decision isn’t “how many crates should I have.” It’s “what does each crate know about, and what is it not allowed to know about.”
shared owns:
- Database — connection pooling, query helpers, schema types. No opinions about when or why a query happens, just how to run it correctly.
- Network — HTTP client setup, retry policies, request/response plumbing. Generic infrastructure, not integration-specific behavior.
- Common models — data structures that more than one application crate actually needs to agree on. If only one crate uses a type, it probably doesn’t belong here.
- Utilities — genuinely cross-cutting helpers (time handling, serialization, shared error types). Not a dumping ground for “didn’t know where else to put it.”
The application crate owns:
- Business logic — the actual rules that make the product the product.
- Event collectors — the code that decides what to fetch, when, and what to do with it.
- Workers — background processes that consume work and execute it.
- Redis — caching and state that’s specific to how this particular application behaves.
- Cron jobs — scheduled behavior tied directly to business needs.
Here’s the shortcut that made this click for me: shared answers "how do we do this kind of thing," and the application crate answers "what are we doing, and why." A database connection pool is a "how." The decision to retry a failed job three times before giving up is a "why." Mixing those two together in one file is exactly what makes single-crate projects hard to navigate — you can't tell, at a glance, whether you're looking at infrastructure or intent.
This split also gives you a simple rule for where new code goes. Not sure which crate a new function belongs in? Ask: does this need to know anything about this specific application’s business rules? If yes, it belongs in the application crate. If it would make just as much sense in a completely different application built on the same foundation, it belongs in shared.
6. Benefits
Once the split settled in, the payoff showed up in a few very concrete ways:
- Better modularity. Each crate has one job, and its public API is the only door into it. Internals stay internal.
- Faster development. Incremental builds only touch the crate that changed, so the edit-compile-test loop while working on a worker no longer waits on the database layer recompiling.
- Easier testing. shared can be unit-tested in isolation, without spinning up any business logic at all. The application crate's tests can stub shared's public interfaces instead of fighting tightly coupled internals.
- Reusability. shared is now a library in the true sense — any future crate in the workspace (a second application, an admin CLI, a metrics service) can depend on it without dragging in unrelated code.
- Cleaner APIs. Because shared is consumed from outside its own module, its public surface gets designed on purpose, not by accident. You start asking "should this really be pub?" instead of defaulting everything to public because it's all one crate anyway.
- Better team collaboration. Crate boundaries double as ownership boundaries. It’s easy to say “shared is infrastructure-owned" and "the application crate is product-owned," and have the compiler back that up instead of leaving it to a code review convention.
7. Trade-offs
None of this is free, and it’s worth being honest about the cost.
More upfront planning. You have to decide what counts as “shared” before you fully understand what shared even means yet. Get it wrong, and you’ll be moving code between crates later — though to be fair, that’s still far easier than untangling a single monolithic crate.
Public API design becomes real work. Every pub in shared is now a promise to every crate that depends on it. Changing a function signature isn't a quick local edit anymore — it ripples outward. It's a genuine tax, and it means thinking harder before exposing something.
Dependency management gets more deliberate. With multiple Cargo.toml files, you have to actively manage which crate depends on which external library and keep versions aligned. Workspace-level dependency inheritance helps a lot, but it doesn't remove the need to think about it.
Avoiding cyclic dependencies takes discipline. The compiler will flatly refuse a genuine cycle between crates — which is a feature, not a bug — but it means occasionally stopping to ask “wait, does this really need to depend on that?” before adding a path = "../other-crate" line. Sometimes the honest answer is that a piece of logic needs to move, or a small third crate needs to exist to hold a shared abstraction.
The trade-off, in short: a single crate lets you defer architectural decisions until they become painful. A workspace makes you face them earlier — more work up front, a lot less work over the life of the project.
8. Lessons Learned
A few things I’d tell myself before doing this again:
Don’t split too early. A brand-new project doesn’t need a workspace on day one. Splitting before you understand your own domain boundaries just adds ceremony to code that’s still finding its shape. Let the pain from Section 2 actually show up before you go solve it.
Split by responsibility, not by file count. “This file got long” isn’t a reason to pull out a crate. “This code represents a distinct concern with its own reason to change” is. A crate boundary should map to a concept, not a line count.
Keep crate APIs small. Every public item is a commitment. A shared crate with a sprawling public surface is barely better than no boundary at all — it's just a monolith with extra Cargo.toml files. Expose the minimum other crates actually need, nothing more.
Treat every crate like an independent library — even the internal ones. The moment you catch yourself thinking “well, I know the application crate is the only one calling this, so I’ll just make it convenient for that one caller,” you’ve quietly reintroduced coupling with extra steps. Design each crate as if a stranger on a completely different project is going to depend on it someday. That mindset is what actually earns you reusability — the folder structure alone won’t do it.
The workspace is just the mechanism. The real architecture is the discipline of separating “how” from “why,” infrastructure from intent. Get that part right, and the crate boundaries just make it stick.