Enforcing Data Trust Boundaries in Rust with Module Privacy and Newtypes
Use Rust's module privacy and newtype pattern to make invalid data unrepresentable: private fields, a public Result-returning constructor, and re-exports only of validated types. Enforce boundaries with clippy, cargo doc audits, and compile-fail tests.
06 Sept 2026, 14:02 UTC

The Problem: Invalid Data Reaching Business Logic
In a typical service, parsing, validation, and business logic live in different modules. Without compiler-enforced boundaries, a caller can bypass validation by constructing domain types directly or accessing private fields through pattern matching. Rust's module system and the newtype pattern solve this by making raw representations inaccessible outside their defining module, so the compiler rejects any code that tries to create invalid instances.
Requirements
- Prevent construction of domain types without validation.
- Keep validation logic co-located with the type definition.
- Expose only validated types to downstream modules.
- Make boundary violations a compile-time error, not a runtime check.
Smallest Suitable Design
Define a struct with private fields in its own module. Provide a single public constructor that returns Result<ValidatedType, ValidationError>. Re-export only the validated type and its error; never expose the raw fields or a public new that skips checks.
// src/domain/email.rs
pub struct Email(String); // newtype, private field
#[derive(Debug, thiserror::Error)]
pub enum EmailError {
#[error("missing @ sign")]
MissingAt,
#[error("invalid domain")]
InvalidDomain,
}
impl Email {
/// Parses and validates an email address.
pub fn parse(input: &str) -> Result {
if !input.contains('@') {
return Err(EmailError::MissingAt);
}
let domain = input.split('@').nth(1).unwrap_or("");
if !domain.contains('.') || domain.starts_with('.') || domain.ends_with('.') {
return Err(EmailError::InvalidDomain);
}
Ok(Email(input.to_owned()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
// Re-export only the validated type and its error
pub use self::Email;
pub use self::EmailError;
Downstream code imports Email and calls Email::parse. It cannot write Email("bad".into()) because the tuple field is private. It cannot pattern-match Email(s) to extract the inner String for the same reason.
Trust Boundaries at Module Edges
Draw the trust boundary at the module that defines the validated type. Parsing modules (e.g., HTTP request handlers, config loaders) call the constructor and handle the Result. Internal modules receive Email (or &Email) and assume validity—no defensive checks needed.
For serialization boundaries (JSON, database, message queues), use separate DTO types. This prevents internal invariants from leaking into external contracts and avoids serde derives generating public accessors that bypass privacy.
// src/dto/email_dto.rs
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmailDto {
pub value: String,
}
impl From for EmailDto {
fn from(e: Email) -> Self {
EmailDto { value: e.0 }
}
}
impl TryFrom for Email {
type Error = EmailError;
fn try_from(dto: EmailDto) -> Result {
Email::parse(&dto.value)
}
}
The DTO is public and derivable; the domain type stays opaque. Conversion is explicit and fallible.
Operational Checks
- Unit tests for rejection paths. Exercise every
Errvariant in the constructor.#[cfg(test)] mod tests { use super::*; #[test] fn rejects_missing_at() { assert!(matches!(Email::parse("nodomain"), Err(EmailError::MissingAt))); } #[test] fn rejects_bad_domain() { assert!(matches!(Email::parse("a@b"), Err(EmailError::InvalidDomain))); } #[test] fn accepts_valid() { assert!(Email::parse("[contact removed]").is_ok()); } } - Clippy lints for missing docs on public items. Run
cargo clippy -- -D missing_docsin CI. This forces documentation on every public constructor and error variant, making the API surface visible. - Deny warnings in CI. Add
#![deny(warnings)]to the crate root or useRUSTFLAGS=-D warnings. Accidentalpubfield additions or unused imports become build failures. - Visibility audit with
cargo doc. Runcargo doc --no-deps --openand inspect the generated HTML. OnlyEmail,EmailError, and their public methods should appear. No private fields, no internal modules.
Failure Modes and Detection
| Failure Mode | How It Bypasses Validation | Detection |
|---|---|---|
Over-broad pub fields | pub struct Email(pub String); allows direct construction | Code review; cargo doc shows field |
unsafe transmute | std::mem::transmute or pointer casts forge instances | Audit unsafe blocks; deny unsafe_code lint |
| Serde derives on domain type | #[derive(Deserialize)] generates a public constructor | cargo expand in CI to inspect generated code |
| Pattern matching in same crate | Module siblings can match Email(s) if field is pub(crate) | Keep fields fully private; test with #[cfg(test)] module in another file |
Add a test module that attempts to violate invariants; the compiler should reject it:
// tests/visibility_check.rs
use my_service::domain::Email;
#[test]
fn cannot_construct_directly() {
// This line must fail to compile:
// let _ = Email("evil@input".to_string());
}
#[test]
fn cannot_pattern_match() {
let e = Email::parse("[contact removed]").unwrap();
// This must fail to compile:
// let Email(inner) = e;
}
Run cargo test --test visibility_check. If it compiles, the boundary is broken.
When the Design Changes
- Plugin architectures or dynamic loading. Compile-time boundaries don't cross
dlopenboundaries. Use runtime validation and versioned schemas. - FFI or
no_stdcontexts.unsafeFFI wrappers need their own validation layer; the Rust-side newtype still helps but cannot protect against C callers. - Distributed serialization (e.g., protobuf, Avro). Schema evolution requires explicit versioning; newtypes alone don't handle wire-format changes.
- Multiple crates with shared domain types. If the validated type lives in a separate crate, consumers can still only call the public constructor. The same rules apply—just ensure the crate boundary matches the trust boundary.
Limitations
- Rust guarantees memory safety, not semantic correctness of external data. Network boundaries still need runtime validation (rate limiting, size limits, schema checks).
- Derive macros (
serde,diesel,utoipa) may generate public constructors or field accessors. Verify withcargo expandin CI. - This pattern assumes a single-process, single-crate (or workspace) service. Introducing
unsafe,no_std, or cross-language calls changes the trust model and requires separate review.
Practical Verification Checklist
- Create a minimal crate with the pattern above. Confirm external code cannot construct the type or read fields (
cargo buildfails on attempted access). - Run
cargo clippy -- -D warnings -D missing_docsandcargo test. All constructor rejection paths must be covered. - Run
cargo doc --no-deps --open. Verify only validated types and their public methods appear. - Add a
#[cfg(test)]module in a separate file that attempts pattern matching or field access. The compiler must reject it. - Add
cargo expandto CI for any crate using derive macros on domain types. Search generated output forpubconstructors or field accessors.
Summary
Use private fields, a fallible public constructor, and module-level re-exports to make invalid states unrepresentable. Keep serialization separate with DTOs. Enforce the boundary with clippy, deny(warnings), cargo doc audits, and compile-fail tests. The design holds until you cross process, FFI, or schema-evolution boundaries—then add runtime validation and versioning.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.