Python’s Protocol defines a boundary by structure rather than inheritance. An object accepted by a static checker simply provides the required methods and properties. Protocols are useful for separating domain logic from HTTP clients, databases, clocks, and message systems. They are not runtime input validation, and a larger “universal interface” is not automatically better. A good seam describes only the capability one caller needs.

Let the consumer own the protocol

If an order calculation only reads a price, define get_price rather than depend on a vendor client with twenty operations:

from dataclasses import dataclass
from decimal import Decimal
from typing import Protocol

class PriceReader(Protocol):
    def get_price(self, sku: str) -> Decimal: ...

@dataclass(frozen=True)
class LineTotal:
    sku: str
    quantity: int
    amount: Decimal

def calculate_line(reader: PriceReader, sku: str, quantity: int) -> LineTotal:
    if quantity <= 0:
        raise ValueError("quantity must be positive")
    price = reader.get_price(sku)
    return LineTotal(sku, quantity, price * quantity)

The production adapter and test fake do not inherit PriceReader. A checker verifies their signatures structurally, which prevents the infrastructure type from spreading through the domain. Place the protocol near calculate_line; the caller determines the capability it consumes.

This direction also makes vendor replacement visible. Only an adapter changes when a new SDK has different authentication, exceptions, and response objects. The domain should not gain a union of both vendors’ types during migration.

Make return types a semantic contract

If a real client returns float | None, do not add a cast merely to satisfy the protocol. The adapter must handle absence, currency precision, and vendor failures before returning Decimal or raising a domain exception. Type checking exposes a mismatch; it cannot choose the correct business meaning.

An asynchronous dependency should use async def in its protocol. Do not accept a sync-or-async union just to make a fake shorter, because every caller then needs branching and possibly incorrect awaiting. A test implementation can provide an async method and still return immediately.

Exceptions, cancellation, and idempotency are part of the boundary even when a type signature cannot express them fully. Document and test them. A protocol with a precise return annotation but undefined failure behavior is not a complete replacement contract.

Narrow only after proving a condition

External JSON begins as object or otherwise untrusted structure. Ordinary runtime checks can validate it step by step, and a type predicate can tell the checker what that validation proved. Python 3.12 supports TypeGuard; Python 3.13 adds TypeIs, which requires the narrowed type to be compatible with the input and can narrow both true and false branches more naturally.

from typing import TypeGuard

def is_string_map(value: object) -> TypeGuard[dict[str, str]]:
    return (
        isinstance(value, dict)
        and all(isinstance(k, str) and isinstance(v, str)
                for k, v in value.items())
    )

def read_region(value: object) -> str:
    if not is_string_map(value):
        raise ValueError("expected string mapping")
    return value.get("region", "unknown")

A predicate is a proof obligation. If it checks only dict while claiming all keys and values are strings, the checker trusts a false promise and permits runtime defects downstream. Test positive and negative predicate cases, including empty data, nested values, custom mappings, and booleans where integers might otherwise be accepted accidentally.

Use TypeIs when its intersection-style semantics match the input and supported Python baseline. Retain TypeGuard where compatibility with Python 3.12 or an intentionally non-subtype narrowing requires it. Do not choose between them merely by which silences a diagnostic.

Be cautious with runtime_checkable

@runtime_checkable enables limited isinstance checks against a protocol, but it primarily checks for attributes and does not fully validate method signatures or generic behavior. Plugin discovery may use it as a coarse filter. Actual invocation still needs error handling and contract tests. Passing a runtime protocol says nothing about remote service health, authorization, or response validity.

Avoid both protocol confetti and giant protocols. A single-method protocol around every function creates naming and adapter overhead; a protocol mirroring an entire SDK makes fakes difficult and couples callers. Group stable collaborations such as catalog reading, payment submission, or event publication. Two operations with the same method spelling but different semantics deserve separate contracts.

Writable attributes require special care because mutation affects variance and substitutability. Prefer a read-only property or an explicit command method. Let actual input and output directions determine generic variance rather than ignoring a type error.

Verify both static and runtime lines

Run the chosen type checker in CI with pinned configuration. Unit tests still cover adapter conversion, errors, timeouts, cancellation, and fake behavior. Contract tests can run the same cases against multiple implementations. Static success does not prove the network, and runtime examples do not prove every call composition is type-safe.

Protocols make dependency direction follow caller capability rather than vendor classes. Keep the interface small, perform semantic conversion in adapters, narrow external data only through honest runtime checks, and treat runtime protocols as limited tools. Static typing shortens feedback while runtime validation guards real inputs; together they make implementations genuinely replaceable.