Skip to content

feat: Support instantiation with multibind #277

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: master
Choose a base branch
from

Conversation

eirikur-nc
Copy link

@eirikur-nc eirikur-nc commented Jul 23, 2025

What

Make it possible to multibind to types/classes which then get instantiated when injector.get(<MultiBoundType>) is called. Support this both for lists and dictionaries. Update the multibind documentation accordingly.

Example:

def configure(binder: Binder):
    binder.multibind(List[Plugin], to=PluginA)
    binder.multibind(List[Plugin], to=[PluginB, PluginC()])

injector = Injector([configure])
plugins = injector.get(List[Plugin])
assert len(plugins) == 3
assert isinstance(plugins[0], PluginA)
assert isinstance(plugins[1], PluginB)
assert isinstance(plugins[2], PluginC)

Why

In modular software systems, it's often convenient to leverage plug-in patterns to allow a particular module to hook into certain events. The Guice documentation explains this nicely.

Personally, I have used this pattern for lifecycle listeners that get invoked whenever an application starts up and shuts down. Imagine that you have an application that supports different database back-ends and key-value stores. In such a scenario, we typically have separate DI modules for each database system and each key-value store, e.g.

class MySQLModule(Module):
    ...

class PostgreSQLModule(Module):
    ...

class RedisModule(Module):
    ...
  
class InMemoryCacheModule(Module):
    ...

The injector is then configured using the appropriate set of modules, depending on which database and cache we want to employ, e.g. injector = Injector([PostgreSQLModule, RedisModule]).

Each of these modules can optionally register a lifecycle listener. Here's an example for a listener that closes the connection to redis on shutdown.

class RedisLifecycleListener(LifecycleListener):
    @inject
    def __init__(self, redis: Redis):
        self._redis = redis

    @override
    async def on_startup(self):
        pass

    @override
    async def on_shutdown(self):
        await self._redis.aclose()


class RedisModule(Module):
    def configure(self, binder):
        binder.multibind(list[LifecycleListener], RedisLifecycleListener)

The application code then simply asks for all lifecycle listeners and invokes them on startup and shutdown. Here's an example of a FastAPI lifespan that does this

@asynccontextmanager
async def lifespan(app: FastAPI):
    lifecycle_listeners = injector.get(list[LifecycleListener])
    for listener in lifecycle_listeners:
        await listener.on_startup()

    yield

    for listener in reversed(lifecycle_listeners):
        await listener.on_shutdown()

app = FastAPI(
    title="My API",
    lifespan=lifespan,
)

According to @alecthomas (see #121 (comment)) it was always the intention to support this behavior but up until now, users have had to employ workarounds to get this working.

Prior work

This is similar to #197 in that it solves the problem of multibinding to types. However, the API is quite different.
In #197 the proposal is to multibind to a MultiClassProvider like so

# PR 197
binder.multibind(List[A], to=MultiBindClassProvider([A, B]))

The implementation in this PR foregoes the need for a special wrapper, allowing one to mix and match classes and instances like so

# this PR
binder.multibind(List[Plugin], to=[PluginB, PluginC()])

Moreover, this PR also supports binding to classes without nesting them in lists

# this PR
binder.multibind(List[Plugin], to=PluginA)

It also supports dictionary multibindings

# this PR
def configure(binder: Binder):
    binder.multibind(Dict[str, Plugin], to={'a': PluginA})
    binder.multibind(Dict[str, Plugin], to={'b': PluginB, 'c': PluginC()})

Closes #121

:param interface: typing.Dict or typing.List instance to bind to.
:param to: Instance, class to bind to, or an explicit :class:`Provider`
subclass. Must provide a list or a dictionary, depending on the interface.
:param interface: A generic list[T] or dict[str, T] type to bind to.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I decided to use PEP 585 styled type hints. Support for them was introduced in Python 3.9. While this project still supports Python 3.8, I suspect it's only a matter of time until that support gets dropped since 3.8 has reached end-of-life.

Comment on lines 507 to 509
binder.multibind(list[Interface], to=A)
binder.multibind(list[Interface], to=[B, C()])
injector.get(list[Interface]) # [<A object at 0x1000>, <B object at 0x2000>, <C object at 0x3000>]
Copy link
Author

@eirikur-nc eirikur-nc Jul 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed these examples to illustrate the use of classes and objects, rather than strings. This improves consistency with the bind documentation.

IMO, people reach for DI libraries to simplify object construction. Injecting strings or other primitive values, while possible, is less beneficial, at least in my experience.

@eirikur-nc eirikur-nc marked this pull request as ready for review July 28, 2025 11:32
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.

How to implement a plug-in system with multibind?
1 participant