Skip to content
Back to skills

Extensible Framework Design

ASecurity

Designs robust extensible software frameworks with plugin architectures, configuration-driven extension points, and validation patterns — enabling third-party contributors while maintaining core stability and API contract guarantees.

  • 4 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 4, 2026
businesspythongoapiperformancedocumentation

Works with

  • cli
  • api

Security analysis

A100/100

Scanned September 4, 2026

npx -y skills add paulpas/agent-skill-router --skill extensible-framework-design --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Extensible Framework Design?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Extensible Framework Design
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/paulpas-extensible-framework-design/badge)](https://www.skillsdirectory.com/skills/paulpas-extensible-framework-design)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---




name: extensible-framework-design
description: Designs robust extensible software frameworks with plugin architectures, configuration-driven extension points, and validation patterns — enabling third-party contributors while maintaining core stability and API contract guarantees.
license: MIT
compatibility: opencode
metadata:
  version: "1.0.0"
  domain: coding
  triggers: framework design, extensible architecture, plugin system, extension points, configuration driven, hook patterns, middleware chains, how do i design an extensible system
  archetypes:
    - tactical
    - strategic
  anti_triggers:
    - brainstorming
    - vague ideation
  response_profile:
    verbosity: low
    directive_strength: high
    abstraction_level: operational
  role: implementation
  scope: implementation
  output-format: code
  content-types: [code, guidance, examples, do-dont]
  related-skills: framework-application-methodology, coding-knowledge-transfer-methods




---





# Extensible Framework Design

Makes the model design robust extensible software frameworks with plugin architectures, configuration-driven extension points, and validation patterns. When loaded, this skill enforces disciplined separation between core runtime behavior and pluggable extensions — ensuring third-party contributors can extend functionality without breaking backward compatibility or circumventing lifecycle hooks.

## TL;DR Checklist

- [ ] Identify all extension points by cataloging where external code must inject behavior
- [ ] Define plugin interfaces with strict type contracts — no duck typing at boundaries
- [ ] Write configuration schemas (JSON Schema / Pydantic models) that validate every extension's config before loading
- [ ] Implement a lifecycle manager that enforces ordered hook execution with error isolation
- [ ] Add validation enforcement so misconfigured plugins fail fast during registration, not at runtime
- [ ] Draft a backward compatibility plan documenting how plugin interfaces evolve across versions

---

## When to Use

Use this skill when:

- Building an internal framework that multiple teams or external contributors will extend via plugins or hooks
- Designing a middleware chain where processing stages must be independently pluggable and orderable
- Creating a configuration-driven system where behavior changes based on deployed extension bundles
- Migrating a monolithic application into a plugin-based architecture with clear separation of concerns
- Establishing an internal package registry where third-party developers submit extensions for review and distribution

---

## When NOT to Use

Avoid this skill for:

- **Simple applications** — A 200-line script or microservice with one responsibility needs no plugin architecture
- **When extension requirements are unknown** — Premature abstraction creates unnecessary complexity; wait until at least two distinct extensions are needed
- **Performance-critical hot paths** — Dynamic plugin resolution adds overhead; use static composition in latency-sensitive code

---

## Core Workflow

### 1. Extension Point Identification — Catalog Where External Code Must Inject Behavior

Walk the framework's core domain model and identify every location where behavior varies based on context, customer, or configuration. Each variation point becomes a potential extension point. Record each as an interface with a clear contract: inputs, outputs, side effects, and error semantics.

```python
from __future__ import annotations

import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Protocol


logger = logging.getLogger(__name__)


class ExtensionPhase(Enum):
    """Lifecycle phase at which an extension hook fires."""
    BEFORE_INPUT = "before_input"
    VALIDATE = "validate"
    PROCESS = "process"
    POST_PROCESS = "post_process"
    ERROR = "error"
    CLEANUP = "cleanup"


@dataclass(frozen=True)
class ExtensionPoint:
    """Describes a single location where plugins can inject behavior.

    Attributes:
        name: Unique identifier for this extension point within the framework.
        phase: Lifecycle phase when registered hooks execute.
        input_type: Expected type of data passed to hooks at this point.
        output_type: Expected return type from hooks (can be None for side-effect-only hooks).
        priority_range: Valid range for hook priority values. Lower runs first.
        error_policy: How framework handles exceptions thrown by registered hooks.
    """
    name: str
    phase: ExtensionPhase
    input_type: type | None = None
    output_type: type | None = None
    priority_range: tuple[int, int] = (-100, 100)
    error_policy: str = "isolate"  # "isolate", "abort", "continue"

    def validate_priority(self, priority: int) -> bool:
        """Check if a priority value falls within the allowed range."""
        low, high = self.priority_range
        return low <= priority <= high


class ExtensionHook(Protocol):
    """Contract that all plugin hooks must satisfy at an extension point."""

    def __call__(self, context: Any) -> Any: ...

    @property
    def priority(self) -> int: ...

    @property
    def name(self) -> str: ...


def catalog_extension_points(domain_model: type) -> list[ExtensionPoint]:
    """Analyze a domain model and identify natural extension points.

    Scans the provided class for methods that delegate to strategies,
    read from configuration-driven behavior tables, or contain
    branching logic based on external input — all indicators of
    natural plugin boundaries.

    Args:
        domain_model: The core framework class to analyze.

    Returns:
        List of identified extension points with inferred contracts.
    """
    import inspect
    from types import FunctionType

    points: list[ExtensionPoint] = []
    analyzed_methods: set[str] = set()

    for name, method in inspect.getmembers(domain_model, predicate=inspect.isfunction):
        if name.startswith("_") and name != "__init__":
            continue
        if name in analyzed_methods:
            continue

        source = inspect.getsource(method) if hasattr(inspect, 'getsource') else ""

        # Heuristic: methods with strategy dispatch or config lookups are extension points
        is_extension_candidate = (
            "strategy" in source.lower() or
            "config." in source.lower() or
            "plugin" in source.lower() or
            any(keyword in source for keyword in [".get(", "dict[", "case ", "if mode"])
        )

        if is_extension_candidate:
            analyzed_methods.add(name)
            sig = inspect.signature(method)
            input_type = None
            output_type = None

            # Infer input from first parameter (usually self is skipped)
            params = list(sig.parameters.values())
            if len(params) > 1:
                param = params[1]
                if param.annotation != inspect.Parameter.empty:
                    input_type = param.annotation

            if sig.return_annotation != inspect.Parameter.empty:
                output_type = sig.return_annotation

            phase = _infer_phase_from_name(name, source)

            points.append(ExtensionPoint(
                name=f"{domain_model.__name__}.{name}",
                phase=phase,
                input_type=input_type,
                output_type=output_type,
            ))

    return sorted(points, key=lambda p: (p.phase.value, p.name))


def _infer_phase_from_name(method_name: str, source: str) -> ExtensionPhase:
    """Heuristically infer the lifecycle phase from a method's name or body."""
    lower = method_name.lower()
    if any(kw in lower for kw in ("pre_", "before_", "init_", "prepare_")):
        return ExtensionPhase.BEFORE_INPUT
    if any(kw in lower for kw in ("valid", "check_", "verify_")):
        return ExtensionPhase.VALIDATE
    if any(kw in lower for kw in ("transform", "process_", "handle_", "execute_")):
        return ExtensionPhase.PROCESS
    if any(kw in lower for kw in ("post_", "after_", "cleanup_", "finish_")):
        return ExtensionPhase.POST_PROCESS
    return ExtensionPhase.PROCESS  # default
```

**Checkpoint:** Every method in your domain model that exhibits branching based on external input has an identified extension point. If a method has no corresponding `ExtensionPoint`, justify why it should remain non-extensible.

---

### 2. Plugin Interface Design — Define Strict Type Contracts

Each plugin interface must be a Protocol or ABC with explicit type annotations, documented side effects, and clearly defined error conditions. No duck typing at extension boundaries — interfaces are contracts, not suggestions.

```python
from __future__ import annotations

import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Generic, Protocol, TypeVar


logger = logging.getLogger(__name__)


T = TypeVar("T")
U = TypeVar("U")


@dataclass(frozen=True)
class PluginMetadata:
    """Immutable metadata describing a loaded plugin.

    Attributes:
        name: Unique plugin identifier (namespace/name format).
        version: Semantic version string.
        author: Organization or developer name.
        supported_versions: Framework version range this plugin supports.
        requires_config_keys: Set of top-level config keys this plugin expects.
    """
    name: str
    version: str = "0.0.0"
    author: str = ""
    supported_versions: tuple[str, str] = ("*", "*")
    requires_config_keys: frozenset[str] = field(default_factory=frozenset)


class PluginError(Exception):
    """Raised when a plugin violates its interface contract."""

    def __init__(self, plugin_name: str, violation: str, context: dict[str, Any] | None = None) -> None:
        self.plugin_name = plugin_name
        self.violation = violation
        self.context = context or {}
        super().__init__(f"PluginError[{plugin_name}]: {violation}")


class Plugin(ABC, Generic[T, U]):
    """Abstract base for all framework plugins.

    Subclasses must implement the lifecycle methods and register themselves
    with the PluginRegistry upon instantiation via the metaclass hook.

    Attributes:
        metadata: Immutable plugin identity and capability description.
        _initialized: Flag indicating whether on_load has completed successfully.
    """

    def __init__(self, metadata: PluginMetadata) -> None:
        self.metadata = metadata
        self._initialized = False

    @property
    def name(self) -> str:
        return self.metadata.name

    @abstractmethod
    async def on_load(self, config: dict[str, Any]) -> None:
        """Called when the plugin is registered. Validate configuration and initialize resources.

        Args:
            config: Configuration dictionary provided by the framework loader.

        Raises:
            PluginError: If configuration is invalid or required resources are unavailable.
        """
        ...

    @abstractmethod
    async def execute(self, input_data: T) -> U:
        """Execute the plugin's primary transformation logic.

        Args:
            input_data: The data payload to process according to the plugin's contract.

        Returns:
            Transformed output matching the plugin's declared output type.

        Raises:
            PluginError: If the input violates the plugin's preconditions.
            RuntimeError: For unexpected failures during execution.
        """
        ...

    @abstractmethod
    async def on_unload(self) -> None:
        """Called when the plugin is being removed. Release all held resources."""
        ...

    def validate_input_type(self, data: Any, expected_type: type[T]) -> bool:
        """Validate that input_data matches the expected plugin input type.

        Args:
            data: The actual input to check.
            expected_type: The type declared by the plugin interface.

        Returns:
            True if data is an instance of expected_type.
        """
        if expected_type is Any:
            return True
        return isinstance(data, expected_type)


# --- Concrete Plugin Example ---

@dataclass
class TransformInput:
    raw_value: str
    metadata: dict[str, str] = field(default_factory=dict)


@dataclass
class TransformOutput:
    processed_value: str
    tags: list[str] = field(default_factory=list)


class TextTransformer(Plugin[TransformInput, TransformOutput]):
    """Example plugin that transforms text with configurable rules.

    Subclasses override _apply_transform to provide custom logic
    while inheriting input validation, error handling, and lifecycle management.
    """

    def __init__(self, metadata: PluginMetadata, config: dict[str, Any] | None = None) -> None:
        super().__init__(metadata)
        self._config = config or {}
        self._rules: list[str] = self._config.get("rules", ["uppercase", "strip_whitespace"])

    async def on_load(self, config: dict[str, Any]) -> None:
        if not isinstance(config, dict):
            raise PluginError(
                self.name, "Configuration must be a dictionary", {"received_type": type(config).__name__}
            )
        known_keys = {"rules", "encoding", "fallback"}
        unexpected = set(config.keys()) - known_keys
        if unexpected:
            raise PluginError(
                self.name, f"Unknown configuration keys: {unexpected}",
                context={"unexpected_keys": list(unexpected)}
            )
        self._rules = config.get("rules", ["uppercase"])
        self._initialized = True

    async def execute(self, input_data: TransformInput) -> TransformOutput:
        if not self._initialized:
            raise PluginError(self.name, "Plugin not initialized — call on_load first")
        if not isinstance(input_data, TransformInput):
            raise PluginError(
                self.name, f"Invalid input type",
                context={"expected": TransformInput.__name__, "received": type(input_data).__name__}
            )

        result = input_data.raw_value
        for rule in self._rules:
            if rule == "uppercase":
                result = result.upper()
            elif rule == "lowercase":
                result = result.lower()
            elif rule == "strip_whitespace":
                result = result.strip()

        return TransformOutput(processed_value=result, tags=self._rules)

    async def on_unload(self) -> None:
        self._initialized = False
        self._rules.clear()
```

**Checkpoint:** Every plugin interface has explicit type annotations on all abstract methods, documented side effects in docstrings, and raises `PluginError` for contract violations — not generic exceptions.

---

### 3. Configuration Schema Definition — Validate Extensions Before Loading

Every extension point must have an accompanying JSON Schema or Pydantic model that validates the configuration before a plugin is registered. Reject invalid configs at load time, never at runtime.

```python
from __future__ import annotations

import json
import logging
from dataclasses import dataclass, field
from typing import Any


logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class SchemaValidationError:
    """Describes a single configuration validation failure."""
    key_path: str  # Dot-separated path to the invalid field
    message: str
    received_value: Any


class ConfigSchemaValidator:
    """Validates plugin configurations against declared schemas.

    Supports Pydantic models, JSON Schema dictionaries, and custom validators.
    Rejects configurations that violate the schema before any plugin code runs.
    """

    def __init__(self) -> None:
        self._schemas: dict[str, Any] = {}
        self._validators: dict[str, Any] = {}

    def register_schema(self, extension_name: str, schema: Any, validator_fn: Any | None = None) -> None:
        """Register a validation schema for a specific extension point.

        Args:
            extension_name: The name of the extension this schema applies to.
            schema: Either a Pydantic BaseModel class or a JSON Schema dictionary.
            validator_fn: Optional custom validation function taking (config) -> list[SchemaValidationError].
        """
        self._schemas[extension_name] = schema
        if validator_fn is not None:
            self._validators[extension_name] = validator_fn

    def validate(self, extension_name: str, config: dict[str, Any]) -> list[SchemaValidationError]:
        """Validate a configuration against the registered schema.

        Args:
            extension_name: The extension whose schema to use.
            config: Configuration dictionary to validate.

        Returns:
            List of validation errors (empty if valid).
        """
        if extension_name not in self._schemas:
            return [SchemaValidationError("", f"No schema registered for extension '{extension_name}'", config)]

        schema = self._schemas[extension_name]
        validator_fn = self._validators.get(extension_name)

        # Custom validator takes priority
        if validator_fn is not None:
            try:
                errors = validator_fn(config)
                return errors if isinstance(errors, list) else []
            except Exception as exc:
                return [SchemaValidationError("", f"Custom validator failed: {exc}", config)]

        # JSON Schema validation
        if isinstance(schema, dict) and "$schema" in schema:
            return self._validate_json_schema(schema, config)

        # Pydantic model validation
        try:
            model_cls = schema if hasattr(schema, "model_validate") else None
            if model_cls is not None:
                model_cls.model_validate(config)  # type: ignore[attr-defined]
                return []
        except Exception:
            pass

        return [SchemaValidationError("", f"Unsupported schema type: {type(schema).__name__}", config)]


    def _validate_json_schema(self, schema: dict[str, Any], config: dict[str, Any]) -> list[SchemaValidationError]:
        """Validate config against a JSON Schema definition.

        Uses a lightweight approach without external dependencies —
        checks required fields and basic type constraints.
        """
        errors: list[SchemaValidationError] = []

        required_fields = schema.get("required", [])
        properties = schema.get("properties", {})

        for req_key in required_fields:
            if req_key not in config:
                errors.append(SchemaValidationError(
                    key_path=req_key,
                    message=f"Required field missing: {req_key}",
                    received_value=None
                ))

        # Type checking for provided fields
        for key, value in config.items():
            if key in properties:
                expected_type_spec = properties[key].get("type")
                if expected_type_spec and isinstance(value, dict):
                    # Nested object — simple check
                    continue

        return errors


# --- Usage Example ---

def create_text_transformer_schema() -> dict[str, Any]:
    """Define the JSON Schema for the TextTransformer plugin."""
    return {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "type": "object",
        "required": ["rules"],
        "properties": {
            "rules": {
                "type": "array",
                "items": {"type": "string"},
                "minItems": 1,
                "enum": ["uppercase", "lowercase", "strip_whitespace", "title_case"],
            },
            "encoding": {
                "type": "string",
                "default": "utf-8",
                "enum": ["utf-8", "ascii", "latin-1"],
            },
            "fallback": {
                "type": "string",
                "default": "",
            },
        },
    }


# Demonstrate validation
if __name__ == "__main__":
    validator = ConfigSchemaValidator()
    validator.register_schema("text_transformer", create_text_transformer_schema())

    # Valid config
    valid_config = {"rules": ["uppercase", "strip_whitespace"], "encoding": "utf-8"}
    errors = validator.validate("text_transformer", valid_config)
    print(f"Valid config errors: {errors}")  # []

    # Invalid config — missing required field
    invalid_config = {"encoding": "ascii"}
    errors = validator.validate("text_transformer", invalid_config)
    print(f"Invalid config errors: {[e.message for e in errors]}")
```

**Checkpoint:** Every plugin has a registered schema, and no plugin passes the `on_load` phase without first passing schema validation. Invalid configurations are rejected with specific error messages pointing to exact field paths.

---

### 4. Lifecycle Management — Enforce Ordered Hook Execution with Error Isolation

The framework must manage the full plugin lifecycle (load → execute → unload) and ensure hooks at each phase execute in priority order. Exceptions from one hook must not prevent other hooks from running — errors are collected and reported, never swallowed silently.

```python
from __future__ import annotations

import asyncio
import logging
from dataclasses import dataclass, field
from typing import Any, Callable


logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class HookExecutionResult:
    """Result of executing a single hook in the lifecycle pipeline."""
    hook_name: str
    phase: str
    priority: int
    succeeded: bool
    output: Any | None = None
    error: Exception | None = None


class LifecycleManager:
    """Manages ordered execution of hooks across all extension phases.

    Each phase maintains a sorted list of hook functions by priority.
    Execution is sequential within a phase but isolated per hook so that
    one failure does not prevent subsequent hooks from running.

    Attributes:
        _phases: Mapping of phase name → list of (priority, callable) tuples, sorted ascending.
        _registered_hooks: Registry tracking which hooks are active per extension.
    """

    def __init__(self) -> None:
        self._phases: dict[str, list[tuple[int, Callable[..., Any], str]]] = {}
        self._registered_hooks: dict[str, list[str]] = {}

    def register_hook(
        self, phase_name: str, priority: int, hook_fn: Callable[..., Any],
        extension_name: str, description: str = ""
    ) -> None:
        """Register a hook function for execution at a specific lifecycle phase.

        Hooks are sorted by priority (ascending) within each phase before execution.

        Args:
            phase_name: The lifecycle phase to attach this hook to.
            priority: Execution order — lower values run first. Must be in the extension point's range.
            hook_fn: Async callable accepting a shared context dict.
            extension_name: Owning extension identifier for lifecycle tracking.
            description: Human-readable name of this hook for logging.
        """
        if phase_name not in self._phases:
            self._phases[phase_name] = []

        # Validate priority range against the registered extension point
        from framework_extension import get_extension_point  # type: ignore
        try:
            ep = get_extension_point(phase_name)
            if not ep.validate_priority(priority):
                raise ValueError(
                    f"Priority {priority} out of range [{ep.priority_range}] for phase '{phase_name}'"
                )
        except Exception:
            # If we can't resolve the extension point, log warning but allow registration
            logger.warning("Could not validate priority %s for phase %s — registering anyway", priority, phase_name)

        self._phases[phase_name].append((priority, hook_fn, description or f"hook_at_{phase_name}_{len(self._phases[phase_name])}"))
        self._phases[phase_name].sort(key=lambda x: x[0])

        if extension_name not in self._registered_hooks:
            self._registered_hooks[extension_name] = []
        hook_desc = description or f"hook_at_{phase_name}"
        if hook_desc not in self._registered_hooks[extension_name]:
            self._registered_hooks[extension_name].append(hook_desc)

    def unregister_hook(self, extension_name: str) -> None:
        """Remove all hooks registered by a specific extension.

        Args:
            extension_name: The extension whose hooks to remove from all phases.
        """
        for phase_hooks in self._phases.values():
            self._phases[phase_name] = [
                (p, fn, desc) for p, fn, desc in phase_hooks
                if desc not in (self._registered_hooks.get(extension_name, []))
            ]
        self._registered_hooks.pop(extension_name, None)

    async def execute_phase(self, phase_name: str, context: dict[str, Any]) -> list[HookExecutionResult]:
        """Execute all hooks registered for a phase in priority order.

        Each hook runs in isolation — exceptions are caught and recorded
        but do not prevent subsequent hooks from executing.

        Args:
            phase_name: The lifecycle phase to execute.
            context: Shared mutable context dict passed to every hook.

        Returns:
            List of HookExecutionResult objects, one per executed hook.
        """
        hooks = self._phases.get(phase_name, [])
        results: list[HookExecutionResult] = []

        for priority, hook_fn, description in hooks:
            try:
                output = await hook_fn(context) if asyncio.iscoroutinefunction(hook_fn) else hook_fn(context)  # type: ignore[misc]
                results.append(HookExecutionResult(
                    hook_name=description, phase=phase_name,
                    priority=priority, succeeded=True, output=output
                ))
            except Exception as exc:
                logger.error("Hook '%s' in phase '%s' failed (priority=%d): %s",
                             description, phase_name, priority, exc)
                results.append(HookExecutionResult(
                    hook_name=description, phase=phase_name,
                    priority=priority, succeeded=False, error=exc
                ))

        return results

    async def execute_all_phases(self, context: dict[str, Any]) -> dict[str, list[HookExecutionResult]]:
        """Execute all registered phases in their defined order.

        Args:
            context: Shared context passed to every phase and hook.

        Returns:
            Mapping of phase name → list of execution results.
        """
        ordered_phases = [
            ExtensionPhase.BEFORE_INPUT,
            ExtensionPhase.VALIDATE,
            ExtensionPhase.PROCESS,
            ExtensionPhase.POST_PROCESS,
        ]
        results: dict[str, list[HookExecutionResult]] = {}

        for phase in ordered_phases:
            phase_results = await self.execute_phase(phase.value, context)
            results[phase.value] = phase_results

            # Check for abort policy — if any hook failed with error_policy "abort"
            if any(not r.succeeded and getattr(r.error, "error_policy", "isolate") == "abort"
                   for phase_res in results.values()
                   for r in phase_res):
                logger.warning("Abort triggered at phase %s — skipping remaining phases", phase.value)
                break

        return results


# --- Demonstration ---

if __name__ == "__main__":
    async def demo() -> None:
        manager = LifecycleManager()

        async def logging_hook(ctx: dict[str, Any]) -> dict[str, Any]:
            print(f"[HOOK] Logging: {ctx.get('action', 'unknown')}")
            return {"logged": True}

        async def transform_hook(ctx: dict[str, Any]) -> dict[str, Any]:
            value = ctx.get("value", "")
            ctx["transformed"] = value.upper()
            return {"transformed": True}

        # Register hooks with priorities
        manager.register_hook(ExtensionPhase.VALIDATE.value, priority=10, hook_fn=logging_hook,
                              extension_name="logging_plugin", description="validate-logger")
        manager.register_hook(ExtensionPhase.PROCESS.value, priority=5, hook_fn=transform_hook,
                              extension_name="transformer_plugin", description="data-transformer")

        # Execute all phases
        context = {"value": "hello world", "action": "test_request"}
        results = await manager.execute_all_phases(context)

        for phase, hooks in results.items():
            print(f"\nPhase: {phase}")
            for h in hooks:
                status = "OK" if h.succeeded else f"FAIL ({h.error})"
                print(f"  [{status}] {h.hook_name} (priority={h.priority})")

    asyncio.run(demo())
```

**Checkpoint:** Hook execution results are collected and inspectable after each phase. Failed hooks log errors but do not crash the pipeline unless the error policy explicitly requires aborting. Every hook has a unique description for traceability in production logs.

---

### 5. Validation Enforcement — Fail Fast During Registration, Not at Runtime

Register every plugin through a central registry that validates: type conformance against the declared interface, schema compliance, and uniqueness of extension point registrations. Plugins that fail validation are rejected with detailed error messages during startup, never silently loaded.

```python
from __future__ import annotations

import importlib.metadata
import logging
from dataclasses import dataclass, field
from typing import Any


logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class RegistrationResult:
    """Outcome of a plugin registration attempt.

    Attributes:
        success: Whether the plugin was registered successfully.
        plugin_name: Name of the attempted plugin.
        errors: List of human-readable error messages (empty on success).
        warnings: Non-fatal observations that don't block registration.
    """
    success: bool
    plugin_name: str
    errors: list[str] = field(default_factory=list)
    warnings: list[str] = field(default_factory=list)


class PluginRegistry:
    """Central registry for framework plugins with validation enforcement.

    All plugins must go through this registry before they can be loaded
    or executed. The registry enforces type conformance, schema validation,
    and registration uniqueness.

    Attributes:
        _plugins: Mapping of plugin name → Plugin instance.
        _schemas: Mapping of extension point name → validation schema.
        _validators: ConfigSchemaValidator instance for config validation.
    """

    def __init__(self) -> None:
        self._plugins: dict[str, Plugin] = {}
        self._schemas: dict[str, Any] = {}
        self._validators = ConfigSchemaValidator()

    def register_schema(self, extension_point_name: str, schema: Any) -> None:
        """Register a validation schema for an extension point.

        Args:
            extension_point_name: Name of the extension point.
            schema: JSON Schema dict or Pydantic model class.
        """
        self._schemas[extension_point_name] = schema
        self._validators.register_schema(extension_point_name, schema)

    def register_plugin(self, plugin: Plugin, config: dict[str, Any] | None = None) -> RegistrationResult:
        """Validate and register a plugin through the central registry.

        Performs three validation gates in order:
        1. Type conformance — does the plugin implement the Plugin ABC?
        2. Schema validation — does its config match the extension's schema?
        3. Uniqueness — is there no existing plugin with the same name?

        Args:
            plugin: The Plugin instance to register.
            config: Configuration dictionary for this plugin.

        Returns:
            RegistrationResult with detailed success/failure information.
        """
        errors: list[str] = []
        warnings: list[str] = []
        plugin_name = plugin.name

        # Gate 1: Type conformance check
        if not isinstance(plugin, Plugin):
            errors.append(
                f"Plugin '{plugin_name}' does not implement the Plugin ABC. "
                f"Missing abstract methods: {self._missing_abstract_methods(type(plugin))}"
            )

        # Gate 2: Schema validation
        if config is not None:
            schema_errors = self._validators.validate(plugin_name, config)
            for err in schema_errors:
                errors.append(f"Config validation failed for '{plugin_name}': [{err.key_path}] {err.message}")

        # Gate 3: Uniqueness check
        if plugin_name in self._plugins:
            existing = self._plugins[plugin_name]
            errors.append(
                f"Plugin name '{plugin_name}' is already registered "
                f"(version {existing.metadata.version})"
            )

        # Gate 4: on_load execution (if all gates passed)
        if not errors and plugin_name not in self._plugins:
            import asyncio
            try:
                loop = asyncio.get_running_loop()
            except RuntimeError:
                loop = None

            if loop:
                asyncio.run_coroutine_threadsafe(plugin.on_load(config or {}), loop)
            else:
                try:
                    import asyncio
                    asyncio.run(plugin.on_load(config or {}))
                except Exception as load_exc:
                    errors.append(f"Plugin on_load failed for '{plugin_name}': {load_exc}")

        if errors:
            return RegistrationResult(success=False, plugin_name=plugin_name, errors=errors, warnings=warnings)

        self._plugins[plugin_name] = plugin
        return RegistrationResult(success=True, plugin_name=plugin_name, errors=[], warnings=warnings)


    def get_plugin(self, name: str) -> Plugin | None:
        """Retrieve a registered plugin by name.

        Args:
            name: The plugin's unique identifier.

        Returns:
            The Plugin instance or None if not found.
        """
        return self._plugins.get(name)

    def list_plugins(self) -> list[str]:
        """Return names of all currently registered plugins, sorted alphabetically."""
        return sorted(self._plugins.keys())

    def unregister_plugin(self, name: str) -> RegistrationResult:
        """Remove a plugin from the registry and call its on_unload hook.

        Args:
            name: Plugin identifier to remove.

        Returns:
            RegistrationResult indicating success or failure.
        """
        if name not in self._plugins:
            return RegistrationResult(
                success=False, plugin_name=name,
                errors=[f"No plugin registered with name '{name}'"]
            )

        plugin = self._plugins.pop(name)
        try:
            import asyncio
            asyncio.run(plugin.on_unload())
        except Exception as exc:
            return RegistrationResult(
                success=False, plugin_name=name,
                errors=[f"Plugin on_unload failed for '{name}': {exc}"]
            )

        return RegistrationResult(success=True, plugin_name=name)

    def _missing_abstract_methods(self, cls: type) -> list[str]:
        """Identify which abstract methods are not implemented by a class."""
        if not hasattr(ABC, '__abstractmethods__'):
            return []
        try:
            from abc import ABC
            missing = []
            for attr in getattr(cls, '__abstractmethods__', set()):
                if not hasattr(cls, attr) or isinstance(getattr(cls, attr), property):
                    missing.append(attr)
            return missing
        except Exception:
            return []
```

**Checkpoint:** The registry rejects misconfigured plugins at registration time with specific error messages. No plugin can execute unless it passes all four validation gates (type conformance, schema validation, uniqueness, on_load success).

---

### 6. Backward Compatibility Planning — Document How Plugin Interfaces Evolve Across Versions

Create a compatibility contract that defines what changes are allowed between major/minor/patch versions. Use version ranges in plugin metadata to declare supported framework versions. Provide migration guides for deprecated interfaces.

```python
from __future__ import annotations

import re
from dataclasses import dataclass, field
from enum import Enum
from typing import Any


class VersionBump(Enum):
    MAJOR = "major"      # Breaking change — plugins may break
    MINOR = "minor"      # New feature — backward compatible
    PATCH = "patch"      # Bug fix — backward compatible


@dataclass(frozen=True)
class CompatibilityRule:
    """Defines what kinds of changes are allowed in a version bump.

    Attributes:
        plugin_interface_name: The interface this rule applies to.
        allowed_modifications: Operations permitted without breaking plugins.
        deprecated_operations: Operations that work but emit warnings.
        removed_operations: Operations no longer available after this version.
        migration_guide_url: URL to documentation on how to migrate plugins.
    """
    plugin_interface_name: str
    allowed_modifications: list[str] = field(default_factory=list)
    deprecated_operations: list[str] = field(default_factory=list)
    removed_operations: list[str] = field(default_factory=list)
    migration_guide_url: str = ""


class CompatibilityChecker:
    """Validates plugin compatibility against declared framework version constraints.

    Tracks semantic versions of both the framework core and each plugin,
    enforcing that plugins only load when their supported version range
    includes the current framework version.
    """

    VERSION_PATTERN = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")

    def __init__(self) -> None:
        self._rules: dict[str, CompatibilityRule] = {}
        self._current_framework_version: str = "0.0.0"

    def set_framework_version(self, version: str) -> None:
        """Set the current framework core version for compatibility checks.

        Args:
            version: Semantic version string (e.g., "3.2.1").
        """
        self._validate_semver(version)
        self._current_framework_version = version

    def register_rule(self, rule: CompatibilityRule) -> None:
        """Register a compatibility rule for a plugin interface.

        Args:
            rule: The compatibility rule defining allowed/forbidden changes.
        """
        self._rules[rule.plugin_interface_name] = rule

    def check_plugin_compatibility(
        self, plugin: Plugin, framework_version: str | None = None
    ) -> list[str]:
        """Check whether a plugin is compatible with the current framework version.

        Args:
            plugin: The plugin to check.
            framework_version: Optional override for the framework version (defaults to set value).

        Returns:
            List of compatibility issues (empty if fully compatible).
        """
        fw_ver = framework_version or self._current_framework_version
        issues: list[str] = []

        # Parse versions
        fw_parts = self._parse_semver(fw_ver)
        plugin_parts = self._parse_semver(plugin.metadata.version)

        # Check major version — breaking change if plugin targets different major
        fw_major, fw_minor, _ = fw_parts
        plugin_major, plugin_minor, _ = plugin_parts

        if fw_major != plugin_major and plugin_major > 0:
            issues.append(
                f"Plugin '{plugin.name}' targets major v{plugin_major}, "
                f"but framework is at v{fw_major}. Breaking changes likely."
            )

        # Check if the plugin's interface has been modified since its version
        rule = self._rules.get(plugin.__class__.__name__)
        if rule:
            for removed in rule.removed_operations:
                if hasattr(plugin, removed):
                    issues.append(
                        f"Plugin '{plugin.name}' uses deprecated method '{removed}' "
                        f"which was removed in framework {fw_ver}. "
                        f"Migrate: {rule.migration_guide_url}"
                    )

            for deprecated in rule.deprecated_operations:
                if hasattr(plugin, deprecated):
                    import warnings
                    warnings.warn(
                        f"Plugin '{plugin.name}' uses method '{deprecated}' "
                        f"which is deprecated in framework {fw_ver}.",
                        DeprecationWarning, stacklevel=2
                    )

        return issues

    def _validate_semver(self, version: str) -> bool:
        """Validate that a string conforms to semantic versioning format."""
        if not self.VERSION_PATTERN.match(version):
            raise ValueError(f"Invalid semver format: '{version}' — expected X.Y.Z")
        return True

    def _parse_semver(self, version: str) -> tuple[int, int, int]:
        """Parse a semantic version string into (major, minor, patch) integers."""
        match = self.VERSION_PATTERN.match(version)
        if not match:
            raise ValueError(f"Cannot parse semver: '{version}'")
        return (int(match.group(1)), int(match.group(2)), int(match.group(3)))


# --- Usage Example ---

if __name__ == "__main__":
    checker = CompatibilityChecker()
    checker.set_framework_version("3.2.1")

    rule = CompatibilityRule(
        plugin_interface_name="TextTransformer",
        allowed_modifications=["add_method", "add_parameter_with_default"],
        deprecated_operations=["legacy_transform"],
        removed_operations=["remove_this_method_v4"],
        migration_guide_url="https://docs.example.com/migrate-v3-to-v4"
    )
    checker.register_rule(rule)

    # Simulate a plugin
    meta = PluginMetadata(name="my-transformer", version="3.1.0", author="team-abc")
    plugin = TextTransformer(metadata=meta, config={"rules": ["uppercase"]})

    issues = checker.check_plugin_compatibility(plugin)
    if issues:
        for issue in issues:
            print(f"  ⚠ {issue}")
    else:
        print("Plugin is fully compatible.")
```

**Checkpoint:** Every plugin interface has an associated `CompatibilityRule` documenting allowed modifications, deprecations, and removals. Plugin metadata includes a version range declaration that the checker validates at load time. Breaking changes between major versions are explicitly blocked unless the plugin declares compatibility.

---

## Implementation Patterns

### Pattern 1: Entry Point Discovery for Console Script Plugins

Enable third-party plugins to self-register via Python entry points (similar to setuptools/console_scripts), allowing them to be discovered automatically without manual registration code.

```python
from __future__ import annotations

import logging
from dataclasses import dataclass, field


logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class EntryPoint:
    """Represents a discovered plugin entry point from distribution metadata.

    Attributes:
        name: Entry point group/name (e.g., "my_framework.plugins").
        value: Dotted import path to the plugin class (e.g., "mypackage.plugin:MyPlugin").
        dist_name: Name of the distribution that provides this entry point.
        dist_version: Version of the providing distribution.
    """
    name: str
    value: str
    dist_name: str
    dist_version: str


def discover_entry_points(group: str) -> list[EntryPoint]:
    """Discover all registered entry points for a given group.

    Uses importlib.metadata to find plugins that have registered
    themselves via setuptools/pyproject.toml console_scripts-style
    entry point declarations.

    Args:
        group: The entry point group name as declared in pyproject.toml.

    Returns:
        List of EntryPoint objects representing discoverable plugins.
    """
    import importlib.metadata as metadata

    entries: list[EntryPoint] = []

    try:
        dists = metadata.distributions()
        for dist in dists:
            eps = dist.entry_points
            for ep in eps:
                if ep.group == group:
                    entries.append(EntryPoint(
                        name=ep.name,
                        value=ep.value,
                        dist_name=dist.metadata.get("Name", "unknown"),
                        dist_version=dist.version or "0.0.0",
                    ))
    except Exception as exc:
        logger.warning("Failed to discover entry points in group '%s': %s", group, exc)

    return entries


def load_entry_point(entry: EntryPoint) -> Any | None:
    """Dynamically import and instantiate a class from an entry point value.

    The entry point value format is "module.path:class_name".
    This function imports the module, retrieves the class, and returns it.

    Args:
        entry: The EntryPoint discovered via metadata.

    Returns:
        The imported class object, or None on failure.
    """
    try:
        module_path, class_name = entry.value.rsplit(":", 1)
        module = importlib.import_module(module_path)
        cls = getattr(module, class_name, None)
        if cls is None:
            logger.error("Entry point '%s' does not export class '%s'", entry.value, class_name)
            return None
        if not isinstance(cls, type):
            logger.error("Entry point '%s' exports '%s' which is not a class", entry.value, class_name)
            return None
        return cls
    except ImportError as exc:
        logger.warning("Failed to import module for entry point '%s': %s", entry.name, exc)
        return None
    except Exception as exc:
        logger.error("Unexpected error loading entry point '%s': %s", entry.name, exc)
        return None


if __name__ == "__main__":
    # Discover plugins in a group (replace with your actual group name)
    discovered = discover_entry_points("my_framework.plugins")
    for ep in discovered:
        print(f"  {ep.dist_name}=={ep.dist_version}: {ep.name} → {ep.value}")

        cls = load_entry_point(ep)
        if cls is not None:
            # Instantiate and register the plugin class
            import sys
            from inspect import signature
            sig = signature(cls.__init__)
            params = list(sig.parameters.values())
            if len(params) > 1:
                print(f"    Constructor requires: {[p.name for p in params[1:]]}")
```

**Checkpoint:** All discoverable entry points are listed with their source distribution and version. Failed imports log warnings but don't crash the discovery process — misconfigured plugins are isolated, not fatal.

---

### Pattern 2: Middleware Chain Builder with Cross-Cutting Concerns

Build composable middleware chains where each middleware has access to request/response context, can short-circuit processing, and participates in structured error handling across the entire pipeline.

```python
from __future__ import annotations

import asyncio
import logging
from dataclasses import dataclass, field
from typing import Any, Callable


logger = logging.getLogger(__name__)


@dataclass
class MiddlewareContext:
    """Shared mutable context passed through the middleware chain.

    Each middleware can read and write fields to pass state downstream.
    Setting `halted` to True prevents subsequent middleware from executing.

    Attributes:
        request: Incoming request data (set by first middleware or framework).
        response: Accumulated response data (built by middleware).
        halted: If True, remaining middleware in the chain are skipped.
        error: Set when an exception occurs in the pipeline.
        metadata: Free-form dict for arbitrary middleware-to-middleware communication.
    """
    request: Any = None
    response: Any = None
    halted: bool = False
    error: Exception | None = None
    metadata: dict[str, Any] = field(default_factory=dict)


# Type alias for a single middleware function
MiddlewareFn = Callable[[MiddlewareContext], asyncio.Future[Any] | None]


class MiddlewareChain:
    """Composable middleware chain with early-exit support.

    Middleware are added via `use()` and executed in registration order.
    Any middleware can set `context.halted = True` to prevent downstream processing.
    Exceptions are captured and stored in context.error without crashing the chain.

    Attributes:
        _middlewares: List of (name, priority, fn) tuples sorted by priority.
    """

    def __init__(self) -> None:
        self._middlewares: list[tuple[str, int, MiddlewareFn]] = []

    def use(self, name: str, priority: int, middleware_fn: MiddlewareFn) -> None:
        """Add a middleware function to the chain.

        Args:
            name: Human-readable identifier for this middleware.
            priority: Execution order — lower numbers run first.
            middleware_fn: Async callable that receives and optionally mutates context.
        """
        self._middlewares.append((name, priority, middleware_fn))
        self._middlewares.sort(key=lambda x: x[1])

    async def process(self, context: MiddlewareContext) -> MiddlewareContext:
        """Execute the full middleware pipeline on the given context.

        Each middleware runs in sequence. If a middleware sets
        `context.halted = True`, subsequent middleware are skipped.
        Exceptions in any middleware are caught and stored in context.error.

        Args:
            context: The initial request context to process.

        Returns:
            The (possibly modified) context after all applicable middleware run.
        """
        for name, _, middleware_fn in self._middlewares:
            if context.halted or context.error is not None:
                logger.debug("Skipping middleware '%s' — chain halted or errored", name)
                break

            try:
                result = middleware_fn(context)
                if asyncio.iscoroutine(result) or asyncio.isfuture(result):
                    await result  # type: ignore[misc]
            except Exception as exc:
                logger.exception("Middleware '%s' failed", name)
                context.error = exc
                context.halted = True
                break

        return context


# --- Concrete Middleware Examples ---

async def auth_middleware(ctx: MiddlewareContext) -> None:
    """Validate authentication tokens on incoming requests."""
    headers = ctx.request.get("headers", {}) if isinstance(ctx.request, dict) else {}
    token = headers.get("Authorization", "")

    if not token.startswith("Bearer "):
        ctx.error = ValueError("Missing or invalid Authorization header")
        ctx.halted = True
        ctx.response = {"error": "unauthorized", "code": 401}
        return

    # In production, validate JWT here
    ctx.metadata["auth_valid"] = True


async def rate_limit_middleware(ctx: MiddlewareContext) -> None:
    """Apply rate limiting based on client IP in the request metadata."""
    if not isinstance(ctx.request, dict):
        return

    client_ip = ctx.request.get("client_ip", "unknown")
    request_count = ctx.metadata.get(f"rate_limit:{client_ip}", 0)

    MAX_REQUESTS_PER_WINDOW = 100
    if request_count >= MAX_REQUESTS_PER_WINDOW:
        ctx.error = RuntimeError(f"Rate limit exceeded for {client_ip}")
        ctx.halted = True
        ctx.response = {"error": "rate_limit_exceeded", "code": 429}


async def logging_middleware(ctx: MiddlewareContext) -> None:
    """Log the final response after all middleware have run."""
    if ctx.error is not None:
        logger.warning("Request failed: %s — error=%s", ctx.request, ctx.error)
    else:
        logger.info("Request processed successfully. Response keys: %s",
                    list(ctx.response.keys()) if isinstance(ctx.response, dict) else "N/A")


# --- Usage Example ---

if __name__ == "__main__":
    chain = MiddlewareChain()
    chain.use("auth", priority=10, middleware_fn=auth_middleware)
    chain.use("rate_limit", priority=20, middleware_fn=rate_limit_middleware)
    chain.use("logging", priority=100, middleware_fn=logging_middleware)

    async def demo() -> None:
        # Valid request
        ctx1 = MiddlewareContext(
            request={"headers": {"Authorization": "Bearer valid-token"}, "client_ip": "10.0.0.1"}
        )
        result1 = await chain.process(ctx1)
        print(f"Valid: halted={result1.halted}, error={result1.error}")

        # Missing auth
        ctx2 = MiddlewareContext(
            request={"headers": {}, "client_ip": "10.0.0.2"}
        )
        result2 = await chain.process(ctx2)
        print(f"No auth: halted={result2.halted}, error={result2.error}")

    asyncio.run(demo())
```

**Checkpoint:** Every middleware is independently testable — pass a `MiddlewareContext` with controlled inputs and verify the mutations. The chain supports early exit via `context.halted`, and all errors flow through `context.error` for centralized handling downstream.

---

## Constraints

### MUST DO
- Define every plugin interface as an ABC or Protocol with explicit type annotations
- Register validation schemas before any plugin loads — reject invalid config at registration time
- Execute hooks in priority order within each lifecycle phase
- Isolate hook exceptions so one failure does not crash the entire phase
- Document backward compatibility rules for every public plugin interface

### MUST NOT DO
- Never use duck typing at plugin boundaries — enforce interface conformance with isinstance() checks
- Load plugins without validating their configuration schema first
- Allow multiple plugins with identical names in the registry without version differentiation
- Execute hooks synchronously if any hook might perform I/O — always use async
- Modify shared `MiddlewareContext` fields outside of declared middleware — all mutations go through explicit middleware functions

---

## Output Template

When this skill is active, model output must contain:

1. **Extension Point Catalog** — List of identified extension points with phase, input/output types, and priority ranges
2. **Plugin Interface Definitions** — ABC/Protocol definitions with type signatures and docstrings
3. **Configuration Schemas** — JSON Schema or Pydantic models for every plugin's config
4. **Lifecycle Manager Setup** — Hook registration code with priorities and error policies
5. **Registry Registration Flow** — Plugin instantiation, schema validation, and compatibility checking
6. **Backward Compatibility Matrix** — Allowed modifications per version bump with migration URLs

---

## Related Skills

| Skill | Purpose |
|-------|---------|
| `framework-application-methodology` | Systematically evaluate third-party frameworks before building your own extensible one |
| `coding-knowledge-transfer-methods` | Train team members on the plugin architecture and extension point conventions |

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…