Skip to content

Hop3's Plugin Architecture: Extensibility Without Complexity

When building a PaaS, you face a fundamental tension: supporting many languages, databases, and deployment patterns while keeping the codebase maintainable. Hop3 resolves this with a plugin architecture built on Pluggy.

Why Plugins?

A PaaS needs to handle many concerns:

  • Build: How do you compile Go? Install Python dependencies? Bundle JavaScript?
  • Deploy: uWSGI for Python? PM2 for Node? Static files for SPAs?
  • Services: PostgreSQL? MySQL? Redis? Each with its own setup and credentials
  • Proxy: Nginx? Caddy? Traefik? Each with different configuration formats
  • OS: Debian uses apt, Fedora uses dnf, package names differ

Each concern stays isolated, testable, and replaceable.

Plugin Types

Hop3 defines six plugin types, each with a specific responsibility:

1. Builders

Builders orchestrate the build process. They decide where and how to build.

@dataclass(frozen=True)
class LocalBuilder:
    """Build directly on the server."""
    name: str = "local"

    def build(self, app: App, toolchain: LanguageToolchain) -> BuildArtifact:
        return toolchain.build()

@dataclass(frozen=True)
class DockerBuilder:
    """Build inside a Docker container."""
    name: str = "docker"

    def build(self, app: App, toolchain: LanguageToolchain) -> BuildArtifact:
        # Build in isolated container
        ...

2. Language Toolchains

Toolchains handle language-specific build logic:

class PythonToolchain(LanguageToolchain):
    name = "Python"

    def accept(self) -> bool:
        # Detect Python projects
        return self.check_exists(["requirements.txt", "pyproject.toml"])

    def build(self) -> BuildArtifact:
        self.make_virtual_env()
        self.install_dependencies()
        return self._make_build_artifact(kind="python")

Current toolchains: Python, Node.js, Ruby, Go, Rust, PHP, Java, Clojure, Static, Generic.

3. Deployers

Deployers run built applications:

@dataclass(frozen=True)
class UWSGIDeployer:
    """Deploy Python/Ruby apps via uWSGI."""
    name: str = "uwsgi"

    def accept(self, artifact: BuildArtifact) -> bool:
        return artifact.kind in ("python", "ruby")

    def deploy(self, app: App, artifact: BuildArtifact) -> None:
        # Generate uWSGI config, start workers
        ...

Current deployers: uWSGI, Static, Docker Compose.

4. Addons

Addons provision backing services:

@dataclass(frozen=True)
class PostgreSQLAddon:
    name: str = "postgres"

    def create(self, instance_name: str) -> AddonCredential:
        # Create database, user, return credentials
        ...

    def attach(self, app: App, credential: AddonCredential) -> dict[str, str]:
        # Return env vars to inject
        return {"DATABASE_URL": credential.connection_url}

Current addons: PostgreSQL, MySQL, Redis.

5. Proxy Plugins

Proxy plugins configure reverse proxies:

@dataclass(frozen=True)
class NginxProxy:
    name: str = "nginx"

    def setup(self, app: App, config: ProxyConfig) -> None:
        # Generate nginx server block
        # Handle SSL certificates
        # Reload nginx
        ...

Current proxies: Nginx, Caddy, Traefik.

6. OS Implementations

OS plugins handle system-level operations:

@dataclass(frozen=True)
class DebianOS:
    name: str = "debian"

    def install_packages(self, packages: list[str]) -> None:
        subprocess.run(["apt-get", "install", "-y", *packages])

    def get_service_manager(self) -> str:
        return "systemd"

Current: Debian family (Ubuntu, Debian), Red Hat family (Fedora, Rocky, AlmaLinux), Arch, BSD, macOS.

Hook Specifications

Plugins register themselves via hook specifications defined in core/hookspecs.py:

class Hop3Spec:
    @hookspec
    def get_builders(self) -> list[type[Builder]]:
        """Return available builders."""

    @hookspec
    def get_language_toolchains(self) -> list[type[LanguageToolchain]]:
        """Return available language toolchains."""

    @hookspec
    def get_deployers(self) -> list[type[Deployer]]:
        """Return available deployers."""

    @hookspec
    def get_addons(self) -> list[type[Addon]]:
        """Return available addons."""

    @hookspec
    def get_proxies(self) -> list[type[ProxyPlugin]]:
        """Return available proxy plugins."""

    @hookspec
    def get_os_implementations(self) -> list[type[OSImplementation]]:
        """Return available OS implementations."""

Plugin Discovery

At startup, Hop3 discovers plugins via Pluggy's plugin manager:

pm = pluggy.PluginManager("hop3")
pm.add_hookspecs(Hop3Spec)

# Register built-in plugins
pm.register(BuiltinPlugins())

# Discover external plugins via entry points
pm.load_setuptools_entrypoints("hop3.plugins")

External plugins can register via pyproject.toml:

[project.entry-points."hop3.plugins"]
my_plugin = "my_package.hop3_plugin"

Dependency Injection with Dishka

Plugins often need access to shared services (database sessions, configuration, etc.). We use Dishka for dependency injection:

class RepositoryProvider(Provider):
    @provide(scope=Scope.REQUEST)
    def get_app_repository(self, session: AsyncSession) -> AppRepository:
        return AppRepository(session=session)

# In a plugin
class MyAddon:
    def create(self, app_repo: AppRepository) -> AddonCredential:
        app = app_repo.get_by_name(self.app_name)
        ...

This keeps plugins decoupled from implementation details.

Writing Your Own Plugin

Here's a minimal example of a custom addon:

# my_addon/plugin.py
from dataclasses import dataclass
from hop3.core.protocols import Addon, AddonCredential

@dataclass(frozen=True)
class MyCustomAddon:
    name: str = "my-service"

    def create(self, instance_name: str) -> AddonCredential:
        # Your provisioning logic here
        return AddonCredential(
            addon_type=self.name,
            instance_name=instance_name,
            credentials={"API_KEY": "..."},
        )

    def destroy(self, credential: AddonCredential) -> None:
        # Cleanup logic
        pass

# Register via hookimpl
import pluggy
hookimpl = pluggy.HookimplMarker("hop3")

class MyPlugin:
    @hookimpl
    def get_addons(self):
        return [MyCustomAddon]

Benefits of This Architecture

  1. Isolation: Each plugin is self-contained and testable
  2. Extensibility: Add new languages/services without touching core code
  3. Flexibility: Swap implementations (Nginx → Caddy) via configuration
  4. Maintainability: Clear boundaries between concerns
  5. Community: External plugins without forking

Learn More


Related posts: Package Architecture explains how we split Hop3 into focused packages. The BuildArtifact Pattern shows how plugins produce portable build artifacts. For plugin development, see the Plugin Development Guide.