Structs are the foundation of idiomatic Rust, yet many engineers coming from dynamic languages or traditional OOP struggle with the ownership and borrowing rules that surround them. In this article, we'll explore structs in Rust: concepts, trade-offs and production lessons—covering everything from basic field declaration through method receivers, and sharing patterns we've refined across e-commerce platforms, trading systems, and SaaS backends.
Why structs matter in Rust
A struct groups related data with explicit type declarations. Unlike inheritance-based OOP, Rust structs embrace composition and explicit behavior through impl blocks. This design forces clarity: every field is named, every type is stated, and every method's access pattern is visible in its signature. In production systems handling millions of transactions, this explicitness prevents silent bugs that plague loosely-typed codebases.
The tradeoff is upfront verbosity. You can't quickly throw fields together. That friction is intentional—it pushes you toward better data modeling before code sprawl sets in.
Named fields with types
Struct fields must be declared with explicit types. This isn't a limitation; it's a contract.
struct Order {
id: u64,
customer_id: u64,
total_cents: i64,
status: OrderStatus,
created_at: DateTime<Utc>,
items: Vec<OrderItem>,
}
enum OrderStatus {
Pending,
Confirmed,
Shipped,
Delivered,
Cancelled,
}Each field is explicit. There's no "status can be a string or an int depending on your mood." The compiler enforces consistency. When you construct an Order, you must provide every field, or derive Default and explicitly use ::default(). This means your code reviewer can glance at a struct and understand exactly what data it holds.
In a ride-hailing system we worked on, sloppy field naming (start vs. origin vs. pickup) led to routing bugs. Named fields with types eliminate that ambiguity.
let order = Order {
id: 12345,
customer_id: 789,
total_cents: 9999,
status: OrderStatus::Pending,
created_at: Utc::now(),
items: vec![],
};
// Compiler error if you forget a field or get a type wrong
let bad_order = Order {
id: 12345,
// ❌ compile error: missing field `customer_id`
};If you need default values for many fields, use the builder pattern or #[derive(Default)] combined with struct update syntax:
#[derive(Default)]
struct OrderQuery {
limit: usize,
offset: usize,
status_filter: Option<OrderStatus>,
}
let query = OrderQuery {
limit: 50,
..Default::default()
};impl blocks for methods
Rust separates data (the struct) from behavior (impl blocks). You can have multiple impl blocks for the same struct, even in different files. This is more flexible than classical OOP and avoids the God Object problem.
impl Order {
/// Constructor. Note: we prefer new() for associated functions.
pub fn new(id: u64, customer_id: u64, total_cents: i64) -> Self {
Self {
id,
customer_id,
total_cents,
status: OrderStatus::Pending,
created_at: Utc::now(),
items: vec![],
}
}
/// Associated function: takes no self.
pub fn is_high_value(total_cents: i64) -> bool {
total_cents > 100_000
}
}
let order = Order::new(1, 100, 5000);
assert!(!Order::is_high_value(5000));In a SaaS billing system, we separated query logic (read-only) from mutation logic (state changes) into distinct impl blocks. This made code reviews and security audits much simpler.
impl Order {
pub fn total_with_tax(&self, tax_rate: f64) -> i64 {
((self.total_cents as f64) * (1.0 + tax_rate)) as i64
}
pub fn revenue_for_period(orders: &[Order], start: DateTime<Utc>, end: DateTime<Utc>) -> i64 {
orders
.iter()
.filter(|o| o.created_at >= start && o.created_at <= end)
.map(|o| o.total_cents)
.sum()
}
}
impl Order {
pub fn confirm(&mut self) {
if self.status == OrderStatus::Pending {
self.status = OrderStatus::Confirmed;
}
}
pub fn add_item(&mut self, item: OrderItem) {
self.items.push(item);
}
}Understanding receiver types: &self, &mut self, and self
Every method on a struct takes a receiver: how the method accesses the struct. This is the heart of Rust's safety model and where production bugs hide.
&self: immutable borrow
Most methods use &self. The method reads the struct but doesn't modify it. Multiple threads can call &self methods concurrently without locks.
impl Order {
pub fn is_delivered(&self) -> bool {
self.status == OrderStatus::Delivered
}
pub fn total_items(&self) -> usize {
self.items.len()
}
}
let order = Order::new(1, 100, 5000);
println!("{}", order.is_delivered()); // ✓ borrows as &self
println!("{}", order.total_items()); // ✓ borrows as &self again
// Both calls are safe, no mutation&mut self: mutable borrow
When a method needs to modify the struct, it takes &mut self. Only one &mut self borrow can exist at a time. This is the compiler's way of preventing data races.
impl Order {
pub fn confirm(&mut self) {
self.status = OrderStatus::Confirmed;
}
pub fn add_item(&mut self, item: OrderItem) {
self.total_cents += item.price_cents;
self.items.push(item);
}
}
let mut order = Order::new(1, 100, 5000);
order.confirm(); // ✓ mutable borrow
order.add_item(new_item); // ✓ mutable borrow again (sequential)In a real-world payment processing system, this distinction prevented a race condition: a method was marked &mut self, forcing us to ensure it was never called concurrently on the same order. Without that, two threads could have both charged the customer.
self: consuming ownership
Some methods consume the struct, taking ownership via self (not a reference). The struct is moved into the method and cannot be used afterward. This is useful for builder patterns, final transformations, or cleanup.
impl Order {
/// Finalize: consumes self, returns a different type.
pub fn finalize(self) -> FinalizedOrder {
FinalizedOrder {
id: self.id,
customer_id: self.customer_id,
total_cents: self.total_cents,
items: self.items.into_iter().map(|i| i.finalize()).collect(),
finalized_at: Utc::now(),
}
}
}
let order = Order::new(1, 100, 5000);
let final_order = order.finalize();
// order is moved; this will not compile:
// println!("{:?}", order); // ❌ error: order has been movedThe compiler error is your safety net. If a method consumes ownership, you can't accidentally use the old struct later.
Derive Debug, Clone, and PartialEq
Rust provides derive macros for common traits. These save boilerplate and are safe by default.
#[derive(Debug, Clone, PartialEq)]
struct Order {
id: u64,
customer_id: u64,
total_cents: i64,
status: OrderStatus,
created_at: DateTime<Utc>,
items: Vec<OrderItem>,
}
#[derive(Debug, Clone, PartialEq)]
enum OrderStatus {
Pending,
Confirmed,
Shipped,
Delivered,
Cancelled,
}Debug
#[derive(Debug)] implements the Debug trait, enabling println!("{:?}", order) and dbg!(order). Essential for logging and debugging. It's one of the first things you add to any struct in development.
let order = Order::new(1, 100, 5000);
dbg!(&order); // prints Order { id: 1, customer_id: 100, ... }Clone
#[derive(Clone)] implements Clone, allowing you to explicitly duplicate the struct with .clone(). Use this when you need a deep copy. In production, prefer references when possible to avoid unnecessary allocations.
let order1 = Order::new(1, 100, 5000);
let order2 = order1.clone(); // explicit copy
// order1 is still validCloning is often a code smell. If you're cloning frequently in hot paths (per-request handlers, high-throughput message processing), reconsider your ownership model. We've seen e-commerce platforms spend 15% of CPU time cloning orders needlessly.
PartialEq
#[derive(PartialEq)] implements equality comparison. Useful for tests and business logic checks.
let order1 = Order::new(1, 100, 5000);
let order2 = Order::new(1, 100, 5000);
assert_eq!(order1, order2);
#[test]
fn test_order_confirmation() {
let mut order = Order::new(1, 100, 5000);
order.confirm();
assert_eq!(order.status, OrderStatus::Confirmed);
}If your struct contains fields that aren't comparable (e.g., channels, file handles), you can't derive PartialEq. You'll need to implement it manually or exclude those fields with #[derive(PartialEq)] plus manual impl.
Common mistakes
Forgetting ownership boundaries
A struct with String or Vec takes ownership of those values. If you need to share the data, use &str and slices, or Arc for thread-safe sharing. Many engineers new to Rust overuse cloning because they haven't internalized borrowing.
// ❌ Not ideal: cloning every item name
#[derive(Clone)]
struct OrderItem {
name: String,
price_cents: i64,
}
// ✓ Better: borrow when you can
struct OrderItemRef<'a> {
name: &'a str,
price_cents: i64,
}Mixing PartialEq with floating-point fields
If your struct contains floats, deriving PartialEq is risky because of NaN. Use a custom implementation or a wrapper type (like ordered_float::OrderedFloat).
Too many impl blocks
While multiple impl blocks are legal and sometimes useful for organization, too many make code harder to navigate. Keep related behavior together.
Exposing mutable fields publicly
Make fields private and provide controlled setter methods. This lets you add validation or logging later without breaking your API.
// ❌ Bad: pub fields
struct Order {
pub status: OrderStatus,
}
// ✓ Good: controlled access
struct Order {
status: OrderStatus,
}
impl Order {
pub fn set_status(&mut self, new_status: OrderStatus) {
// Validation and side effects here
self.status = new_status;
}
}Key takeaways
- Named fields with explicit types eliminate ambiguity and catch errors at compile time, not in production logs.
- impl blocks separate data from behavior and let you organize methods by concern—queries, mutations, conversions.
- &self, &mut self, and self receivers encode safety guarantees that the compiler enforces. Read-only operations use &self; mutations use &mut self; ownership transfers use self.
- Derive Debug, Clone, and PartialEq for fast iteration. Reconsider Clone usage in performance-critical paths—it's often hiding an ownership design issue.
- Treat borrowing as a first-class concern. Most Rust performance and safety benefits come from understanding ownership, not from syntax.
- Keep fields private and provide intentional accessor methods. This protects your invariants and makes refactoring safer.






Comments
Loading comments…