Skip to content

Extension Registry Design - Protocol-Based Architecture

Overview

The extension registry uses Protocol-based design to avoid circular dependencies while maintaining type safety. This follows the Dependency Inversion Principle (SOLID).

Architecture

Core Components

  1. Extension (base interface)
  2. All extensions must implement this
  3. Defines id, category, name, etc.

  4. ExecutableTask (executor interface)

  5. Extends Extension
  6. Defines execute(), get_input_schema()
  7. Located in core/interfaces/executable_task.py

  8. ExecutorLike Protocol (structural typing)

  9. Defines the "shape" of executors
  10. Located in core/extensions/protocol.py
  11. No dependency on ExecutableTask

  12. ExtensionRegistry

  13. Uses ExecutorLike protocol for type checking
  14. No direct import of ExecutableTask
  15. Located in core/extensions/registry.py

Import Flow (No Circular Dependencies)

Extension (base.py)
ExecutableTask (interfaces/executable_task.py)
ExtensionRegistry (extensions/registry.py)
    ↑ (uses ExecutorLike Protocol, not ExecutableTask)
ExecutorLike Protocol (extensions/protocol.py)
    ↑ (no dependencies)

Key Design Decisions

1. Protocol-Based Structural Typing

Instead of importing ExecutableTask for type checks, we use ExecutorLike protocol:

# ✅ Good: No circular dependency
from aipartnerupflow.core.extensions.protocol import ExecutorLike

if hasattr(extension, 'execute') and hasattr(extension, 'get_input_schema'):
    # It's an executor-like object
    ...

# ❌ Bad: Would cause circular import
from aipartnerupflow.core.interfaces.executable_task import ExecutableTask
if isinstance(extension, ExecutableTask):  # Circular!
    ...

2. Runtime Structural Checks

We use hasattr() checks instead of isinstance() with Protocol:

# Check if object has required methods (duck typing)
if hasattr(extension, 'execute') and hasattr(extension, 'get_input_schema'):
    # It implements ExecutorLike protocol
    ...

This works because: - Python's duck typing allows any object with the right methods - Protocol defines the interface without requiring inheritance - No runtime dependency on ExecutableTask

3. Type Hints with Protocol

Type hints use Protocol for better IDE support:

from aipartnerupflow.core.extensions.protocol import ExecutorFactory

_factory_functions: Dict[str, ExecutorFactory] = {}

Benefits

  1. No Circular Dependencies
  2. ExtensionRegistry doesn't import ExecutableTask
  3. ExecutableTask can safely import Extension
  4. Clean import hierarchy

  5. Type Safety

  6. Protocol provides type hints
  7. IDE autocomplete works
  8. Static type checkers understand the structure

  9. Flexibility

  10. Any class with execute() and get_input_schema() can be used
  11. Not limited to ExecutableTask inheritance
  12. Supports duck typing

  13. Maintainability

  14. Clear separation of concerns
  15. Easy to understand and modify
  16. Follows SOLID principles

Example Usage

# 1. Define executor (implements ExecutableTask which extends Extension)
from aipartnerupflow.core.base import BaseTask

class MyExecutor(BaseTask):
    id = "my_executor"
    name = "My Executor"

    async def execute(self, inputs):
        return {"result": "done"}

    def get_input_schema(self):
        return {}

# 2. Register (automatically via decorator)
from aipartnerupflow.core.extensions.decorators import extension_register

@extension_register()
class MyExecutor(BaseTask):
    ...

# 3. Use in registry (no ExecutableTask import needed)
from aipartnerupflow.core.extensions import get_registry

registry = get_registry()
executor = registry.create_executor_instance("my_executor", inputs={})
# executor has execute() and get_input_schema() - that's all we need!

Migration Notes

Before (Circular Import Problem)

# registry.py
from aipartnerupflow.core.interfaces.executable_task import ExecutableTask  # ❌

if isinstance(extension, ExecutableTask):  # Circular!
    ...

After (Protocol-Based Solution)

# protocol.py
from typing import Protocol

class ExecutorLike(Protocol):
    async def execute(self, inputs): ...
    def get_input_schema(self): ...

# registry.py
from aipartnerupflow.core.extensions.protocol import ExecutorLike  # ✅

if hasattr(extension, 'execute') and hasattr(extension, 'get_input_schema'):
    # No circular dependency!
    ...

Testing

All imports work without circular dependencies:

# ✅ All these imports work simultaneously
from aipartnerupflow.core.extensions import ExtensionRegistry
from aipartnerupflow.core.interfaces import ExecutableTask
from aipartnerupflow.core.extensions.protocol import ExecutorLike
from aipartnerupflow.extensions.stdio import StdioExecutor

Future Extensions

This pattern can be extended for other extension types:

class StorageLike(Protocol):
    def connect(self): ...
    def disconnect(self): ...

class HookLike(Protocol):
    def before_execute(self): ...
    def after_execute(self): ...