Compile-Time Boundaries and Architectural Optionality

·7 min read

Architectural discipline begins with the question: what can we defer, and what must we decide now?

In most systems, that question gets answered implicitly. Dependencies accumulate. Abstractions leak. By the time you're debugging a production incident, you've forgotten which layers were supposed to be optional and which are load-bearing.

Rust doesn't let you forget. Today, while implementing logging adapters for sheen, I was reminded why.

The Problem: Multiple Logging Backends in Rust

The Rust ecosystem has two dominant logging approaches:

  1. log — a lightweight facade. Libraries log through it, applications provide a backend.
  2. tracing — structured, span-based observability. More powerful, heavier, async-aware.

If you're building a library that needs to emit diagnostics, you face a choice: depend on log, depend on tracing, or roll your own abstraction and support both.

The naive solution: bundle both. Detect at runtime which one the user has configured, and route logs accordingly.

This is what most ecosystems do. It's ergonomic. It's convenient.

It's also wrong.

Why Bundling Is an Architectural Smell

When you bundle optional dependencies, you're making a runtime decision that should have been made at compile time. You're saying: "I don't know which of these you'll need, so I'll ship both and let you figure it out."

The costs:

  • Binary bloat. Every user pays for code they don't use.
  • Coupling. Your abstraction now depends on both logging crates, even if the user only wants one.
  • Hidden complexity. The branching logic lives inside your library. Users can't see it. They can't reason about it without reading your source.

In a dynamic language, this is unavoidable. You can't eliminate unused code paths at compile time because you don't know what will execute until runtime.

Rust gives you a choice. Feature flags let you push that decision upstream.

The Discipline of Feature Flags

Here's how I structured sheen's adapters:

// Core abstraction (no logging dependencies)
pub trait Logger {
    fn log(&self, level: Level, message: &str);
}
 
// Adapter for `log` crate (behind "log" feature)
#[cfg(feature = "log")]
pub struct LogAdapter;
 
#[cfg(feature = "log")]
impl Logger for LogAdapter {
    fn log(&self, level: Level, message: &str) {
        log::log!(level.into(), "{}", message);
    }
}
 
// Adapter for `tracing` crate (behind "tracing" feature)
#[cfg(feature = "tracing")]
pub struct TracingAdapter;
 
#[cfg(feature = "tracing")]
impl Logger for TracingAdapter {
    fn log(&self, level: Level, message: &str) {
        tracing::event!(level.into(), "{}", message);
    }
}

No default features. If you depend on sheen, you get the core abstraction and nothing else. To actually log, you must explicitly enable log or tracing:

[dependencies]
sheen = { version = "0.1", features = ["log"] }

Or both:

sheen = { version = "0.1", features = ["log", "tracing"] }

The key insight: the library doesn't decide what you ship. You do.

What This Looks Like at Scale

This pattern isn't just for logging. It's everywhere in Rust's backend ecosystem:

  • Serialization: serde supports JSON, YAML, TOML, MessagePack—all behind feature flags.
  • HTTP clients: reqwest lets you opt into rustls vs OpenSSL, blocking vs async, cookies, gzip—each a separate feature.
  • Databases: sqlx supports Postgres, MySQL, SQLite—pick one, pay for one.

The discipline compounds. When every layer of your stack follows this pattern, you build systems where:

  1. Dependencies are legible. Run cargo tree --features and see exactly what you're shipping.
  2. Coupling is explicit. If a feature pulls in a heavy dependency, that's visible in the feature graph.
  3. Composition is user-controlled. The application decides the tradeoffs, not the library author.

This is architectural discipline enforced by the compiler.

The Cost of Explicitness

There's friction here. Users have to read docs. They have to make decisions. They might get it wrong the first time.

In contrast, the "just works" approach—bundle everything, auto-detect at runtime—has zero cognitive load. You add the dependency, and it figures itself out.

So why not do that?

Because deferred decisions are technical debt.

Every runtime branch is a code path you have to test. Every bundled dependency is a supply chain risk. Every implicit coupling is a future refactor waiting to happen.

Rust's feature flags front-load that complexity. You pay the cost at integration time, when you're actively thinking about dependencies. In exchange, you get:

  • Smaller binaries.
  • Faster compile times (unused features aren't compiled).
  • Clearer contracts (the feature list documents what's optional).

Most importantly, you avoid runtime surprises. There's no "wait, why is this library trying to initialize a logger I didn't configure?" moment. If you didn't enable the feature, the code doesn't exist.

When Explicitness Is Wrong

This pattern isn't universal. There are cases where feature flags add more friction than value:

  1. Stable, universal dependencies. If everyone needs serde, just depend on it. Feature-flagging it is ceremony.
  2. Tightly coupled features. If feature A only makes sense with feature B, they shouldn't be separate flags.
  3. Internal implementation details. If the choice doesn't affect the public API, don't expose it as a feature.

The heuristic: feature flags should map to user-facing decisions, not implementation details.

If you're feature-flagging because "the user might not need this," ask: does the user even know this exists? If not, it's probably not a feature; it's an internal abstraction you're leaking.

Leadership Implications

Here's where this becomes a technical leadership question.

On a team, feature flags create coordination costs. Someone has to document them. Someone has to test combinations. Someone has to field questions when users enable the wrong set.

In a high-trust, slow-moving environment, that's fine. You write the docs, you own the combinations, you support the users.

In a fast-moving startup, it might be the wrong tradeoff. Ship the batteries-included version. Optimize for iteration speed, not binary size.

The leadership question: what's your team's relationship with optionality?

If you're building infrastructure that will be reused across many projects—an internal platform, a shared library, a framework—then explicitness pays off. You're forcing each consumer to think about what they need, and that thinking prevents drift.

If you're building a one-off service under deadline pressure, explicitness might be premature optimization. Ship it working, refactor later if you need to.

Rust doesn't make that decision for you. It gives you the tools to express optionality cleanly. Whether you use them is a judgment call.

The Compiler as Architectural Review

What I appreciate about Rust's approach is that the decision is visible in the code.

When you see:

[dependencies]
sheen = { version = "0.1", features = ["log", "tracing"] }

...you know someone made a choice. They decided to pull in both logging backends. Maybe that's intentional. Maybe it's a mistake. Either way, it's legible.

Contrast with a runtime approach:

const logger = require('universal-logger');

What did you just pull in? Which backends? Which transitive dependencies? You'd have to read the source or inspect node_modules to know.

Rust's feature flags make the dependency graph a first-class part of the architecture. They turn optionality from a runtime concern into a compile-time contract.

And when the compiler enforces your architectural boundaries, you can't accidentally violate them. You can't merge a PR that silently bundles a dependency you didn't mean to ship. The CI build fails.

This is what I mean when I say Rust enforces architectural discipline. It's not about the borrow checker or memory safety—it's about making implicit decisions explicit, and letting the type system hold you to them.

Closing Thought

The hardest part of architecture isn't choosing the right abstraction. It's choosing where to draw the boundaries.

Rust's feature flags are a tool for drawing those boundaries explicitly. They let you defer decisions to the right layer—not runtime vs compile-time, but library author vs application developer.

The library provides capabilities. The application composes them. The compiler ensures the composition is sound.

That's the discipline: knowing what you control, what you defer, and what you enforce.

It's a small decision—how to structure a logging adapter—but it reflects a larger principle. When you build systems where optionality is explicit and composition is user-controlled, you're not just writing better Rust.

You're practicing technical leadership. You're making the right things easy and the wrong things hard. And you're building systems that stay legible as they scale.

Comments

Participate in the discussion