When working on business core domain logic, we want to have your application code open for extension but close against modification, aka Open-closed principle
.
Developers commonly take DI
to achieve the goal which is where dependency is introduced in the form of either interface(or trait in Rust)
or abstract class
.
Undoubtedly, however, following such principle involves some extra work which will pay off only after the size of company becomes big enough.
In essence, they are closely related to Inversion of control.
In OOP, Dependencies
are categorized as Volatile
if any of the following criteria are met:
By definition, control freak
occurs every time you depend on a volatile dependency
in any place other than a composition root
. Take the following example:
struct Controller;
impl Controller {
fn view_result() -> Vec<DomainObject> {
let service:Service = Service::new(); // tight coupling!
let domain_objects:Vec<DomainObject> = service.get_objs();
return domain_objects;
}
}
Here, Controller
creates a new instance of Service
, causing tightly coupled code. And because Controller
has to manage lifetime of the instance and no one else gets a change to intercept that object; therefore, no chance to test double
, nor to making loose coupling.
Let's see the following example:
trait Repository {} // Trait that represents data access repository
struct RDBRepository; // Imagine this is contrete repository implementation
impl Repository for RDBRepository {} // Repository implementation for RDBRepo
struct ServiceHandler {
repository: Box<dyn Repository>,
}
impl ServiceHandler {
fn new() -> Self {
Self {
repository: Box::new(RDBRepository), // Control Freak!
}
}
}
You see, regardless of restriction of what repository
could be for ServiceHandler
here via Box<dyn Repository>
, at runtime, ServiceHandler
will take only RDBRespository
. Likewise, directly initializing without explicit dependency is the archetypal example of control freak
anti-pattern.
Luckily in Rust, when you use static dispatch as opposed to dynamic dispatch shown above, you can aboid this problem. See the following example.
// Same implementation
trait Repository {}
struct RDBRepository;
impl Repository for RDBRepository {}
// Change to depend on generic Repository rather than dynamic dispatch
struct ServiceHandler<R: Repository> {
repository: R,
}
impl<R: Repository> ServiceHandler<R> {
fn new() -> Self {
Self {
repository: RDBRepository, // Compile Error!
}
}
}
With Rust's rich type system, you can prevent control freak
happening in construction site. And this segways into how you can address control freak
anti pattern; constructor injection
.
impl<R: Repository> ServiceHandler<R> {
fn new(repository:R) -> Self {
Self {
repository
}
}
}
Now, the repository
becomes:
You may be thinking, "dynamic counterpart would be accepting
Box<dyn Repoistory>
as argument fornew
method!" and you are right. But the key takewaway is you CANNOT even try to createServiceHandler
with concrete implentation and that essentially suffices to achieve type safety.
What about having free function that returns impl Trait
by using configuration setting? Try to find out how the following code may cause a problem:
struct ServiceHandler {
repository: Box<dyn Repository>,
}
impl ServiceHandler {
fn new() -> Self {
Self {
repository: repository(),
}
}
}
fn repository_factory() -> Box<dyn Repository> {
// read env
let repo = std::env::var("REPOSITORY").expect("There must be REPOSITORY environment variable!");
if repo.as_str() == "rdb" {
return Box::new(RDBRepository);
}
Box::new(MongoRepository)
}
You think this way you can determine whether you use RDB
or Mongo
and don't need to recompile ever? Well - not quite.
First all all, you don't want the application to request values from configuration. If anything - that should be configurable by the callers.
Secondly, repository_factory
is now dependent upon both RDBrepository
AND MongoRepository
, and it is called at new()
method of ServiceHandler
, indicating that there is implicit dependency on both of the conrete repository implementations.
Lucky for Rustacean again, you cannot reenact such problem when using generic type as the following code will fail to compile:
impl<R: Repository> ServiceHandler<R> {
fn new() -> Self {
Self {
repository: repository(), //opaque type `impl Repository`
}
}
}
fn repository() -> impl Repository {
let repo = std::env::var("REPOSITORY").expect("There must be REPOSITORY environment variable!");
if repo.as_str() == "rdb" {
return RDBRepository;
}
MongoRepository // mismatched types expected `RDBRepository`, found `MongoRepository`
}
When using generic, Rust type system doesn't allow for returning more than one type at a time, meaning that when return type is presumed to be RDBRepository
in return
statement, the possible return type of repoistory
function became RDBRepository
, no change for other type.