API design: Accurate shape vs Smaller mental cloud

heya, I'm designing an API for consumers to implement, and wondering which API is more digestable.

  • Consumers have to implement a state discovery function, but depending on the usage, the src may or may not exist.
  • In a "read" usage, src not existing is okay.
  • In a "write" usage, src not existing is an error.

What are your thoughts on the following designs? (pseudo rust)

  1. Two functions, whose return type accurately depicts the concept.

    trait ItemSpec {
        type State;
    
        // if !params.src().exists() { Ok(None) }
        fn discover_for_read(ctx: &Ctx, params: &Params) -> Result<Option<Self::State>, Error>;
    
        // if !params.src().exists() { Err(..) }
        //
        // Pros: this doesn't allow the consumer to return `Ok(None)`
        // as it conceptually doesn't fit the execution model.
        fn discover_for_write(ctx: &Ctx, params: &Params) -> Result<Self::State, Error>;
    }
    
  2. One function, whose return value should be correctly chosen by the consumer.

    trait ItemSpec {
        type State;
    
        // if !params.src().exists() {
        //     if ctx.read() {
        //         Ok(None)
        //     } else if ctx.write() {
        //         Err(..)
        //     }
        // }
        //
        // Pros: less things to think about in the trait
        fn discover(ctx: &Ctx, params: &Params) -> Result<Option<Self::State>, Error>;
    }
    

    Notably this is introducing an opportunity to represent invalid state in the return type.


An evolution of the question, instead of each function within the Spec being just a function, they are their own separate trait:

// instead of
fn discover_for_read(ctx: &Ctx, params: &Params) -> Result<Option<Self::Output>, Error>;

// it becomes
trait FnSpec {
    type Output;

    fn exec(ctx: &Ctx, params: &Params) -> Result<Self::Output, Error>;
}

So the umbrella trait is now either:

trait ItemSpec {
    type State;

    type StateDiscoverReadFn: FnSpec<Output = Option<Self::State>>;
    type StateDiscoverWriteFn: FnSpec<Output = Self::State>;
}

or

trait ItemSpec {
    type State;

    type StateDiscoverFn: FnSpec<Output = Option<Self::State>>;
}

Given this abstraction, what would your bias be?

Out of the presented options, I think number 1 is my preferred. It seems obvious and doesn't have invalid states.

The "smaller mental cloud" can sometimes end up being a bigger mental burden because you can't just plug things in and let the compiler tell you you're wrong. The spec becomes more implicit and reliant on documentation because failures can happen at runtime.

I think the last option with the extra traits is interesting, but not bringing much extra usability to the table, and is probably just more confusing for people with less Rust experience.

2 Likes

I definitely prefer option 1, as it pushes errors earlier in the process - if I do the wrong thing, I get a compile error instead of a failing test or a runtime failure.

As a rule of thumb, errors cost an extra order of magnitude as you move closer to production in the chain thinking -> writing code -> compiling code -> testing -> production. Ideal API design makes it impossible to even think about making a mistake, but is next to impossible to do; however, the further left you can push errors in that chain, the cheaper a mistake is.

While he was originally thinking about C code, Rusty Russell's positive API design scorechart (and associated negative scorechart) is useful to keep in mind. You want to get your API to the highest score you can manage - and to avoid ever reaching the negative scores.

4 Likes

Good to have both your thoughts – and some timeless design rules to remember (14 years ago!).

Implementing the separate functions approach didn't end up causing the "two similar implementations, but with a nuanced difference" issue (like async vs sync impls).

Thank you :bowing_man:

2 Likes