Sayantam Dey on Product Development

Effective Dependency Inversion

Dec 25, 2022
Clean Architecture

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.

Incorrect boundary

The correct way to partition would be to move the Service and Data dependencies on the same side of the boundary as the Client.

Correct boundary

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.

Enjoyed this post? Follow this blog to never miss out on future posts!

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.