Low-Level Design (Object-Oriented Design) Module 9 – One design, six languages
LLD in Rust: traits, enums and ownership
Write a low-level design in Rust: traits instead of inheritance, enums with exhaustive matches for states, and object graphs held together by IDs.
What you will learn
- Model behaviour with traits, choosing between generics and trait objects
- Use enums with match for states and results
- Design object graphs that satisfy the borrow checker
- Use Rc, Weak and RefCell only where ownership is really shared
Before you start
What you’ll learn · Before you start
What you will learn
- Model behaviour with traits, choosing between generics and trait objects
- Use enums with match for states and results
- Design object graphs that satisfy the borrow checker
- Use Rc, Weak and RefCell only where ownership is really shared
Before you start
On this page
Rust has no inheritance, so a low-level design in Rust rests on three other tools. Traits describe behaviour: a
type implements a trait’s methods, a trait can supply default methods, and code that needs the behaviour takes any
type that implements the trait, either as a generic parameter or as a trait object such as Box<dyn FeePolicy>.
Enums hold one of several variants, each with its own data, so a lifecycle’s states or an operation’s outcome
become one type, and a match must handle every variant or the program does not compile. Ownership decides the
shape of the object graph: every value has exactly one owner and is dropped when that owner goes away, and the
compiler refuses a reference that could outlive what it points to. Long-lived objects therefore refer to each other by
ID, through the collection that owns them, rather than by reference, and shared ownership with Rc and Weak is kept
for the few links where it is real.
Every program in this lesson was built and run with Rust 1.99.0, in the 2024 edition.
A lift door in Rust: an enum, a match and a trait
The Door enum as a state machine: four variants, the data each carries, and the moves step() allows
Text description of the diagram
A state diagram with four states, each a variant of the Door enum.
- Closed. Press moves it to Opening.
- Opening { percent }. Tick adds 50 to percent; a Tick at 50 % moves it to Open.
- Open { seconds_left }. Tick takes one second off; Press sets the time back to 2 seconds; a Tick at 0 seconds moves it to Closing.
- Closing { percent }. Tick takes 50 off percent; a Tick at 50 % moves it to Closed. Obstacle or Press moves it back to Opening (a dashed arrow).
A note lists the refused moves: Press or Obstacle while the door is Closed, Opening or Open returns Err(Refused). A Tick while Closed leaves the door Closed.
The door’s four states are the four variants of one enum, and each carries only its own data: how far the door has
opened, or how many seconds it stays open. One function, step, takes a state and an event and returns the next state
or a refusal:
session · door_session.py
from session import run, version
version()
run(
"""
rustc --edition 2024 -D warnings door/door.rs -o door_demo
./door_demo
"""
) Output
# rustc 1.99.0 (b940084d7)
$ rustc --edition 2024 -D warnings door/door.rs -o door_demo
$ ./door_demo
Closed --Press--> Opening { percent: 0 }
Opening { percent: 0 } --Tick--> Opening { percent: 50 }
Obstacle refused while Opening { percent: 50 }
Opening { percent: 50 } --Tick--> Open { seconds_left: 2 }
ding at floor 3
Open { seconds_left: 2 } --Tick--> Open { seconds_left: 1 }
Open { seconds_left: 1 } --Tick--> Open { seconds_left: 0 }
Open { seconds_left: 0 } --Tick--> Closing { percent: 100 }
doors closing
Closing { percent: 100 } --Obstacle--> Opening { percent: 0 }
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 door_session.py
door/door.rs
// A lift door in Rust: its states are an enum whose variants carry their own data, every move goes
// through one exhaustive match that returns a Result, and the people who must hear about a move are
// trait objects that the lift owns.
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Door {
Closed,
Opening { percent: u8 },
Open { seconds_left: u8 },
Closing { percent: u8 },
}
#[derive(Debug, Clone, Copy)]
enum Event {
Press,
Tick,
Obstacle,
}
#[derive(Debug)]
struct Refused {
door: Door,
event: Event,
}
impl fmt::Display for Refused {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{:?} refused while {:?}", self.event, self.door)
}
}
fn step(door: Door, event: Event) -> Result<Door, Refused> {
use Door::*;
match (door, event) {
(Closed, Event::Press) => Ok(Opening { percent: 0 }),
(Closed, Event::Tick) => Ok(Closed),
(Opening { percent }, Event::Tick) if percent >= 50 => Ok(Open { seconds_left: 2 }),
(Opening { percent }, Event::Tick) => Ok(Opening { percent: percent + 50 }),
(Open { .. }, Event::Press) => Ok(Open { seconds_left: 2 }),
(Open { seconds_left: 0 }, Event::Tick) => Ok(Closing { percent: 100 }),
(Open { seconds_left }, Event::Tick) => Ok(Open { seconds_left: seconds_left - 1 }),
(Closing { .. }, Event::Obstacle | Event::Press) => Ok(Opening { percent: 0 }),
(Closing { percent }, Event::Tick) if percent <= 50 => Ok(Closed),
(Closing { percent }, Event::Tick) => Ok(Closing { percent: percent - 50 }),
(door @ (Closed | Opening { .. } | Open { .. }), event @ (Event::Obstacle | Event::Press)) => {
Err(Refused { door, event })
}
}
}
// Anything that must hear about the door implements this trait; the lift does not know which.
trait Announcer {
fn announce(&self, floor: FloorId, door: Door) -> Option<String>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct FloorId(u8);
struct Chime;
impl Announcer for Chime {
fn announce(&self, floor: FloorId, door: Door) -> Option<String> {
matches!(door, Door::Open { seconds_left: 2 }).then(|| format!("ding at floor {}", floor.0))
}
}
struct Panel;
impl Announcer for Panel {
fn announce(&self, _floor: FloorId, door: Door) -> Option<String> {
matches!(door, Door::Closing { percent: 100 }).then(|| "doors closing".to_string())
}
}
struct Lift {
floor: FloorId,
door: Door,
announcers: Vec<Box<dyn Announcer>>,
}
impl Lift {
fn handle(&mut self, event: Event) {
match step(self.door, event) {
Ok(next) => {
println!("{:?} --{:?}--> {:?}", self.door, event, next);
self.door = next;
for a in &self.announcers {
if let Some(text) = a.announce(self.floor, next) {
println!(" {text}");
}
}
}
Err(refused) => println!("{refused}"),
}
}
}
fn main() {
let mut lift = Lift { floor: FloorId(3), door: Door::Closed, announcers: vec![Box::new(Chime), Box::new(Panel)] };
use Event::*;
for event in [Press, Tick, Obstacle, Tick, Tick, Tick, Tick, Obstacle] {
lift.handle(event);
}
} session.py
"""Prints a terminal session: "$ " and each command, then everything it printed (standard output and standard
error together), and its exit status when that is not 0.
`rustc` is Rust 1.99.0, the version this lesson pins, from its rustup toolchain in the user's home folder.
learn:verify's sandbox may not read that folder, so these sessions are recorded with LEARN_VERIFY_NETWORK=allow. They
read only this folder and the toolchain, write only inside this folder, and never use the network.
A command that fails makes the session exit with status 1, unless the script lists it in may_fail. For a command
that fails, `keep` (a regular expression) shows only the lines of its output that matter: the compiler's error and
the "-->" line with its location. Version lines are shown without their build date, and test timings, which change on
every run, are left out.
"""
import os
import pwd
import re
import subprocess
import sys
from pathlib import Path
RUST = Path(pwd.getpwuid(os.getuid()).pw_dir) / ".rustup" / "toolchains" / "1.99.0-aarch64-apple-darwin" / "bin"
BUILD_DATE = re.compile(r"\s+\d{4}-\d{2}-\d{2}(?=\))")
TIMING = re.compile(r"; finished in \d+\.\d+s")
def _env():
here = Path.cwd()
for folder in ("home", "tmp"):
(here / folder).mkdir(exist_ok=True)
env = dict(os.environ)
env.update(
PATH=os.pathsep.join([str(RUST), "/usr/bin", "/bin"]),
HOME=str(here / "home"),
TMPDIR=str(here / "tmp"),
RUST_BACKTRACE="0",
NO_COLOR="1",
)
return env
def _shell(command):
done = subprocess.run(command, shell=True, env=_env(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
return done.returncode, [TIMING.sub("", line) for line in done.stdout.splitlines()]
def version():
"""Prints `rustc --version` as a comment, without its build date."""
status, lines = _shell("rustc --version")
if status != 0 or not lines:
sys.exit("Rust 1.99.0 is not installed on this computer")
print("#", BUILD_DATE.sub("", lines[0]))
def _kept(lines, keep):
out = []
for i, line in enumerate(lines):
if re.search(keep, line):
out.append(line)
if i + 1 < len(lines) and lines[i + 1].lstrip().startswith("--> "):
out.append(lines[i + 1])
return out
def run(script, may_fail=(), keep=None):
"""Runs each line of the script as one shell command, in the examples folder."""
unexpected = 0
for command in (line.strip() for line in script.strip().splitlines()):
print(f"$ {command}")
status, lines = _shell(command)
if status != 0 and keep:
lines = _kept(lines, keep)
for line in lines:
print(line)
if status != 0:
print(f"[exit status {status}]")
if command not in may_fail:
unexpected += 1
if unexpected:
sys.exit(1) Every line is one call to step. The obstacle that arrived while the door was opening was refused with an Err, a
value the lift printed rather than an exception it had to catch. The match in step names every pair of state and
event, the last arm listing the refusals explicitly instead of ending with _, so a fifth variant added to Door
would stop the build until each pair had an answer. The lift owns its two announcers as Box<dyn Announcer> and calls
them without knowing their types: the chime spoke when the door opened, the panel when it began to close. FloorId(3)
is a newtype, a struct around a number, so a floor can never be passed where a seat count is expected.
Each tool leads to a decision that the rest of a design depends on:
- Generics or trait objects: a generic function is compiled for each type, while a trait object chooses the code at run time.
- Dyn compatibility: a trait with a generic method cannot become a trait object unless that method is kept out.
#[non_exhaustive]: an enum published to other crates can promise that more variants may come.- IDs instead of references: objects that live long refer to each other through the collection that owns them.
Rc,WeakandRefCell: shared ownership and checks at run time, for the links that need them.
Check yourself
3 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Generics or trait objects: static and dynamic dispatch Pro: opens with a Pro, Premium or Ultimate pass
In this part:
- Enums across crates: #[non_exhaustive]
- Object graphs: IDs instead of references
- Shared ownership where it is real: Rc, Weak and RefCell
- Practise: port a parking model to Rust
- Interview questions
- Rust has no inheritance. How does a Rust design share behaviour and model "is a kind of"?
- A developer ports a Java parking design to Rust: tickets hold references to spots, spots to their level, and levels back to the lot. The borrow checker rejects it at every turn. How would you redesign the object graph, and what does the new design cost?
- A pricing engine in Rust applies one of several discount strategies per order, chosen from configuration that can change while the service runs. Do you use generics or trait objects, and what happens when a teammate adds a generic method to the strategy trait?
- A Rust service built its domain model from Rc<RefCell<T>> everywhere. In production it now panics with "RefCell already borrowed" when a spot change notifies an observer that reads the lot. How do you fix this incident, and how would you redesign the model?
- Key takeaways
6 runnable examples (Python, board.rs, borrowed.rs, ids.rs, observers.rs, parking.rs, policies.rs, session, spots.rs, tests.rs) · 3 interview answers · about 17 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Read a complete sample first: Low-level design: parking lot (the model)
References
- The Rust Programming Language: Object-Oriented Programming Features (The Rust Project)
- The Rust Programming Language: Using Trait Objects to Abstract over Shared Behavior (The Rust Project)
- Announcing Rust 1.86.0 (trait upcasting) (The Rust Project)
- The Rust Reference: Traits (dyn compatibility) (The Rust Project)
- The Rust Reference: the non_exhaustive attribute (The Rust Project)
- Rust API Guidelines: Type safety (C-NEWTYPE) (The Rust Project)
- std::rc::Weak (The Rust Project)
- std::cell::RefCell (The Rust Project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress