Effective Dependency Inversion
Dec 25, 2022
Can you spot the issues in the following code snippet?
// in a class-method in the Application layer
final AwsRequest awsRequest = AwsRequestBuilder.build();
final AwsResponse awsResponse = aws.someRequest(awsRequest);
Clean Architecture
Robert Martin espouses Clean Architecture with a single overriding rule called the Dependency Rule. The rule states - Source code dependencies must point only inward toward higher-level policies.
- The outermost layer is for external interfaces or libraries such as databases, devices, user interfaces, and APIs.
- The next layer is for controllers, gateways and presenters.
- The following layer is for application libraries.
- The innermost circle is for policies, core interfaces and entities.
An application library must not take a dependency on an object in the controller or external interfaces layer. The problem with the source code snippet above is that it violates the Dependency Rule because the service class belongs to the application layer, which directly depends on an external library for interacting with AWS. Why is it a problem?
The problem is it makes migrations very hard. It also makes library upgrades very hard when the major version changes. For example, I once had to upgrade my code base from the AWS library version from version 1 to 2, and it took me weeks instead of days because AWS dependencies had found their way into every nook and cranny of the application core.
What's being passed around?
Its not enough to program to interfaces. We also need to take care of what we are passing around. The classical advice is to use system level constructs such as arrays and maps. Some programming languages like Java make it hard to create complex data-structures out of arrays and maps because of limited syntactical support. For them, it might be more convenient to use Data Transfer Objects (DTOs) part of the interface definition.
interface SomeService {
class static MyAwsRequest {
// fields, getters and setters
}
class static MyAwsResponse {
// fields, getters and setters
}
MyAwsResponse someMethod(MyAwsRequest awsRequest);
}
// then in the class-method in the Application layer
final MyAwsRequest awsRequest = new MyAwsRequest();
// someService is an injected dependency
final MyAwsResponse awsResponse = someService.someMethod(awsRequest);
SomeService
would be in the domain or the inner core. Any outer later can have a dependency on it. Its implementation would be in the outer-most layer. This adheres to the Dependency Rule because the dependencies are pointed inwards while control flow is in the reverse direction.
Crossing Boundaries
Speaking of control flow brings up the question of boundaries. Regardless of a monolithic or service oriented or micro service architecture, partitioning dependencies aids greatly in developing, testing and deployment. An incorrect partition would look like the following.
The correct way to partition would be to move the Service and Data dependencies on the same side of the boundary as the Client.
Further, while packaging, the Service interface and Data would be packaged as one library (usually called the core
) while the ServiceImpl (implementation) would be in a seperate library. This is how libraries such as SLF4J, Hibernate and others are packaged. The clients depend on the core
package at compile time while there is a runtime dependency on the implementation.
Boundaries and Dynamic Languages
How to achieve boundaries in dynamic languages such as Ruby, Python and JavaScript? Dynamic languages do not have a concept of an interface that is enforced by the compiler, so we need to fallback to other methods.
Mixins
Refering to the diagrams above, we can extend a Service
object at design or run time with a ServiceMixin
. The client object then can methods on the Service
object. Using this technique the client can use the service library without directly depending on it.
module ServiceMixin
def some_method(options = {})
# do something
end
end
class Client
def initialize(service) # constructor
self.service = service
end
def operation
self.service.some_method
end
end
class Service
include ServiceMixin
end
# initialization code
service = Service.new
client = Client.new(service)
# where needed
client.operation
Proxies
A Service
proxy can isolate the implementation and the client. Ruby has a method_missing
method which
gets invoked when an unknown method is called on an instance object. A proxy implementation can direct the unknown
methods towards the service instance.
class Service
def some_method(options = {})
# do something
end
end
class ServiceProxy
def initialize(service) # constructor
self.service = service
end
def method_missing(method_name, *method_args)
self.service.send(method_name, *method_args)
end
end
# initialization code
service = Service.new
proxy = ServiceProxy.new(service)
client = Client.new(proxy)
# where needed
client.operation
Conclusion
Depedency Inversion is more than dependency injection. Depedency Inversion is a strategy that is implemented using dependency injection, following the dependency rule, and drawing the right boundaries amongst dependencies.
Related Posts
How Time-based OTP (TOTP) works
Sep 10 2023
The adoption of two-factor authentication (2FA) has been steadily growing over the past three years.
JAM Stack
Nov 29 2020
In a previous post on how this blog works, I referred to Gatsby and GraphQL.
Password-less Web Login with a Mobile App
Apr 17 2022
If you have used Whatsapp web, you have experienced the use case explored in this post.
The First Post
Apr 20 2019
Just like fashion, technology tends to go in circles, and static web sites are rising in popularity for content.