Fluent Rust - Volatile Dependency, Control Freak

Migo·2023년 10월 9일
0

Fluent Rust

목록 보기
12/27
post-thumbnail

Volatile Dependency

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:

  • It requires set-up and configuration for runtime environment. Database are archetypal example.
  • The dependency doesn't yet exist - or still in development.
  • The dependency is not installed on all machines.
  • The dependency has nondetermistic behaviour.

Control Freak

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;
    }
}

Why is this problematic?

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.

Other examples

1) Bad code - No use of trait in construction

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:

  • interceptable depending on runtime environment!
  • loosely coupled

You may be thinking, "dynamic counterpart would be accepting Box<dyn Repoistory> as argument for new method!" and you are right. But the key takewaway is you CANNOT even try to create ServiceHandler with concrete implentation and that essentially suffices to achieve type safety.


2) Bad code - Static Factory

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.

profile
Dude with existential crisis

0개의 댓글

Powered by GraphCDN, the GraphQL CDN