ADR-001: Structural Protocols over Abstract Base Classes¶
Date: 2026-05-11
Status: Accepted
Deciders: ExecutionKit core team
Technical Story: During the v0.1 design review, the team needed to define the LLMProvider and ToolCallingProvider interfaces in a way that supported test doubles, custom implementations, and third-party clients without requiring inheritance from ExecutionKit base classes.
Context and Problem Statement¶
ExecutionKit's three reasoning patterns (consensus, refine_loop, react_loop) each accept an LLMProvider as their first argument. The project needed a formal interface definition for this parameter — something that enforces the required complete() method signature and makes the type checkable.
Two approaches were available: Python's Abstract Base Classes (ABCs) from abc.ABC, or structural protocols from typing.Protocol (PEP 544, Python 3.8+). The choice has significant downstream effects on how users and test authors interact with the library.
The choice also interacts with a core design principle: zero runtime dependencies. The interface mechanism must not require any user-side import of ExecutionKit internals just to satisfy the interface.
Decision Drivers¶
- Users should be able to pass any object with the right
complete()method, without inheriting from an ExecutionKit class. - The
MockProvidertest double must satisfy the interface without being coupled to the production protocol definition. - Third-party clients (e.g., a wrapper around an Anthropic client) must satisfy the interface without modification.
isinstance()checks must work at runtime for internal guards in_TrackedProvider.- The zero-runtime-dependency design principle must not be compromised.
Considered Options¶
- Option A: PEP 544 structural protocols (
typing.Protocol) - Option B: Abstract base classes (
abc.ABC/abc.ABCMeta)
Decision Outcome¶
Chosen option: Option A (structural protocols), because structural typing lets any conforming object satisfy the interface through duck typing while still enabling isinstance() checks via @runtime_checkable. Users and test authors never need to import or inherit from ExecutionKit classes.
Positive Consequences¶
- Any object with the right method signatures satisfies
LLMProvider— includingMockProvider, custom wrappers, and third-party clients. @runtime_checkableenablesisinstance(provider, LLMProvider)checks inside_TrackedProvider.supports_toolswithout requiring inheritance.- Users implementing custom providers need zero imports from ExecutionKit at definition time; they only need to match the method signature.
- Consistent with Python's own standard library (
collections.abc,asyncio.Transport) and popular third-party libraries.
Negative Consequences¶
- IDEs may not always infer protocol conformance as clearly as ABC inheritance in all tool chains.
- Structural conformance is checked by type checkers (mypy) and at
isinstance()call sites; a class with a subtly wrong signature silently does not conform rather than raising at class definition time.
Pros and Cons of the Options¶
Option A: Structural protocols (typing.Protocol)¶
- Good: Zero coupling — conforming objects need not import the protocol definition.
- Good: Satisfies the zero-dependency principle — no ExecutionKit imports required on the user side.
- Good:
@runtime_checkablepreservesisinstance()capability where needed. - Good: Aligns with how Python's own stdlib defines abstract interfaces.
- Bad: Missing-method errors surface at call time or type-check time, not at class definition time.
Option B: Abstract base classes (abc.ABC)¶
- Good: Subclasses that omit required methods raise
TypeErrorimmediately at instantiation. - Good: IDE support for abstract method discovery is mature.
- Bad: Users must inherit from
ExecutionKit'sLLMProviderABC, coupling their code to our class hierarchy. - Bad: Third-party clients cannot satisfy the interface without modification or a shim subclass.
- Bad: Contradicts the zero-dependency design — satisfying the interface requires importing from
executionkit.