I've been hobby programming in Rust for a while now and there's something annoying me when I try to create abstractions. Here's a small example I made of the kind of code I end up with when writing a program in a dependency injection style:
type UserId = u8;
pub struct User {}
pub struct UserDatabase {}
impl UserDatabase {
pub fn get(&self, user_id: UserId) -> User {
todo!()
}
}
pub struct UserService {
user_database: UserDatabase
}
impl UserService {
fn get_user(&self, user_id: UserId) -> User {
self.user_database.get(user_id)
}
}
fn main() {
let user_database = UserDatabase{};
let user_service = UserService{
user_database
};
user_service.get_user(todo!());
}
In this example the UserService
exposes a get_user
method. Internally, this calls a UserDatabase
. This UserDatabase
is injected into the UserService
in the main
method.
So this all works well, but consider the scenario now that this UserDatabase
needs to get used (injected into) another service:
pub struct SomeOtherService {
user_database: UserDatabase
}
fn main() {
let user_database = UserDatabase{};
let user_service = UserService{
user_database
};
let some_other_service = SomeOtherService{
user_database
};
user_service.get_user(todo!());
}
Obviously this code doesn't compile as we've tried to move user_database
twice in main
.
So we have to wrap it in something like an Rc
:
let user_database = Rc::new(UserDatabase{});
But this means now, in order to be able to inject it into user_service
, we have to also wrap the type in the UserService
struct in an Rc
:
pub struct UserService {
user_database: Rc<UserDatabase>
}
This bothers me because:
UserService
was used externally, forced us to alter the internal structure of UserService
.UserService
is now aware of the fact that UserDatabase
is used elsewhere. The UserService
only needs the UserDatabase
to call 1 function. Why should it have to be aware of the fact that the UserDatabase
is referenced elsewhere? Objectively I know the answer to this: it's because the program has to work out how to safely dereference the value from the heap without interfering with other parts of the code; but as programmers we don't want to leak unnecessary details to the implementation of a function.Similarly for something like Arc
, all the parts of the program would now have to be aware they are operating in a multi-threaded scenario.
Is there some way we can achieve a kind of structure where UserService
is not aware of how the UserDatabase
type is wrapped? One idea that comes to mind is having a trait:
trait GetUserDatabase {
fn get(&self, user_id: UserId) -> User;
}
and then somehow being able to make Rc<UserDatabase>
or Arc<UserDatabase>
implement that trait.
Perhaps I'm missing some neat tricks here or perhaps I just need alter my traditional Golang/Java mindset when approaching patterns like dependency injection.
Rust is not designed for Java's dependency injection mindset, indeed.
UserService
is now aware of the fact thatUserDatabase
is used elsewhere. TheUserService
only needs theUserDatabase
to call 1 function. Why should it have to be aware of the fact that theUserDatabase
is referenced elsewhere?
Because this is exactly the kind of things Rust wants to be explicit about.
This is not just a limitation: Rust wants you to know who owns your objects. The ownership system should design your mind and your program. This "share-style" is indeed not very Rusty - and one of the reasons Rc
and friends are not commonly seen in idiomatic Rust (they do appear, but not at the same amount as GC'd languages).
Do not think about services - think about data. Instead of asking who needs access to the user database, ask of whom it is. The owner does not inject the DB to whoever needs it, he just let them take a look. If main()
owns the database, the services should take a reference to it (or not exist at all). If both services own it, it should be Rc
. Either way, you have to be explicit about that. And that's a good thing!
If you do dependency injection, you should likely base your design on a trait. For example:
pub trait UserDatabase {
fn get(&self, user_id: UserId) -> User;
}
// UserService works with any database that implements the trait
pub struct UserService<Db> {
user_database: Db,
}
impl<Db: UserDatabase> UserService<Db> {
fn get_user(&self, user_id: UserId) -> User {
self.user_database.get(user_id)
}
}
The implementation of the trait is no more complex than before:
pub struct UserDatabaseImpl {}
impl UserDatabase for UserDatabaseImpl {
fn get(&self, user_id: UserId) -> User {
todo!()
}
}
...and your main()
looks the same, too:
fn main() {
let user_database = UserDatabaseImpl {};
let user_service = UserService { user_database };
user_service.get_user(todo!());
}
But now, if you want to share the database, you no longer need to modify UserService
- you only need to provide a new implementation of the trait that allows such sharing. In the above case it can even make use of the non-sharing implementation:
impl UserDatabase for Rc<UserDatabaseImpl> {
fn get(&self, user_id: UserId) -> User {
self.as_ref().get(user_id)
}
}
The database can now be shared among multiple services without modifying the implementation of UserService
or SomeOtherService
:
pub struct SomeOtherService<Db> {
user_database: Db,
}
fn main() {
let user_database = Rc::new(UserDatabaseImpl {});
let user_service = UserService {
user_database: Rc::clone(&user_database),
};
let some_other_service = SomeOtherService {
user_database: Rc::clone(&user_database),
};
user_service.get_user(todo!());
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With