Skip to content

Hooks vs Plugins

Functualize provides two extension mechanisms — hooks and plugins — that serve different purposes but work together. This guide clarifies when to use each and how to share reusable behavior with colleagues.

At a Glance

Hooks Plugins
What it is A callback function registered against a lifecycle event An installable Python package discovered via entry points
Scope Reacts to lifecycle events (before/after job, config resolution) Can do anything: register hooks, add CLI commands, inject config, declare dependencies
Discovery Registered imperatively in code Auto-discovered via pyproject.toml entry points
Distribution Lives in your app code Installable package (pip install, uv add)
Versioning None (part of your app) PEP 440 versioned, dependency-managed
Config support None (read config yourself in the handler) Automatic config resolution via config_model / config_section
Dependency ordering Registration order only Topological sort via depends_on

The Relationship

Plugins are the delivery mechanism; hooks are one of the extension points a plugin can use. A plugin often registers hooks as part of its setup:

from functualize.plugin import HookEvent

class MetricsPlugin:
    name = "metrics"
    version = "1.0.0"
    description = "Emits timing metrics for every job"

    def __call__(self, app):
        app.hook_registry.register_global(HookEvent.BEFORE_JOB, self._start_timer)
        app.hook_registry.register_global(HookEvent.ON_TEARDOWN, self._emit_duration)

    def _start_timer(self, rc):
        import time
        rc.metadata["_metrics_start"] = time.perf_counter()

    def _emit_duration(self, rc):
        import time
        start = rc.metadata.get("_metrics_start")
        if start:
            duration = time.perf_counter() - start
            rc.log(f"[metrics] Job completed in {duration:.3f}s")

Why app is untyped here

hook_registry is not a PluginHost member — only APP_READY has a port door, app.hooks.on_ready. Every other event still goes through app.hook_registry, which is public on FunctualizeApp but off the narrow plugin port, so annotate such a plugin app: FunctualizeApp rather than Any.

But plugins can also do things hooks cannot:

  • Add CLI commands (via app.cli_command)
  • Declare config schemas that get auto-resolved through the Resolution Chain
  • Participate in dependency ordering (depends_on)
  • Subscribe to custom events on the EventBus

When to Use Which

Use hooks directly when...

  • The behavior lives in your own app and just reacts to lifecycle events
  • You need a quick, one-off cross-cutting concern (logging, metrics, cleanup)
  • You want job-scoped behavior for a single job
# In your app bootstrap — simple and direct
app.hook_registry.register_global(HookEvent.BEFORE_JOB, log_job_start)
app.hook_registry.register_for_job("etl", HookEvent.AFTER_FAILURE, alert_on_etl_failure)

Use a plugin when...

  • The behavior needs to be shared across projects or with colleagues
  • It adds CLI commands or modifies the app structure
  • It needs its own config section (e.g., [plugin.notifications])
  • It depends on other plugins being loaded first
  • It should be versioned and released independently

Sharing With Colleagues

The problem with raw hooks

Hooks alone are not portable. They're registered imperatively in code:

# This lives in YOUR app — your colleague can't reuse it without copy-pasting
app.hook_registry.register_global(HookEvent.BEFORE_JOB, my_audit_hook)

If a colleague wants the same behavior, they either copy-paste the function into their app or you share a module they need to manually import and wire up.

The solution: wrap reusable hooks in a plugin

The canonical way to share reusable behavior is to package it as a plugin. The entry point system makes this zero-config for consumers:

You (the author):

src/functualize_audit/__init__.py
"""Reusable audit logging plugin."""

from functualize.plugin import HookEvent
from functualize.job import RunContext


class AuditPlugin:
    name = "audit-logger"
    version = "1.0.0"
    description = "Logs job lifecycle events for auditing"

    def __call__(self, app):
        app.hook_registry.register_global(HookEvent.BEFORE_JOB, self._on_start)
        app.hook_registry.register_global(HookEvent.AFTER_FAILURE, self._on_failure)

    def _on_start(self, rc: RunContext):
        rc.log(f"[audit] Job '{rc.name}' starting")

    def _on_failure(self, rc: RunContext, exc: Exception):
        rc.log(f"[audit] Job '{rc.name}' failed: {exc}")
pyproject.toml
[project]
name = "functualize-audit"
version = "1.0.0"
dependencies = ["functualize>=0.1.0"]

[project.entry-points."functualize.plugins"]
audit-logger = "functualize_audit:AuditPlugin"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

Your colleague (the consumer):

uv add functualize-audit
# or: pip install functualize-audit
# or for local dev: uv pip install -e ../functualize-audit

Done. The plugin is auto-discovered on next app run — no code changes in the host app.

Sharing without publishing to PyPI

You don't need to publish to a package index. Common approaches:

  • Editable install from a local path: uv pip install -e ../my-plugin
  • Install from a Git repo: uv pip install git+https://github.com/yourorg/functualize-audit.git
  • Monorepo with workspace: add the plugin as a workspace member and declare it as a dependency

Decision Flowchart

Is this behavior reusable across projects?
├── No → Register hooks directly in your app
└── Yes
    ├── Does it only react to lifecycle events?
    │   └── Yes → Wrap your hooks in a minimal plugin package
    └── Does it also add CLI commands, config, or dependencies?
        └── Yes → Full plugin with config_model, depends_on, etc.

Summary

Scenario Approach
Quick logging/metrics in your own app Hook directly
Plugin-shaped behaviour, one project, no packaging File-based plugin in .functualize/plugins/
Job-specific error handling register_for_job hook
Behavior shared with one colleague Plugin (editable install)
Behavior shared across your org Plugin (Git install or private index)
Open-source extension Plugin (published to PyPI)
Adding a CLI command Plugin (only plugins can modify the CLI command group)
Needs its own config section Plugin with config_model / config_section