Hone logo
Hone
Problems

Implementing Dependency Injection in Python

Dependency Injection (DI) is a powerful design pattern that promotes loose coupling and makes code more testable and maintainable. This challenge asks you to implement a basic dependency injection container in Python, allowing you to manage the creation and injection of dependencies for your classes.

Problem Description

Your task is to build a simple dependency injection container. This container will be responsible for:

  1. Registering classes or factories that can be used to create dependencies.
  2. Resolving dependencies by creating instances of registered classes and injecting their own dependencies.
  3. Handling circular dependencies gracefully (e.g., by raising an error).

You should design a Container class that provides methods for registration and resolution. The container should be able to resolve dependencies based on their type (e.g., a class itself) or a registered name/alias.

Key Requirements:

  • Registration:
    • Allow registration of a class with an optional key (name/alias). If no key is provided, the class name (lowercase) will be used as the default key.
    • Allow registration of a factory function that returns an instance of a dependency.
  • Resolution:
    • When a class is resolved, the container should automatically inspect its __init__ method's type hints.
    • For each parameter in __init__, the container should attempt to resolve and inject a dependency of the corresponding type or key.
    • If a dependency cannot be resolved, an appropriate error should be raised.
  • Singleton Scope (Optional but Recommended): The container should optionally support creating dependencies as singletons (i.e., only one instance is created per key and reused on subsequent resolutions).
  • Circular Dependency Detection: The container should detect and report circular dependencies to prevent infinite recursion during resolution.

Expected Behavior:

  • A class can be instantiated with its dependencies automatically provided by the container.
  • The container should handle complex dependency chains.
  • Errors should be raised for unresolvable dependencies or circular dependencies.

Edge Cases:

  • Classes with no dependencies in their __init__ method.
  • Classes with dependencies that are registered with custom keys.
  • Dependencies that are primitive types (e.g., int, str) – these should generally not be automatically injected unless explicitly registered.
  • Circular dependencies between two or more classes.

Examples

Example 1: Basic Dependency Injection

class Database:
    def __init__(self):
        print("Database initialized")

class UserRepository:
    def __init__(self, db: Database):
        self.db = db
        print("UserRepository initialized with Database")

class UserService:
    def __init__(self, user_repo: UserRepository):
        self.user_repo = user_repo
        print("UserService initialized with UserRepository")

# --- Container Usage ---
# container = Container()
# container.register(Database)
# container.register(UserRepository)
# container.register(UserService)
#
# userService_instance = container.resolve(UserService)
# assert isinstance(userService_instance, UserService)
# assert isinstance(userService_instance.user_repo, UserRepository)
# assert isinstance(userService_instance.user_repo.db, Database)

Output: (Console output during initialization and resolution)

Database initialized
UserRepository initialized with Database
UserService initialized with UserRepository

Explanation: The Container registers Database, UserRepository, and UserService. When UserService is resolved, the container sees it needs a UserRepository. It then resolves UserRepository, which needs a Database. It resolves Database (creating an instance), injects it into UserRepository, then injects UserRepository into UserService.

Example 2: Using Custom Keys and Factories

class Config:
    def __init__(self, setting: str):
        self.setting = setting
        print(f"Config initialized with setting: {setting}")

def create_db_connection(config: Config):
    print(f"Creating DB connection with config: {config.setting}")
    return f"DB Connection (using {config.setting})"

# --- Container Usage ---
# container = Container()
# container.register(Config, key="app_config", instance=Config("production")) # Registering with a specific instance
# container.register_factory("db_conn", create_db_connection)
#
# db_connection = container.resolve("db_conn")
# assert db_connection == "DB Connection (using production)"
#
# app_config_instance = container.resolve("app_config")
# assert isinstance(app_config_instance, Config)
# assert app_config_instance.setting == "production"

Output: (Console output during initialization and resolution)

Config initialized with setting: production
Creating DB connection with config: production

Explanation: Config is registered with the key "app_config" and an explicit instance is provided. The create_db_connection factory is registered with the key "db_conn". When "db_conn" is resolved, the factory is called, and it automatically resolves its dependency "app_config" from the container.

Example 3: Circular Dependency

class ServiceA:
    def __init__(self, service_b: 'ServiceB'):
        self.service_b = service_b
        print("ServiceA initialized")

class ServiceB:
    def __init__(self, service_a: ServiceA):
        self.service_a = service_a
        print("ServiceB initialized")

# --- Container Usage ---
# container = Container()
# container.register(ServiceA)
# container.register(ServiceB)
#
# try:
#     container.resolve(ServiceA)
# except Exception as e:
#     print(f"Caught expected error: {e}")

Output:

Caught expected error: Circular dependency detected: ServiceA -> ServiceB -> ServiceA

Explanation: ServiceA depends on ServiceB, and ServiceB depends on ServiceA. When the container attempts to resolve ServiceA, it needs ServiceB, which in turn needs ServiceA, leading to a circular dependency. The container should detect this and raise an error.

Constraints

  • Your Container class should be implemented in Python 3.7+.
  • Type hints must be used for dependency resolution where applicable.
  • The solution should aim for a time complexity of O(N) for registration and O(N*M) in the worst case for resolution (where N is the number of registered items and M is the depth of dependency), assuming no excessively deep or complex dependency graphs beyond what's reasonably expected.
  • Memory usage should be proportional to the number of registered items and their instances.
  • Do not use any third-party dependency injection libraries.

Notes

  • Consider how you will store registered classes, factories, and their scopes (e.g., transient, singleton).
  • For resolving dependencies, you'll likely need to introspect class __init__ methods and their type annotations. The inspect module in Python can be very helpful here.
  • Think about how to represent the dependency graph to detect cycles. A simple way is to keep track of the resolution path.
  • For the optional singleton scope, ensure that once an instance is created, it's stored and reused for subsequent resolutions of the same key.
  • When resolving a class, if a parameter's type hint is a primitive (like int, str, bool), it's generally a good idea to not automatically inject it unless it's explicitly registered with a specific key that matches the parameter name or type. This prevents the container from trying to guess values for basic types.
Loading editor...
python