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:
- Registering classes or factories that can be used to create dependencies.
- Resolving dependencies by creating instances of registered classes and injecting their own dependencies.
- 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.
- When a class is resolved, the container should automatically inspect its
- 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
Containerclass 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. Theinspectmodule 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.