From Primitive Soup to Domain Types
Rust is famous for saying “no” in ways that save you from yourself.
So it’s a little tragic when we build a whole codebase that says “sure, whatever” by representing everything as:
u64Stringbooland the occasional cursed
f64for money (we’ll call the police later)
This post is about the quiet villain behind many bugs and refactors: primitive obsession—when your domain model is basically a bag of numbers and strings wearing a trench coat.
We’re going to fix that with newtypes, enums, and a couple of helpful crates: nutype and bare-types.
Not because it’s “enterprise”. Because it’s… sane.
Primitive obsession: the bug that compiles
Imagine you’re reading a function signature like this:
fn ship_order(user_id: u64, order_id: u64, is_express: bool) { ... }
This is Rust code that has emotionally given up.
Is
user_ida database id? a hash? a timestamp? the number of cats the user owns?Is
is_expressabout shipping speed, payment method, or the CEO’s mood today?Also: what stops you from swapping
user_idandorder_id?
Answer: nothing. The compiler shrugs and your bug ships on time.
This is the core problem: primitives carry no meaning.
Rust has a solution: make meaning a type.
The rule: primitives at the edges, not in the brain
Think of your system like a fancy restaurant:
The edges (HTTP/JSON, DB rows, CLI args, env vars) are the front door.
The domain (business logic) is the kitchen.
At the front door, you accept raw ingredients (String, u64).
In the kitchen, you use prepared ingredients with labels and safety checks.
So:
✅ Primitives allowed:
Parsing/serialization boundaries
DTO/wire structs that convert immediately to domain types
❌ Primitives forbidden:
Domain structs and public domain APIs that represent real business concepts
Because if the domain is “just primitives”, you’re not modeling your problem. You’re modeling your regret.
The newtype pattern: give your u64 a job title
Let’s make UserId and OrderId separate types.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(transparent)]
pub struct UserId(u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(transparent)]
pub struct OrderId(u64);
Now ship_order(UserId, OrderId, …) can’t accidentally swap IDs.
The compiler becomes your annoying-but-right coworker.
Also: #[repr(transparent)] is how you tell the compiler:
“This wrapper is zero-cost, I’m not trying to build a spaceship.”
It keeps layout equivalent to the inner type (useful for FFI and guarantees). (RFC)
Replace bool with an enum (your future self will send you a thank-you card)
bool is a liar because it hides context.
pub struct Account {
pub is_active: bool,
}
Active… according to what? Billing? legal status? account deletion? vibes?
Use an enum:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountStatus {
Active,
Suspended,
}
pub struct Account {
status: AccountStatus,
}
Now the code reads like English and doesn’t require a séance to understand.
Validation: stop trusting user input, it’s literally made of user
Newtypes are good.
Validated newtypes are where the magic starts.
You want types like:
EmailAddressNonEmptyNamePercentPositiveIntHostnameCallbackUrl
…and you want them to be impossible to create incorrectly.
You can write manual constructors, but let’s be real: you will forget one edge case, and then you’ll meet it in production at 2am.
That’s where macro-based validation helps.
nutype: “I want a newtype, but I also want to keep my free time”
nutype generates a newtype plus validation and sanitization logic. (docs.rs)
Example:
use nutype::nutype;
#[nutype(
sanitize(trim, lowercase),
validate(not_empty, len_char_max = 20),
derive(Debug, PartialEq, Eq, Hash),
)]
pub struct Username(String);
What you get:
A
Username::try_new(...) -> Result<Username, UsernameError>An error enum telling you what rule failed
A value you can trust everywhere after construction
This is “parse once at the boundary, then relax”.
Key advice:
Prefer checked constructors (
try_new)Avoid unchecked “escape hatches” unless you prove it’s safe
Avoid
Dereffor domain types (it leaks primitives back into the codebase like a broken pipe)
bare-types: “I don’t want to reinvent Email today”
Sometimes you don’t need custom rules. You need a solid email/url/uuid type now.
That’s bare-types: a collection of ready-made validated types, including network-ish ones like Email, Url, Hostname, Port, Uuid. (net module docs)
Example:
use bare_types::net::{Email, Url};
fn register(email: &str, homepage: &str) -> Result<(Email, Url), Box<dyn std::error::Error>> {
let email = Email::new(email)?;
let homepage = Url::new(homepage)?;
Ok((email, homepage))
}
Now your domain logic can treat these as “already checked”.
Bonus move: even if you use Uuid, still wrap it in domain IDs so you don’t mix them:
use bare_types::net::Uuid;
#[repr(transparent)]
pub struct UserId(Uuid);
#[repr(transparent)]
pub struct OrderId(Uuid);
Same underlying representation, different meaning. That’s the whole point.
The refactor plan: how to do this without setting your repo on fire
This is the boring part. Also the part that works.
1) Inventory the primitive crimes
Search for:
IDs:
u64,String,Uuidflags:
boolmoney:
f32/f64(🚨)“units”: milliseconds in
u64, kilometers inf32, etc.structured strings: email, url, sku, slug, etc.
Write a map:
where → what it means → what type should replace it
2) Create a domain types module/crate
One place. Centralized. Boring and consistent.
Example:
src/domain/types/or
crates/domain_types/
3) Convert at boundaries immediately
DTOs can be messy. Domain should be clean.
Bad:
Carry
Stringinto the domain and validate “later”
Good:
Validate/convert once at input boundary and only pass domain types forward
4) Replace APIs first, let the compiler guide you
Change struct fields and fn signatures first.
Now the compiler becomes your migration assistant.
5) Add tests for every type
At minimum:
validation success/failure cases
parsing/formatting
serde roundtrip (if used)
Guardrails: prevent relapse into primitive chaos
Primitive obsession is like entropy: it returns unless you fight it.
Practical defenses:
Keep DTOs and domain separate modules (adapters vs core)
Avoid
pubfields in domain structs (expose behavior methods)Consider CI checks (even a simple ripgrep rule) to detect primitives in domain paths
Final thought: your compiler is a teammate, not a referee
When your domain model is strong, Rust stops being “the language that yells at me” and becomes:
“the language that won’t let me do dumb things”
“the language that makes refactors safer”
“the language where half the tests are replaced by types”
And yes, you can still write bugs.
But they’ll have to work harder now.

