Question

While I know this is the tight coupling example:

class User:
    def __init__(self):
        pass

    def get_status(self):
        api_client = APIClient()
        status = api_client.get_user_status()
        return status

And this is the loose coupling example:

class User:
    def __init__(self, api_client):
        self.api_client = api_client

    def get_status(self):
        status = self.api_client.get_user_status()
        return status

Then what about these:

Example 1

class User:
    def __init__(self):
        self.api_client = APIClient()

    def get_status(self):
        status = self.api_client.get_user_status()
        return status

Example 2

class User:
    def __init__(self):
        self.api_client_class = APIClient

    def get_status(self):
        api_client = self.api_client_class()
        status = api_client.get_user_status()
        return status

Example 3

class User:
    def __init__(self):
        pass

    def get_api_client(self):
        return APIClient()

    def get_status(self):
        api_client = self.get_api_client()
        status = api_client.get_user_status()
        return status

All those 3 examples makes it easy to stub the APIClient dependency in unit tests and it also allows the User's class consumer to inject something other than APIClient (example 3 is the worst one anyway I guess).

But the question is: Are those examples tightly coupled or not? Which of these examples is the best one? Are they proper or not? Are they better than the tightly coupled example?

Was it helpful?

Solution

Which ones break when APIClient doesn't exist?

Being able to override APIClient is nice. It's handy to have a known good default. But it's still more coupled than having no idea that APIClient exists.

OTHER TIPS

Coupling in python is more prevelant at the import level.

If you have a User class in ./user.py like so:

class User:
    def __init__(self, api_client):
        self.api_client = api_client

    def get_status(self):
        status = self.api_client.get_user_status()
        return status

It is totally decoupled from anything. The moment you introduce an import statement, you've coupled it to the module you're importing.

What is usually done is moving the coupling untill you can't anymore. In some bootstrap file: bootstrap.py

from api_client import ApiClient
from user import User

api_client = ApiClient()
user = User(api_client)

This is file is totally coupled to both User and ApiClient. However neither User or ApiClient are coupled to one another.

Static typed languages would have coupled User to IApiClient and implemented it in Apiclient. Althougth this conveys useful information in what is it the User needs from ApiClient, it is not at all necessary in python, has an ApiClient2 would work just fine, as long as it had the same python API as ApiClient, where statically compiled languages would have failed to compile.

This all depends on the project itself, its dimension, parties involved, etc.

How I approach it

Lately, the approach I have been following is to actually couple the things. Like:

from api_client import api_client

class User:
    def get_status(self):
        status = api_client.get_user_status()
        return status

It may seem bad to be coupling those two, but the reality of maintaining most of the projects where this has been applied has proven to be good.

  1. ApiClient may change, as long as the api itself does not change the User class sees no difference.
  2. The api_client in the User class module is perfectly mockable. Assuming tests are your only scenario to use a different implementation (true for most of my cases)
  3. If you need to have the actual dependencies decided at runtime, use dependency injection.
  4. The ApiClient itself (or the module) is way more in control. Where previously bootstrap.py may have changed to acomodate ApiClient config changes, that is limited to the api_client module itself (no leaky abstraction in this regard, or leak of responsibilities).
  5. Configuration injection is done via environment variables. Again if you don't need to make runtime decisions in what configuration to use (what API url to use, etc), its as simple as looking at os.environ.
  6. ApiClient tests may instantiate a differently configured ApiClient object than the one provided in api_client for import. The latter beeing the production default.

In essence, I've been prefering to keep everything in one place, and worry about dependency injecction when the need arises.

Its just a problem fixed for free.

Your examples might be easier to test, but still tightly coupled.

Testing isn't the only reason to eliminate coupling, you also do it for maintainability. Imagine there is a new version of the API and instead of you APIClient, you need APIVersion2Client, you won't be able to do this replacement without crude hacks and your dependency on APIClient will still be there, even though you won't use it.

class User:
    def __init__(self, api_client):
        self.api_client = api_client

    def get_status(self):
        status = self.api_client.get_user_status()
        return status

Only this is considered as loose coupling as it is using dependency injection.

Instead of giving access to whole api_client to User class, it will be better to pass function api_client.get_user_status to User class.

By doing this User class will not know about api_client class at all. This can be also achieved by declaring a new interface which only has functionalities required by User class.

Both of these approaches make sure that in future User class will not start using other functionalities provided by api_client class.

Licensed under: CC-BY-SA with attribution
scroll top