Sync-first Protocols
Note: This ADR is partially superseded by ADR-0014 Async Ports for OIDC (2026-03-17). I/O-bound authentication ports (
TokenValidator,MethodAuthenticator,Authenticator) are nowasync def. Pure-logic ports (PrincipalResolver,ApiKeyValidator,PolicyRepository,ResourceRepository,Authorizer) remain sync as decided here.
Context and Problem Statement
Ports define the contract between services and adapters. Should port Protocols use synchronous or asynchronous method signatures? Adapters will eventually perform I/O (HTTP calls to IdPs, database queries for policies), which naturally suits async.
Decision Drivers
- Domain and services layers are pure logic with no I/O — async adds no value there
- Adapter layer (not yet built) will introduce I/O to external systems
- Premature async infects the entire call chain (
async/awaitis viral) - Testing sync code is simpler — no event loop, no async fixtures needed
- The proxy will likely run on an async framework (e.g., FastAPI), but services should remain framework-agnostic
Considered Options
- Async from the start (all Protocols use
async def) - Sync now, parallel async Protocols later (e.g.,
AsyncTokenValidatoralongsideTokenValidator) - Sync only, convert to async when needed
Decision Outcome
Chosen option: "Sync now, parallel async Protocols later", because it avoids premature complexity while providing a clear migration path.
Current state (updated 2026-03-17)
The migration path predicted below was executed in ADR-0014. Instead of parallel async Protocols, the existing Protocols were converted in-place since the project had no production deployments.
Async Protocols (I/O-bound — ADR-0014):
class TokenValidator(Protocol):
async def validate(self, token: str) -> TokenValidationResult: ...
class MethodAuthenticator(Protocol):
async def authenticate(self, credential: Credential) -> AuthenticationResult: ...
class Authenticator(Protocol): # input port
async def authenticate(self, credential: Credential) -> AuthenticationResult: ...
Sync Protocols (pure logic — unchanged):
class PrincipalResolver(Protocol):
def resolve(self, claims: TokenClaims) -> Principal | None: ...
class ApiKeyValidator(Protocol):
def validate(self, api_key: str) -> KeyValidationResult: ...
class PolicyRepository(Protocol):
def rules_for_resource(self, resource_uri: str) -> tuple[PolicyRule, ...]: ...
class Authorizer(Protocol):
def authorize(self, request: AccessRequest) -> AccessResult: ...
Migration path
~~When the adapter layer introduces I/O:~~
- ~~Add async variants alongside sync Protocols~~
- ~~Async adapters implement the async Protocol~~
- ~~Services that need async get async counterparts or are wrapped with
asyncio.to_thread~~ - ~~Sync Protocols remain for testing and non-I/O adapters (in-memory stubs)~~
What actually happened: I/O-bound Protocols were converted to async def in-place. See ADR-0014 for the analysis that drove this decision.
Consequences
- Good, because pure-logic ports remain sync and framework-agnostic
- Good, because I/O-bound ports no longer block the ASGI event loop
- Good, because tests use
pytest.mark.anyiowith both asyncio and trio backends - Neutral, because
InMemoryTokenValidatoris trivially async (no real I/O)
Confirmation
- I/O-bound Protocols use
async def— verified by ADR-0014 - Pure-logic Protocols use
def— architecture tests verify no I/O dependencies - Test suite uses
pytest-anyiofor async tests
More Information
- Superseded by: ADR-0014 Async Ports for OIDC
- Related: ADR-0002 Adopt DDD
- Affected files:
src/hdc_auth_proxy/ports/authentication.py,src/hdc_auth_proxy/ports/authorization.py,src/hdc_auth_proxy/ports/input.py