Factories
Use factories to handle complex creation logic, instantiate third-party classes, or manage resources that can't be handled by simple class constructors.
Third-party Classes¶
You can't add @injectable to code you don't own. Factories solve this by wrapping third-party classes in a function
you control. The factory creates and configures the object, and Wireup registers it by the return type.
Example: Redis Client
from typing import Annotated
import redis
from wireup import Inject, injectable
@injectable
def redis_factory(
url: Annotated[str, Inject(config="redis_url")],
) -> redis.Redis:
return redis.from_url(url)
# Usage:
@injectable
class AuthService:
def __init__(self, cache: redis.Redis):
self.cache = cache
Pure Domain Objects¶
If you prefer to keep your domain layer free of Wireup imports, don't use @injectable on those classes. Instead,
create factory functions in a separate wiring module that construct and return them.
# No Wireup imports here
class UserService:
def __init__(self, repository: UserRepository) -> None:
self.repository = repository
from wireup import injectable
from domain.services import UserService
@injectable
def user_service_factory(repository: UserRepository) -> UserService:
return UserService(repository)
Complex Initialization¶
Some objects need conditional logic, multi-step setup, or configuration that depends on the environment. Factories let you encapsulate this logic in one place rather than scattering it across your codebase.
from wireup import injectable
@injectable
def db_connection_factory(config: AppConfig) -> DatabaseConnection:
timeout = config.timeout if config.is_production else 30
conn = DatabaseConnection(dsn=config.dsn, timeout=timeout)
conn.set_encoding("utf-8")
return conn
Injecting Primitives¶
If you register two factories that both return str, Wireup can't tell them apart. Use typing.NewType to create
distinct types so each primitive can be requested independently.
from typing import NewType
from wireup import injectable
AuthenticatedUsername = NewType("AuthenticatedUsername", str)
@injectable(lifetime="scoped")
def authenticated_username_factory(auth: AuthService) -> AuthenticatedUsername:
user = auth.get_current_user()
return AuthenticatedUsername(user.username)
Usage:
from wireup import injectable
@injectable(lifetime="scoped")
class UserProfileService:
def __init__(self, username: AuthenticatedUsername):
self.username = username
Strategy Pattern¶
When the correct implementation depends on runtime state (user preferences, feature flags, environment), a factory can inspect other dependencies and return the appropriate one.
from typing import Protocol
from wireup import injectable
class Notifier(Protocol):
def notify(self, message: str): ...
@injectable(lifetime="scoped")
def get_user_notifier(
user: AuthenticatedUser,
slack: SlackNotifier,
email: EmailNotifier,
) -> Notifier:
if user.prefers_slack:
return slack
return email
Models and DTOs¶
Sometimes you want to inject data that comes from another service, like the currently authenticated user. A factory can call that service and return the result, making it available as a dependency.
from wireup import injectable
@injectable(lifetime="scoped")
def get_current_user(auth_service: AuthService) -> AuthenticatedUser:
return auth_service.get_current_user()
Optional Dependencies¶
Some dependencies are only available under certain conditions, like a cache that's disabled in development. Factories
can return None to signal the dependency isn't available, and consumers can handle this gracefully.
Registration Required
The factory must still be registered even if it returns None. Wireup needs to know how to resolve the specific
type, even if the result is nothing.
from wireup import injectable
@injectable
def cache_factory(settings: Settings) -> Redis | None:
if not settings.use_cache:
return None
return Redis.from_url(settings.redis_url)
Requesting Optional Dependencies
from wireup import injectable
@injectable
class UserService:
# Use T | None (Python 3.10+) or Optional[T]
def __init__(self, cache: Redis | None) -> None:
self.cache = cache
def get(self, user_id: str):
if self.cache:
return self.cache.get(user_id)
# ...
Null Object Pattern for Optional Dependencies
Instead of adding conditional checks throughout your code, use the pattern to handle optional dependencies cleanly. It involves creating a noop implementation that can be used when the real implementation is not available.
from typing import Annotated, Any, Protocol
from wireup import Inject, injectable
class Cache(Protocol):
def get(self, key: str) -> Any | None: ...
def set(self, key: str, value: str) -> None: ...
class RedisCache: ... # Real Redis implementation
class NullCache:
def get(self, key: str) -> Any | None:
return None # Always cache miss
def set(self, key: str, value: str) -> None:
return None # Do nothing
@injectable
def cache_factory(
redis_url: Annotated[str | None, Inject(config="redis_url")],
) -> Cache:
return RedisCache(redis_url) if redis_url else NullCache()
Usage
@injectable
class UserService:
def __init__(self, cache: Cache | None):
self.cache = cache
def get_user(self, user_id: str) -> User:
# Guard required
if self.cache and (cached := self.cache.get(f"user:{user_id}")):
return User.from_json(cached)
user = self.db.get_user(user_id)
# Guard required
if self.cache:
self.cache.set(f"user:{user_id}", user.to_json())
return user
@injectable
class UserService:
def __init__(self, cache: Cache):
self.cache = cache # Always a Cache instance
def get_user(self, user_id: str) -> User:
if cached := self.cache.get(f"user:{user_id}"):
return User.from_json(cached)
user = self.db.get_user(user_id)
self.cache.set(f"user:{user_id}", user.to_json())
return user