Skip to content

ADR 028: Pluggy + Dishka Integration for Plugin-Contributed Services

Status: Final Type: Feature Created: 2025-11-20 Authors: Stefane Fermigier Related-ADRs: 020

Context

Hop3 uses two complementary frameworks: - Pluggy: Plugin system for discovering and executing extension points - Dishka: Dependency injection framework for managing service lifecycles

Absent an integration between them, these systems operate independently: - Plugins register via Pluggy's hook system - Services are managed via Dishka's container and providers - No mechanism lets plugins contribute services to the DI container

This separation creates several problems:

Problem 1: Manual Service Registration

Plugins had to manually register services using global registries or singletons:

# Anti-pattern: Global service registration
from hop3.services import register_service

register_service("postgres", PostgresService())

This approach: - Runs at import time (order-dependent) - Creates global state - Is difficult to test - Bypasses Dishka's lifecycle management

Problem 2: No Dependency Injection for Plugin Services

Plugin services couldn't leverage Dishka's features: - No automatic dependency resolution - No scope management (APP vs REQUEST) - No type-safe injection - Manual lifecycle management

Problem 3: Tight Coupling

To use a plugin service, code had to: 1. Know the service exists globally 2. Import from the plugin module directly 3. Handle instantiation manually

This made plugins harder to test and created tight coupling between core and plugins.

Design Goals

We needed a solution that: 1. Allows plugins to contribute services to the DI container 2. Maintains separation between Pluggy (discovery) and Dishka (DI) 3. Supports dependency injection between plugin services 4. Preserves type safety 5. Remains testable 6. Follows both frameworks' best practices

Decision

We integrate Pluggy with Dishka using a hook-based provider registration pattern, where:

  1. Plugins contribute Dishka providers via a Pluggy hook
  2. Container creation collects providers from all plugins
  3. Services are injected using standard Dishka mechanisms

Architecture

┌─────────────────┐
│  Plugin System  │
│    (Pluggy)     │
└────────┬────────┘
         │ get_di_providers() hook
         ├──► Plugin A → ProviderA
         ├──► Plugin B → ProviderB
         └──► Plugin C → ProviderC
         ┌──────────────────┐
         │  DI Container    │
         │    (Dishka)      │
         │                  │
         │ • ConfigProvider │
         │ • CoreProviders  │
         │ • ProviderA      │
         │ • ProviderB      │
         │ • ProviderC      │
         └──────────────────┘

Detailed Design

1. Hook Specification

The get_di_providers() hook in hop3/core/hookspecs.py:

@hookspec
def get_di_providers() -> list:
    """Get DI providers from this plugin.

    Returns:
        List of Dishka Provider instances
    """

2. Container Integration

Container creation in hop3/di/container.py:

def _get_plugin_providers() -> list:
    """Collect DI providers from all registered plugins."""
    pm = get_plugin_manager()
    provider_lists = pm.hook.get_di_providers()
    return [provider for sublist in provider_lists for provider in sublist]

def create_container() -> Container:
    providers = [
        ConfigProvider(),
        HopServicesProvider(),
    ]
    plugin_providers = _get_plugin_providers()
    providers.extend(plugin_providers)
    return make_container(*providers)

3. Plugin Implementation Pattern

Plugins implement the hook to contribute providers:

# myapp_plugin/plugin.py
from dishka import Provider, provide, Scope
from hop3.core.hooks import hop3_hook_impl

class MyPluginProvider(Provider):
    scope = Scope.APP

    @provide
    def get_my_service(self, config: HopConfig) -> MyService:
        return MyService(config.setting)

@hop3_hook_impl
def get_di_providers() -> list:
    return [MyPluginProvider()]

Usage Patterns

CLI Context

from hop3.di import create_container

container = create_container()
try:
    service = container.get(MyService)  # From plugin
    service.do_work()
finally:
    container.close()

Web Context

from dishka.integrations.starlette import FromDishka, inject

@inject
async def my_view(service: FromDishka[MyService]):
    """Service from plugin automatically injected."""
    return service.do_work()

Consequences

Positive

  1. Clean Separation of Concerns
  2. Pluggy handles plugin discovery and registration
  3. Dishka handles service lifecycle and injection
  4. Each framework does what it's best at

  5. Automatic Discovery

  6. Plugins register providers via hooks
  7. Container creation automatically collects them
  8. No manual registration needed

  9. Type-Safe Dependency Injection

  10. Full type checking with mypy/pyright
  11. IDE autocompletion for injected services
  12. Runtime type validation via Dishka

  13. Dependency Injection Between Plugins

  14. Plugin A can depend on services from Plugin B
  15. Dishka resolves the dependency graph
  16. Clear, declarative dependencies

  17. Proper Lifecycle Management

  18. Services use Dishka scopes (APP, REQUEST)
  19. Automatic cleanup via context managers
  20. No manual singleton management

  21. Testability

  22. Easy to create containers with mock providers
  23. Can test plugins in isolation
  24. No global state to manage

  25. Backwards Compatible

  26. Existing code continues to work
  27. Plugins can migrate incrementally
  28. No breaking changes

