Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 30 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and Rust 1.99.0
  • By MySmartCoPilot
Free intro, Pro: 9 of 30 minutes free
Free: A lift door in Rust: an enum, a match and a trait. With a pass: the other 7 sections and 3 interview answers, about 21 minutes.

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

State diagram of the Door enum: Closed, Opening, Open and Closing, with the events that move between them.ClosedOpening { percent }Open { seconds_left }Closing { percent }Err(Refused): Press or Obstaclewhile Closed, Opening or OpenPressTickTick at 50 %Tick or PressTick at 0 sTickTick at 50 %Obstacle or Press

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:

A lift door as an enum, its moves as one match, and announcers as trait objects

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, Weak and RefCell: 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.

  1. Question 1 of 3 A fifth variant, Stuck, is added to the Door enum. What happens to door.rs?

    Choose one answer.

    Show the answer to question 1

    Answer: It stops compiling until step's match has an answer for every pair with Stuck

    A match must cover every possible value. Because the last arm lists the refused states by name instead of using a wildcard, a new variant leaves pairs uncovered, and the compiler reports them.

  2. Question 2 of 3 How does the lift call its announcers without knowing their types?

    Choose one answer.

    Show the answer to question 2

    Answer: It owns them as Vec<Box<dyn Announcer>> and calls the trait's method

    Box<dyn Announcer> is a trait object: the lift owns each announcer and calls announce() through the trait, so a new announcer type needs no change to the lift.

  3. Question 3 of 3 Why does door.rs wrap the floor number in FloorId(3) instead of using a plain u8?

    Choose one answer.

    Show the answer to question 3

    Answer: A newtype is its own type, so a floor cannot be passed where another number is expected

    A newtype costs nothing at run time and makes the compiler keep different kinds of numbers apart.

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.

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.