Factories and Resource Management¶
Use factories to handle complex service creation logic or resource management that can't be done with simple class constructors.
Use cases¶
- Object construction needs additional logic or configuration.
- Create optional dependencies.
- Depending on the runtime environment or configuration, you may need to create different objects inheriting from the same base class/protocol.
- Inject a model/dto which represents the result of an action, such as the current authenticated user.
- Inject a class from another library where it's not possible to add annotations.
- Inject strings, ints and other built-in types.
In order for the container to inject these dependencies, you must decorate the factory with @service
and register
it with the container. Return type annotation of the factory is required as it denotes what will be built.
Generator Factories¶
Use generator factories when a service requires cleanup (like database connections or network resources).
@service
def db_session_factory() -> Iterator[Session]:
db = Session()
try:
yield db
finally:
db.close()
@service
def db_session_factory() -> Iterator[Session]:
with contextlib.closing(Session()) as db:
yield db
@service
async def client_session_factory() -> ClientSession:
async with ClientSession() as sess:
yield sess
Generator Factories
Generator factories must yield exactly once. Yielding multiple times will result in cleanup not being performed.
Implement strategy pattern¶
Assume a base class Notifier
with implementations that define how the notification is sent (IMAP, POP, WebHooks, etc.)
Given a user it is possible to instantiate the correct type of notifier based on user preferences.
from wireup import service
@service(lifetime="scoped")
def get_user_notifier(
user: AuthenticatedUser,
slack_notifier: SlackNotifier,
email_mailer: EmailNotifier
) -> Notifier:
notifier = ... # get notifier type from preferences.
return notifier
When injecting Notifier
the correct type will be injected based on the authenticated user's preferences.
Inject a third-party class¶
You can use factories to inject a class which you have not declared yourself and as such, cannot annotate. Let's take redis client as an example.
from wireup import service
@service
def redis_factory(redis_url: Annotated[str, Inject(param="redis_url")]) -> Redis:
return redis.from_url(redis_url)
Inject Models¶
Assume the authenticated user is provided by AuthService
. You may choose to allow the user to be injected directly
instead of having to call auth_service.get_current_user()
everywhere.
from wireup import service
@service(lifetime="scoped")
def get_current_user(auth_service: AuthService) -> AuthenticatedUser:
return auth_service.get_current_user()
Inject built-in types¶
If you want to inject resources which are just strings, ints, or other built-in types then you can use a factory in
combination with NewType
.
Note that since Wireup uses types to identify dependencies, new types are strongly recommended for this use case.
AuthenticatedUsername = NewType("AuthenticatedUsername", str)
@service
def authenticated_username_factory(auth: SomeAuthService) -> AuthenticatedUsername:
return AuthenticatedUsername(...)
This can now be injected as usual by annotating the dependency with the new type.
Error Handling¶
When using generator factories with scoped or transient lifetimes, unhandled errors that occur within the scope are automatically propagated to the factories. This enables proper error handling, such as rolling back database transactions or cleaning up resources when operations fail.
@service(lifetime="scoped")
def db_session_factory(engine: Engine) -> Iterator[Session]:
session = Session(engine)
try:
yield session
except Exception as e:
# Error occurred somewhere in the scope - rollback the transaction
session.rollback()
raise
else:
# No errors - commit the transaction
session.commit()
finally:
# Always close the session
session.close()
Suppressing Errors
Factories may perform cleanup (for example, rolling back a transaction), but they cannot suppress the original error, that exception will still be propagated. Wireup enforces this so cleanup code cannot change the program's control flow by swallowing errors.
If a factory raises additional exceptions during teardown, Wireup will temporarily catch those exceptions so it can finish cleaning up all generator factories. After cleanup completes, the teardown exceptions are re-raised alongside the primary exception.
Database Transaction Example¶
from typing import Iterator
from sqlalchemy.orm import Session
from wireup import service, Injected
@service(lifetime="scoped")
class UserService:
# Uses Session as defined above.
def __init__(self, db: Injected[Session]) -> None:
self.db = db
def create_user(self, user_data: UserCreate) -> User:
# Database operations here
...
# Usage in a web framework (FastAPI example)
@app.post("/users")
def create_user(
user_data: UserCreate,
user_service: Injected[UserService]
) -> User:
# If this raises an exception, the database transaction
# will automatically be rolled back
return user_service.create_user(user_data)
Framework Integration
When using Wireup with web frameworks, each request automatically gets its own scope. When using this feature, database transactions and other resources are automatically managed per request, with automatic rollback on any unhandled exception.
Optional Dependencies and Factories¶
You can both request Optional dependencies and create factories that return optional values. This is useful when a service might not be available or when you want to make a dependency optional.
Service Registration Required
When using optional dependencies, the service providing the optional dependency must still be registered in the container. The service cannot be absent - it can only return None
. This means you must register a factory that can potentially return None
,
rather than simply not registering the service at all.
Factories Returning Optional Values¶
Sometimes you want a factory to return None
when certain conditions aren't met. A common example is a service that requires configuration to be available:
@service
def cache_factory(
redis_url: Annotated[str | None, Inject(param="redis_url")],
) -> Redis | None:
return Redis.from_url(redis_url) if redis_url else None
Requesting Optional Dependencies¶
When a service has an optional dependency, simply use T | None
or Optional[T]
.
@service
class UserService:
def __init__(self, cache: Cache | None) -> None:
self.cache = cache
def get_user(self, id: str) -> User:
if self.cache:
cached = self.cache.get(id)
if cached:
return cached
# Fallback to database
...
@service
class UserService:
def __init__(self, cache: Optional[Cache]) -> None:
self.cache = cache
def get_user(self, id: str) -> User:
if self.cache:
cached = self.cache.get(id)
if cached:
return cached
# Fallback to database
...
Null Object Pattern
For cleaner code when dealing with optional dependencies, consider using the Null Object Pattern instead of conditional checks throughout your code.
Injection vs. Direct Access: Optional dependencies can be injected into services and decorated functions, but there's an important distinction when accessing them directly:
# ✅ This works - injecting optional dependencies
@service
class UserService:
def __init__(self, cache: Optional[Cache]) -> None: ...
# ✅ This works - getting the service directly
cache = container.get(Cache) # Returns None if factory returns None
# ❌ This doesn't work - cannot get Optional types directly
cache = container.get(Optional[Cache])