Skip to content

Conversation

@maldoinc
Copy link
Owner

@maldoinc maldoinc commented Jan 3, 2026

This PR introduces a modernized Wireup API focused on clearer naming, explicit abstraction binding, and first-class support for protocols.

This will be a minor backwards-compatible release with the previous API continuing to work until v3, but being deprecated.


1. @service -> @injectable

The @service decorator will be renamed to @injectable. The name service carries architectural baggage and is misleading for many injected values. Even in Wireup's own docs the usage of @service is confusing.

AuthenticatedUsername = NewType("AuthenticatedUsername", str)

-@service(lifetime="scoped")
+@injectable(lifetime="scoped")
def authenticated_username_factory(auth: SomeAuthService) -> AuthenticatedUsername:
    return AuthenticatedUsername(...)

Many injected values such as AuthenticatedUsername from the example, are not really "services".

The name injectable is strictly about capability: A thing that can be injected. It is agnostic to the produced object's role in the system architecture making it overall less confusing.

Note: This is a pure rename and the behavior is unchanged.

2. Inject(param=...) -> Inject(config=)

Same as "service", injecting "parameters" is somewhat ambiguous for users. The name itself is also very overloaded especially in the context of a web application: You have query parameters, path parameters, function parameters in the signature and now Wireup parameters.

The feature remains to enable co-located definitions, but it is renamed to config to make it explicit that values come from Wireup’s configuration rather than some runtime or function parameters.

-container = wireup.create_{a}sync_container(params={"api_key": "secret"})
+container = wireup.create_{a}sync_container(config={"api_key": "secret"})

@wireup.inject_from_container(container)
-def main(api_key: Annotated[str, Inject(param="api_key")]) -> None:
+def main(api_key: Annotated[str, Inject(config="api_key")]) -> None:
    pass

Similarly container.params is deprecated in favor of container.config.

Note: Same as above, this is a pure rename and the behavior is unchanged.

3. Better support for abstractions

Currently Wireup requires tagging classes to be used as "interfaces" with @abstract. The linking between the two is indirect, wireup scans bases to see if any of them was marked with @abstract to do the wiring. This also has the side-effect that the container knows both about the implementation and the abstraction.

This is now deprecated in favor of explicit binding via as_type.

  • You can now bind classes or protocols without owning/decorating them.
  • Unlike @abstract, as_type replaces the registration rather than creating an alias. The concrete type will no longer be visible to the container unless explicitly exposed (see example below).
  • If the factory where this is used returns an optional type FooImpl | None, as_type=Foo automatically registers the key as Foo | None to ensure runtime safety.
-@abstract
class Cache(abc.ABC):
    def get(self, key): ...
    def set(self, key, value): ...

-@service
+@injectable(as_type=Cache)  # as_type can be any regular class, abc or protocol.
class InMemoryCache(Cache):
    ...

For factories, you can control the registration by setting the return type or using as_type. You may choose to keep the concrete type visible (e.g. for tests) while exposing only the abstraction to the container.

@injectable(as_type=Cache, qualifier="redis")
def make_redis_cache(...) -> Redis:
    return Redis(...)

With functions since you can control the return type (unlike @injectable on a class), instead of using as_type=Cache, you can have the function return Cache for the same effect.

-@injectable(as_type=Cache, qualifier="redis")
+@injectable(qualifier="redis")
def make_redis_cache(...) -> Cache:
    return Redis(...)

If you need to keep both the implementation and the abstraction visible to the container you can explicitly expose both by writing a small adapter.

@injectable
class FooImpl: ...

@injectable
def make_foo_protocol(impl: FooImpl) -> FooProtocol:
    return impl

Both FooImpl and FooProtocol are visible to the container and will reuse the same instance.

Improved container creation signature

Given the "service" rename, the signatures must be updated as well. The new api exposes a new injectables parameter where you can place either injectables themselves or modules for wireup to scan for injectables rather than scattering them in two parameters (services and service_modules).

-container = wireup.create_{a}sync_container(service_modules=[services, repositories], services=[AuthService])
+container = wireup.create_{a}sync_container(injectables=[services, repositories, AuthService])

4. Improved handling of optional values

Wireup supports optional values as first-class citizens, with one caveat:

@injectable
def make_cache(
    redis_url: Annotated[str | None, Inject(config="redis_url")],
) -> Redis | None:
    return Redis.from_url(redis_url) if redis_url else None

This dependency can be injected into function signatures as Redis | None.
However, when using the container as a service locator, users previously had to write container.get(Redis) This meant the type checker was unaware that the value could be absent.

This is now fixed: container.get(Redis | None) is supported and correctly typed.

Calling container.get(T) for a dependency registered as T | None will continue to work, but will emit a deprecation warning.

* Initial support for protocols via binds

* binds -> as_type; Replace registration instead of aliasing

* Add as_type tests

* Fix import

* Handle optional factories

* Update tests

* Rm @abstract from docs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants