Skip to content

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 now async 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 / await is 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., AsyncTokenValidator alongside TokenValidator)
  • 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:~~

  1. ~~Add async variants alongside sync Protocols~~
  2. ~~Async adapters implement the async Protocol~~
  3. ~~Services that need async get async counterparts or are wrapped with asyncio.to_thread~~
  4. ~~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.anyio with both asyncio and trio backends
  • Neutral, because InMemoryTokenValidator is 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-anyio for async tests

More Information