Best practices for designing traits in public crates?

Hey folks, I'm the author and maintainer of a public crate, but I'm not a particularly experienced Rustacean, specifically when it comes to crates design best practices.

I have a specific question that I'd like to get some perspectives on from more experienced people:

The crate I maintain exposes an important trait, Repository, which has two methods: get and save.
These two methods have completely different errors, as they perform two different operations.
However, somebody using the crate would likely use both methods in the same module (i.e. get, do work, then save).

Coming from Go, we're used to separate functionalities into smaller, one-method-long interfaces.
In this case, it would be something like this (source):

type Getter[I any, T any]] interface {
	Get(ctx context.Context, id I) (T, error)
}

type Saver[I any, T any] interface {
	Save(ctx context.Context, root T) error
}

type Repository[I any, T any] interface {
	Getter[I, T]
	Saver[I, T]
}

In my initial attempt, I've basically mimicked the same approach, as you can see here:

#[async_trait]
pub trait Getter<T>: Send + Sync
where
    T: Aggregate,
{
    type Error: Send + Sync;

    async fn get(&self, id: &T::Id) -> Result<aggregate::Root<T>, GetError<Self::Error>>;
}

#[async_trait]
pub trait Saver<T>: Send + Sync
where
    T: Aggregate,
{
    type Error: Send + Sync;

    async fn save(&self, root: &mut aggregate::Root<T>) -> Result<(), Self::Error>;
}

pub trait Repository<T>: Getter<T> + Saver<T> + Send + Sync
where
    T: Aggregate,
{
}

impl<T, R> Repository<T> for R
where
    T: Aggregate,
    R: Getter<T> + Saver<T> + Send + Sync,
{
}

This approach was nicer in keeping the two methods separate in two different traits, especially since they had their own type Error associated.

But when using it directly in a consumer, it was a pain in the butt to set trait impl boundaries.
A direct example: https://github.com/get-eventually/eventually-rs/blob/bada1c32b5a144353ffe78e938341a8f2247792e/examples/bank-accounting/src/application.rs#L79-L83

I've revisited it a bit, and decided to try to merge all traits in a single Repository trait (source):

#[async_trait]
pub trait Repository<T>: Send + Sync
where
    T: Aggregate,
{
    type GetError: Send + Sync;
    type SaveError: Send + Sync;

    async fn get(&self, id: &T::Id) -> Result<aggregate::Root<T>, GetError<Self::GetError>>;
    async fn save(&self, root: &mut aggregate::Root<T>) -> Result<(), Self::SaveError>;
}

While I like now that there is a single trait involved (which also makes it easier to write super-types), I don't like the requirement for those associated type names like type GetError and type SaveError. I also don't particularly like the idea of hiding everything behind a single Error type, as it kinda defeats the purpose of having such a nice type system like the one Rust has.

Is there any better design than this? Am I looking at things from the wrong perspective? Really keen on some outside perspective.

Thank you so much!

In my experience as a consumer of Rust crates, I would say that single-method traits is definitely not a common pattern, and I can see why.

What's the reason for you to give the implementor the ability to set the error types? If you don't have a reason, then don't give the implementors the ability to set them. The common pattern in Rust is to define your own crate Error type, and let the consumers implement From<YourCrateError> for TheirDomainError .

2 Likes

That's a good point.

Implementors are also part of the project. The reason for giving implementors the ability to specify the error is so that a potential consumer (end users) of both trait and trait implementor has access to the specific error that occurred. As an example: https://github.com/get-eventually/eventually-rs/blob/406edf8a54e99206d820113a12099e2775786da7/eventually-postgres/src/aggregate.rs#L56-L84

I do realize though that the layering gets... deep. And potentially cumbersome to use.
This is how a potential end user would be using it: https://github.com/get-eventually/eventually-rs/blob/406edf8a54e99206d820113a12099e2775786da7/examples/bank-accounting/src/grpc.rs#L44-L58

(Spoiler: downcasting)

What would be a better design? I'm genuinely curious.

perspective depends on what purpose the trait serves. for example, as a library, there's typically two way to use a trait:

  1. the library is "framework"-like, and the trait serves as an interface for some extension points that the client code can "hook" into the framework, that is, the trait is for the client type to implement, while the framework/library "consumes" the trait.

  2. the library provide some types which all implement the same public API, and the trait is like the abstract "overloaded" API entrypoint. in other words, the library itself is the implementer of the trait, and the client code "consumes" the types through a generic interface provided by the trait.

in the first use case, it makes sense to use separate traits for different "hooks", and the client code can decide what to implement.

in the second scenario, there's no real benefit to define separate traits if all your concrete types ends up implements all the interfaces. you can have a single trait with many APIs, the caller can just use what they need and ignore the rest.

if they need to be different error types, you cannot get away with two different name. there's no real difference between Getter::Error + Saver::Error vs Repository::GetError + Repository::SaveError.

it's not "hiding" something, it's your API design, whether to use single type or different types depends on how you want your API to be used. for a language with sum types, the choice does feel somewhat arbitrary though.

1 Like

Another way of thinking about traits is that they can be classifying the type or describing a capability. Some do both. A classifying trait could be something named Server. It says more about what something is and less about what it does. A capability trait could be named HandleRequest and it describes what the type can do.

You will see a lot of traits in that latter category in Rust ( Write, Read, Add, ...) and I feel like the language design tends to favor them. They are very composable in trait bounds and can be added where they make sense. Whether or not they should have one or multiple methods depends on what they represent.

I think the classifying traits can have a tendency to want to bring in everything and the kitchen sink. Maybe that's just a classic OOP way of thinking. Regardless, the problem that can occur is that it's a breaking change to add any super traits or non-default implemented methods, so they can end up frozen until the next major release.

In the end, I think there a need for both, but I have learned that it's usually useful to design for capabilities first and I have found myself having to break larger traits apparat to represent nuances. Of course, always ask yourself if it makes sense for your case.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.