Ownership
Builder should return an instance, not a reference to it, and be consumed during build
call. This is semantically correct: builder is a one-off logic container with sole purpose of creating some object. Normally all its methods take ownership and return self
, like this (with blocks replaced by unreachable to avoid copying a wall of code):
impl VehicleBuilder {
fn new() -> Self {
unreachable!();
}
fn with_wheels(mut self, wheels: u32) -> Self {
unreachable!();
}
fn with_engine(mut self, engine: &str) -> Self {
unreachable!();
}
fn with_seats(mut self, seats: u32) -> Self {
unreachable!();
}
fn finalize_build(self) -> Vehicle {
self.vehicle
}
}
This is to avoid producing a reference. The final user can make a reference if needed, but going other way requires wasteful cloning.
You can use a builder example in the unofficial rust book for reference.
Model definition
In your domain model, is there a reasonable definition of a default vehicle configuration? Same question about every separate enum.
- If yes: move the default vehicle to
Default
trait (or #[derive(Default)]
) for better visibility of what the "default" looks like.
- If no: do not assume a default vehicle configuration, make all builder steps required. That probably means duplicating
Vehicle
fields and wrapping them in Option
.
I assume enums are just placeholders, but if not - derive at least Clone
and Copy
on them, plain enums without content are cheap, this would be a great quality of life improvement.
If defaults make sense, that would be
#[derive(Debug, Default)]
enum WheelConfiguration {
#[default]
TwoWheeler,
ThreeWheeler,
FourWheeler,
FourPlusWheeler,
}
#[derive(Debug, Default)]
enum EngineConfiguration {
#[default]
SmallEngine,
MediumEngine,
LargeEngine,
}
#[derive(Debug, Default)]
enum SeatConfiguration {
#[default]
TwoSeater,
FourSeater,
FiveSeater,
}
#[derive(Debug, Default)]
struct Vehicle {
wheels: WheelConfiguration,
engine: EngineConfiguration,
seats: SeatConfiguration,
}
// ....
impl<'a> VehicleBuilder<'a> {
fn new() -> Self {
Self {
vehicle: Vehicle::default(),
}
}
}
If not, VehicleBuilder
would be essentially a copy of Vehicle
with all fields wrapped in Option
, and finalize_build
will return Err
if some fields were not provided. Derive Default
on such struct to make new
implementation just a wrapper around Self::default()
. You can mix&match these two approaches - if some fields have sensible defaults and some do not, you can avoid wrapping the former with Option
and just use the default value.
Error handling
Whichever way you go, the canonical way to handle faults in builder is to fail the build. You could make every step fallible, but that's slightly less user-friendly (but manageable with ?
unwrapping). For example, like this:
struct VehicleBuilder {
vehicle: Vehicle,
errors: Vec<String>,
}
impl VehicleBuilder {
fn new() -> Self {
Self {
vehicle: Vehicle {
wheels: WheelConfiguration::TwoWheeler,
engine: EngineConfiguration::SmallEngine,
seats: SeatConfiguration::TwoSeater,
},
errors: vec![],
}
}
fn with_wheels(mut self, wheels: u32) -> Self {
self.vehicle.wheels = match wheels {
1 => {
self.errors.push(format!("Invalid wheel configuration: {}", wheels));
return self;
}
2 => WheelConfiguration::TwoWheeler,
3 => WheelConfiguration::ThreeWheeler,
4 => WheelConfiguration::FourWheeler,
_ => WheelConfiguration::FourPlusWheeler,
};
self
}
fn with_engine(mut self, engine: &str) -> Self {
self.vehicle.engine = match engine {
"small" => EngineConfiguration::SmallEngine,
"medium" => EngineConfiguration::MediumEngine,
"large" => EngineConfiguration::LargeEngine,
_ => {
self.errors.push(format!("Invalid engine configuration: {}", engine));
return self;
}
};
self
}
fn with_seats(mut self, seats: u32) -> Self {
self.vehicle.seats = match seats {
2 => SeatConfiguration::TwoSeater,
4 => SeatConfiguration::FourSeater,
5 => SeatConfiguration::FiveSeater,
_ => {
self.errors.push(format!("Invalid seat configuration: {}", seats));
return self;
}
};
self
}
fn finalize_build(self) -> Result<Vehicle, Vec<String>> {
if self.errors.is_empty() {
Ok(self.vehicle)
} else {
Err(self.errors)
}
}
}
fn main() {
let result = VehicleBuilder::new()
.with_wheels(1)
.with_engine("small")
.with_seats(4)
.finalize_build();
match result {
Ok(vehicle) => println!("vehicle is {:?}", vehicle),
Err(errors) => {
eprintln!("Invalid confugration! The following problems were detected:");
for err in errors {
eprintln!("* {err}");
}
}
};
}
This way, the caller knows when something goes wrong. You can mix and match - for example, only store an Option<String>
and report the first/last error. I'm sticking to strings to show the idea, in practice that would probably be
use thiserror::Error;
struct VehicleBuilder<'a> {
vehicle: Vehicle,
errors: Vec<VehicleBuilderError<'a>>,
}
#[derive(Error, Debug)]
pub enum VehicleBuilderError<'a> {
#[error("Invalid wheel configuration: '{0}'")]
InvalidWheel(u32),
#[error("Invalid engine configuration: '{0}'")]
InvalidEngine(&'a str),
#[error("Invalid seat configuration: '{0}'")]
InvalidSeat(u32),
}
impl<'a> VehicleBuilder<'a> {
fn new() -> Self {
...
}
fn with_wheels(mut self, wheels: u32) -> Self {
self.vehicle.wheels = match wheels {
1 => {
self.errors.push(VehicleBuilderError::InvalidWheel(wheels));
return self;
}
2 => WheelConfiguration::TwoWheeler,
3 => WheelConfiguration::ThreeWheeler,
4 => WheelConfiguration::FourWheeler,
_ => WheelConfiguration::FourPlusWheeler,
};
return self;
}
fn with_engine(mut self, engine: &'a str) -> Self {
self.vehicle.engine = match engine {
"small" => EngineConfiguration::SmallEngine,
"medium" => EngineConfiguration::MediumEngine,
"large" => EngineConfiguration::LargeEngine,
_ => {
self.errors.push(VehicleBuilderError::InvalidEngine(engine));
return self;
}
};
return self;
}
fn with_seats(mut self, seats: u32) -> Self {
...
}
fn finalize_build(self) -> Result<Vehicle, Vec<VehicleBuilderError<'a>>> {
if self.errors.is_empty() {
Ok(self.vehicle)
} else {
Err(self.errors)
}
}
}
Trailing return
Blocks can have a trailing value in Rust. Conventionally, functions that return something at the end omit the return:
fn something(self) -> Self {
self
}
Simpler
This is fine:
println!("vehicle is {:?}", vehicle)
but this is slightly more readable, especially when there are several variables:
println!("vehicle is {vehicle:?}")
Accessibility
I'd add a builder
associated function to Vehicle
to make the builder easier to discover:
impl Vehicle {
pub fn build() -> VehicleBuilder {
VehicleBuilder::new()
}
}
Shift of responsibility
What you're doing might be reasonable in some cases. However, if the parsing makes sense for every configuration block as a separate entity, consider offloading the parsing work to it.
In other words, do you want to allow creating WheelConfiguration
from a number?
- If no, consider requiring
WheelConfiguration
in builder too. But then the whole builder becomes pointless, you can just move parsing to the enums, remove builder and create Vehicle
as a struct literal.
- If yes, consider implementing
TryFrom
on the enum (and add a enum variant to stop discriminating monowheels!):
use thiserror::Error;
#[derive(Debug)]
enum WheelConfiguration {
TwoWheeler,
ThreeWheeler,
FourWheeler,
FourPlusWheeler,
}
#[derive(Error, Debug)]
#[error("Invalid wheel: '{0}'")]
struct InvalidWheel(u32);
impl TryFrom<u32> for WheelConfiguration {
type Error = InvalidWheel;
fn try_from(wheels: u32) -> Result<Self, Self::Error> {
match wheels {
0 | 1 => Err(InvalidWheel(wheels)),
2 => Ok(Self::TwoWheeler),
3 => Ok(Self::ThreeWheeler),
4 => Ok(Self::FourWheeler),
_ => Ok(Self::FourPlusWheeler),
}
}
}
// and now
type BuilderErrors<'a> = Vec<Box<dyn std::error::Error + 'a>>;
struct VehicleBuilder<'a> {
vehicle: Vehicle,
errors: BuilderErrors<'a>,
}
impl<'a> VehicleBuilder<'a> {
fn new() -> Self {
Self {
vehicle: Vehicle {
wheels: WheelConfiguration::TwoWheeler,
engine: EngineConfiguration::SmallEngine,
seats: SeatConfiguration::TwoSeater,
},
errors: vec![],
}
}
fn with_wheels(mut self, wheels: u32) -> Self {
match wheels.try_into() {
Ok(parsed) => self.vehicle.wheels = parsed,
Err(e) => self.errors.push(Box::new(e)),
}
self
}
// ...
fn finalize_build(self) -> Result<Vehicle, BuilderErrors<'a>> {
if self.errors.is_empty() {
Ok(self.vehicle)
} else {
Err(self.errors)
}
}
}
Note that it still might make sense to remove the builder after this and use literal construction, as now the builder just accumulates errors without any other logic.
FourPlusWheeler
simply be calledMoreWheeler
? ;-> \$\endgroup\$usize
and&str
. \$\endgroup\$