Negative

  1. Increased Complexity
  2. Developers must understand both Pluggy and Dishka
  3. More indirection (hook → provider → service)
  4. Learning curve for new contributors

  5. Import-Time Plugin Discovery

  6. Plugins are discovered at import time
  7. All plugins load even if not used
  8. Could be slow with many plugins (mitigated by lazy imports)

  9. No Provider Validation

  10. Invalid providers only fail at runtime
  11. No compile-time checks for provider compatibility
  12. Could add validation in future

  13. Circular Dependency Risk

  14. container.py imports plugins.py at runtime
  15. Mitigated by lazy import inside function
  16. Must be careful about import order

Neutral

  1. Two Frameworks Instead of One
  2. Could use pure Dishka or pure Pluggy
  3. But combination leverages strengths of both
  4. Industry pattern (e.g., pytest uses Pluggy + fixtures)

  5. Hook Returns List

  6. Follows Pluggy best practices
  7. Allows plugins to return multiple providers
  8. Requires flattening list of lists

Alternatives Considered

Alternative 1: Global Service Registry

# Anti-pattern
services = {}

def register_service(name, instance):
    services[name] = instance

def get_service(name):
    return services[name]

Rejected because: - Global mutable state - No type safety - No dependency injection - Hard to test

Alternative 2: Pure Dishka (No Pluggy)

Use entry points directly in Dishka:

def create_container():
    providers = [ConfigProvider()]

    # Load providers from entry points
    for ep in entry_points(group="hop3.providers"):
        provider = ep.load()
        providers.append(provider())

    return make_container(*providers)

Rejected because: - Loses Pluggy's hook system - No way to contribute build strategies, OS handlers, etc. - Would need to rebuild plugin infrastructure - Inconsistent with existing architecture

Alternative 3: Pure Pluggy (No Dishka)

Plugins provide factories via hooks:

@hookspec
def get_services() -> dict:
    """Return service name → instance mapping."""

@hookimpl
def get_services():
    return {"postgres": PostgresService()}

Rejected because: - Manual lifecycle management - No automatic dependency resolution - No type-safe injection - Would have to rebuild DI features

Alternative 4: Dependency Injection in Pluggy Hooks

Pass container to hooks, let plugins get services:

@hookspec
def initialize_plugin(container):
    """Plugin can get services from container."""

@hookimpl
def initialize_plugin(container):
    config = container.get(HopConfig)
    # Use config...

Rejected because: - Inversion of control is backwards - Plugins become tightly coupled to container - Doesn't allow plugins to contribute services - Less declarative than providers

Alternative 5: Hybrid Registry + DI

Plugins register factories, DI system calls them:

@hookimpl
def register_services():
    return {
        "postgres": lambda config: PostgresService(config)
    }

Rejected because: - More complex than provider pattern - Loses Dishka's declarative providers - Still requires custom integration code - Less type-safe

  • ADR 027: Config System Refactoring — Dishka manages configuration.
  • Dishka replaces wireup as the dependency-injection framework.
  • The global container singleton is removed in favour of explicitly created containers.

Implementation Notes

Migration Path

Plugins move from global registration to provider contribution:

Before:

# Old approach
register_service("postgres", PostgresService())

After:

# New approach
class PostgresProvider(Provider):
    scope = Scope.APP

    @provide
    def get_postgres_service(self) -> PostgresService:
        return PostgresService()

@hop3_hook_impl
def get_di_providers() -> list:
    return [PostgresProvider()]

Testing Strategy

Test plugin provider integration:

def test_plugin_contributes_provider():
    container = create_container()
    try:
        service = container.get(PostgresService)
        assert service is not None
    finally:
        container.close()

Test with mock providers:

@pytest.fixture
def container_with_mock():
    class MockProvider(Provider):
        @provide
        def get_service(self) -> Service:
            return Mock(spec=Service)

    return make_container(MockProvider())

Performance Considerations

  • Plugin discovery happens once at startup
  • Provider collection is O(n) where n = number of plugins
  • Service resolution is cached by Dishka
  • Lazy imports keep plugin load fast

References


Related ADRs: ADR 020: Pluggable Architecture for Core Deployment Workflow