ADR-004: Content Pack Protocol (Structural Typing)¶
Status¶
Accepted
Date¶
2024-02-01
Context¶
MAID's content pack system allows third-party modules to register ECS systems, events, commands, and document schemas with the engine. The engine needs a way to define the interface that content packs must satisfy, while keeping the coupling between the engine and pack implementations as loose as possible.
Content packs are discovered through multiple mechanisms: explicit registration via
engine.load_content_pack(), Python entry points (maid.content_packs group), and
directory scanning with manifest.toml files. Discovery via entry points and
directory scanning requires instantiation without constructor arguments.
Decision¶
Define the content pack interface as a Python Protocol with @runtime_checkable
(ContentPack in packages/maid-engine/src/maid_engine/plugins/protocol.py)
rather than an abstract base class. The protocol defines eight methods:
@runtime_checkable
class ContentPack(Protocol):
@property
def manifest(self) -> ContentPackManifest: ...
def get_dependencies(self) -> list[str]: ...
def get_systems(self, world: World) -> list[System]: ...
def get_events(self) -> list[type[Event]]: ...
def register_commands(self, registry: AnyCommandRegistry) -> None: ...
def register_document_schemas(self, store: DocumentStore) -> None: ...
async def on_load(self, engine: GameEngine) -> None: ...
async def on_unload(self, engine: GameEngine) -> None: ...
A convenience base class BaseContentPack is provided alongside the protocol,
offering default no-op implementations for all methods except manifest. Content
packs can either implement the protocol directly (structural typing) or extend
BaseContentPack (nominal typing).
The ContentPackLoader (packages/maid-engine/src/maid_engine/plugins/loader.py)
discovers packs via importlib.metadata entry points, validates they satisfy the
protocol at runtime using isinstance(pack, ContentPack), and resolves dependency
ordering via topological sort.
Consequences¶
Positive¶
- No forced inheritance: A content pack class does not need to inherit from any engine base class. It only needs to implement the required methods with matching signatures. This means content packs written for other frameworks could potentially be adapted with minimal wrapper code.
- Duck typing alignment: Protocol-based design aligns with Python's duck typing philosophy. If a class has the right methods, it is a valid content pack.
- Runtime validation: The
@runtime_checkabledecorator allowsisinstance()checks during discovery, providing early error detection before the engine attempts to call pack methods. - Testing flexibility: Test doubles can implement just the methods needed for
a specific test without extending
BaseContentPack. TheTestingContentPackinpackages/maid-engine/src/maid_engine/plugins/testing.pydemonstrates this.
Negative¶
- Discoverability: New content pack authors cannot simply look at an ABC's
abstract methods to see what they must implement. They need to read the
Protocoldefinition or theBaseContentPackdefaults. IDE support for protocol implementations is less mature than for ABCs. - No enforcement at definition time: A class missing a protocol method will
not raise an error when defined, only when an
isinstancecheck is performed or when the missing method is called. ABCs catch missing methods at class creation time. - Type signature drift: If a protocol method's signature changes, existing
content packs will not get a compile-time error. This risk is mitigated by the
runtime checks in
ContentPackLoader.
Alternatives Considered¶
Abstract Base Class (ABC)¶
Using class ContentPack(ABC) with @abstractmethod decorators would enforce
method implementation at class definition time. Rejected because it forces all
content packs to inherit from the engine's base class, creating a hard coupling.
Content packs from external libraries would need to subclass an engine type.
Class Registry with Decorators¶
A decorator-based approach (@register_content_pack) that registers classes in
a global registry was considered. Rejected because global registries create import
ordering issues and make testing difficult. The protocol approach is stateless.
Entry Points Only (No Protocol)¶
Relying solely on entry points with duck-typing and no formal interface was
considered. Rejected because it provides no guidance to implementors about what
methods they need, and error messages when a method is missing would be confusing
(AttributeError instead of a clear protocol violation).