Keyed services
Overview
Keyed services let us register multiple implementations of the same abstraction and pick the one we need at resolution time by supplying a key. They are ideal for multi-tenant workloads, per-region clients, feature flags, or whenever a single interface must be backed by different resources.
Introductory example
We have two implementations of NotificationService: one that sends emails and another that sends push notifications. We want to be able to choose which implementation to use when injecting NotificationService into UserService.
We can use add_keyed_transient, add_keyed_scoped, or add_keyed_singleton to bind an implementation to a specific key.
class NotificationService(ABC):
@abstractmethod
async def send_notification(self, recipient: str, message: str) -> None:
...
class EmailService(NotificationService):
@override
async def send_notification(self, recipient: str, message: str) -> None:
pass
class PushNotificationService(NotificationService):
@override
async def send_notification(self, recipient: str, message: str) -> None:
pass
class UserService:
def __init__(
self,
notification_service: Annotated[NotificationService, FromKeyedServices("email")],
) -> None:
self.notification_service = notification_service
async def create_user(self, email: str) -> None:
user = self.create_user(email)
await self.notification_service.send_notification(user.id, "Welcome to our service!")
services.add_keyed_transient("email", NotificationService, EmailService)
services.add_keyed_transient("push", NotificationService, PushNotificationService)
In the example we're using a key of type string, but we can use any type (enums, integers, etc.).
Example with feature flags
class OrderService:
def __init__(
self,
feature_manager: FeatureManager,
service_provider: BaseServiceProvider
) -> None:
self.feature_manager = feature_manager
self.service_provider = service_provider
async def calculate_price(self, product: Product) -> Decimal:
pricing_service = (
await self.service_provider.get_required_keyed_service("new", PricingService)
if await self.feature_manager.is_enabled("NewPricing")
else await self.service_provider.get_required_keyed_service("legacy", PricingService)
)
return pricing_service.calculate_price(product)
services.add_keyed_transient("new", PricingService, NewPricingService)
services.add_keyed_transient("legacy", PricingService, LegacyPricingService)
Or we can also use a factory to encapsulate the logic and a transient registration.
Register multiple and dynamic keyed services
Factories receive the requested key as their first argument so we can flow it into the created object when needed.
def inject_principal_postgres_client(_: str | None) -> PostgresClient:
return PostgresClient(f"postgresql://principal1239139123213.example/db")
def inject_secondary_postgres_client(_: str | None) -> PostgresClient:
return PostgresClient(f"postgresql://secondary9954322u3912u123.example/db")
def inject_tenant_postgres_client(tenant_id: str | None) -> PostgresClient:
return PostgresClient(f"postgresql://{tenant_id}.example/db")
services = ServiceCollection()
services.add_keyed_singleton("principal", inject_principal_postgres_client)
services.add_keyed_singleton("secondary", inject_secondary_postgres_client)
services.add_keyed_singleton(KeyedService.ANY_KEY, inject_tenant_postgres_client)
async with services.build_service_provider() as service_provider:
postgres_client = await service_provider.get_required_keyed_service(
"principal", PostgresClient
)
The ANY_KEY registration works as a fallback: any lookup that does not find a dedicated key reuses that instance.
Passing None as the key resolves services that were explicitly registered with None, but it also falls back to the unkeyed registration of the same service type when no keyed entry exists. This makes it easy to gradually adopt keyed services without duplicating registrations.
We can query registrations programmatically through ServiceProviderIsKeyedService.is_keyed_service(key, service_type) to decide when to fall back to defaults.
Composing services with FromKeyedServices
We can inject keyed dependencies into other services via typing.Annotated and the FromKeyedServices helper:
class TenantRepository:
def __init__(
self,
connection: Annotated[PostgresClient, FromKeyedServices("tenant-1")],
) -> None:
self.connection = connection
FromKeyedServices behaves differently depending on how we call it:
FromKeyedServices("tenant-1")resolves that exact key.FromKeyedServices(None)forces theNonekey (or the unkeyed registration fallback).FromKeyedServices()inherits the key that was used to resolve the parent service. This is ideal when the parent itself is keyed and we want every nested dependency to share the same key automatically.
Receiving the key inside a service
To know which key was requested when our service was resolved, annotate a constructor parameter with ServiceKey():
from typing import Annotated
from wirio.annotations import ServiceKey
class KeyAwareCache:
def __init__(self, key: Annotated[str, ServiceKey()]) -> None:
self._cache_namespace = f"tenant:{key}"
This works only when the service is itself resolved via a key (explicitly or through inheritance). Trying to use ServiceKey() on an unkeyed service raises CannotResolveServiceError.
Wildcard registrations and caveats
KeyedService.ANY_KEYlets us register a catch-all implementation but cannot be used when resolving services. Attempting to resolve with that sentinel value raisesKeyedServiceAnyKeyUsedToResolveServiceError.- Factories registered under
ANY_KEYreceive the requested key so they can still personalize the instance. - Register with
Nonewhen we want a dedicated "default" slot that can still be requested explicitly viaget_required_keyed_service(None, service_type)orFromKeyedServices(None).
Best practices
- Pick a stable key type (string tenant IDs, enums, UUIDs) and reuse it consistently.
- Prefer inherited keys (
FromKeyedServices()) for chains of dependent services so they all operate within the same tenant context. - Use
ServiceProviderIsKeyedServiceto guard features that require a keyed registration and to emit helpful errors during startup. - Fall back to
KeyedService.ANY_KEYorNoneto provide safe defaults, but keep the wildcard work lightweight to avoid becoming a hotspot.