Interfaces
Use protocols or abstract base classes (ABCs) to define the behavior your application needs, separately from how it is implemented. This allows you to easily switch between different implementations, for example using an in-memory repository during testing instead of a real database.
For generic repository patterns and other parameterized types, see Generic Dependencies.
Basic Usage¶
You can use the as_type parameter in @injectable to register an injectable as any other type. This is commonly used
to bind a concrete class to a Protocol or an Abstract Base Class.
from typing import Protocol
from wireup import injectable
class Cache(Protocol):
def get(self, key: str) -> str | None: ...
def set(self, key: str, value: str): ...
@injectable(as_type=Cache)
class InMemoryCache: ...
from abc import ABC, abstractmethod
from wireup import injectable
class Cache(ABC):
@abstractmethod
def get(self, key: str) -> str | None: ...
@abstractmethod
def set(self, key: str, value: str): ...
@injectable(as_type=Cache)
class InMemoryCache(Cache):
def get(self, key: str) -> str | None: ...
def set(self, key: str, value: str): ...
Type Checking Limitation
Type checkers cannot verify that the decorated class implements the protocol or ABC specified in as_type. This is a
Python type system limitation. If you want static guarantees, consider using factory functions with return type
annotations instead of class decorators.
Runtime Validation
Wireup performs runtime validation for as_type with the following behavior:
- Non-protocol targets (
ABC/regular classes): strictissubclassvalidation. @runtime_checkableprotocols: best-effort runtime validation.- Non-runtime-checkable protocols: no runtime structural validation.
To get the most reliable guarantees:
- Prefer factory return types over
as_typewhen possible, since return annotations are statically checkable. - Prefer
ABCs when you need strict runtime enforcement. - Mark protocols as
@runtime_checkableif you want Wireup to attempt runtime checks.
With factories, you control the registration type via the return annotation, which gives stronger static checks:
from wireup import injectable
@injectable
def make_cache() -> Cache:
return InMemoryCache()
This is equivalent to:
@injectable(as_type=Cache)
class InMemoryCache: ...
Use as_type when you want to register under a different type while keeping the original implementation type for other
direct uses.
Multiple Implementations¶
When you have multiple implementations of the same type, use qualifiers to distinguish between them. This is useful for environment-specific behavior (e.g., in-memory cache in development, Redis in production) or feature flags.
from typing import Annotated
from wireup import Inject, injectable, inject_from_container
@injectable(as_type=Cache, qualifier="memory")
class InMemoryCache: ...
@injectable(as_type=Cache, qualifier="redis")
class RedisCache: ...
@inject_from_container(container)
def main(
memory_cache: Annotated[Cache, Inject(qualifier="memory")],
redis_cache: Annotated[Cache, Inject(qualifier="redis")],
): ...
Qualifiers don't have to be strings
You can avoid magic strings by using Enums or hashable types for qualifiers. This prevents typos and makes refactoring easier.
from enum import StrEnum
class CacheType(StrEnum):
MEMORY = "memory"
REDIS = "redis"
@injectable(as_type=Cache, qualifier=CacheType.REDIS)
class RedisCache: ...
Use Type Aliases for Repeated Qualified Injections
If you repeatedly inject the same qualified dependency, consider creating a type alias once and reusing it:
from typing import Annotated
from wireup import Inject, Injected, inject_from_container
RedisCacheDep = Annotated[Cache, Inject(qualifier="redis")]
@inject_from_container(container)
def main(
default_cache: Injected[Cache],
redis_cache: RedisCacheDep,
): ...
Default Implementation¶
You can register a default implementation by omitting the qualifier on one of the implementations. When Cache is
requested without a qualifier, the default will be injected.
from typing import Annotated
from wireup import Inject, injectable, Injected, inject_from_container
@injectable(as_type=Cache)
class InMemoryCache: ...
@injectable(as_type=Cache, qualifier="redis")
class RedisCache: ...
@inject_from_container(container)
def main(
# Default implementation: InMemoryCache
memory_cache: Injected[Cache],
# RedisCache: Qualified via qualifier="redis".
redis_cache: Annotated[Cache, Inject(qualifier="redis")],
): ...
Collection Injection¶
When a type has one or more registrations, you can request all of them at once as a collection. Collection injection
returns every registration for the requested type. If you used as_type to register under a different type, the collection includes the as_type target, not the original.
from collections.abc import Hashable, Mapping
from typing import Protocol
from wireup import create_sync_container, injectable
class Cache(Protocol):
def get(self, key: str) -> str: ...
@injectable(as_type=Cache)
class InMemoryCache:
def get(self, key: str) -> str:
return f"memory:{key}"
@injectable(as_type=Cache, qualifier="redis")
class RedisCache:
def get(self, key: str) -> str:
return f"redis:{key}"
Supported Collection Types¶
Collection injection only supports collections.abc.Sequence[T] and collections.abc.Mapping[Hashable, T].
Requesting typing.Sequence[T] or typing.Mapping[K, V] raises UnknownServiceRequestedError with a hint pointing
at the corresponding collections.abc type.
Sequence[T]¶
Use collections.abc.Sequence[T] to receive every registration for T in registration order.
from collections.abc import Sequence
from wireup import injectable
@injectable
class CacheReporter:
def __init__(self, caches: Sequence[Cache]) -> None:
self.caches = caches
Sequence[T] includes every registration for T in registration order, including the unqualified default, if
present, plus any qualified implementations.
Mapping[Hashable, T]¶
Use collections.abc.Mapping[Hashable, T] to receive every registration for T keyed by qualifier.
from dataclasses import dataclass
from wireup import injectable
@injectable
@dataclass
class CacheRouter:
def __init__(self, caches: Mapping[Hashable, Cache]) -> None:
self.caches = caches
def default(self) -> Cache:
return self.caches[None]
Mapping[Hashable, T] includes every registration for T, keyed by qualifier. The unqualified default is keyed
under None.
Custom Collection Factories¶
Built-in collection injection covers collections.abc.Sequence[T] and collections.abc.Mapping[Hashable, T].
When you need a filtered, transformed, or domain-specific collection shape, create it with a regular factory.
This is an advanced pattern. Most applications can inject Sequence[T] or Mapping[Hashable, T] directly.
Use a custom collection factory when you want to:
- filter out some implementations
- reshape the built-in mapping into application-specific keys
- expose a collection under a domain-specific type
Filter a Collection¶
You can build a derived collection by injecting the built-in Mapping[Hashable, T] and returning a new type.
from collections.abc import Hashable, Mapping
from typing import NewType, Protocol
from wireup import injectable
class Cache(Protocol):
def source(self) -> str: ...
# Optionally NewType-annotate the result for better type safety and readability.
FilteredCaches = NewType("FilteredCaches", list[Cache])
@injectable(as_type=Cache)
class MemoryCache:
def source(self) -> str:
return "memory"
@injectable(as_type=Cache, qualifier="redis")
class RedisCache:
def source(self) -> str:
return "redis"
@injectable
def filtered_caches(
caches: Mapping[Hashable, Cache],
) -> FilteredCaches:
return FilteredCaches(
[cache for key, cache in caches.items() if key is not None]
)
filtered_caches receives the built-in qualifier mapping for Cache, removes the default entry under None, and
registers the result as FilteredCaches.
Consumers can then request the derived collection directly:
from wireup import injectable
@injectable
class CacheReporter:
def __init__(self, caches: FilteredCaches) -> None:
self.caches = caches
Reshape a Mapping¶
You can also build your own mapping type on top of Mapping[Hashable, T].
from collections.abc import Hashable, Mapping
from typing import NewType
from wireup import injectable
CacheMap = NewType("CacheMap", dict[str, Cache])
@injectable
def cache_map(caches: Mapping[Hashable, Cache]) -> CacheMap:
return CacheMap(
{
"default": caches[None],
**{
str(key): value
for key, value in caches.items()
if key is not None
},
}
)
This lets you keep Wireup's built-in qualifier-based collection injection while exposing the result under the shape your application actually wants.
Start with the built-in collection types when they already match your needs:
- use
Sequence[T]when order matters and qualifiers do not - use
Mapping[Hashable, T]when you want qualifier-based access
Reach for a custom factory only when you need additional filtering, transformation, or a distinct domain type.
as_type with Optional Types¶
When registering factory functions that return optional types (e.g. Cache | None), the binding is automatically
registered as optional.
@injectable(as_type=Cache)
def make_cache() -> RedisCache | None: ...
This acts as if the factory was registered with as_type=Cache | None, allowing you to inject Cache | None or
Optional[Cache].