ADR-0047: The mypy plugin makes a decorated element a subclass of its base
Status: Accepted
Date: October 2026
Context
An element decorator such as @domain.aggregate builds a new class that
inherits from the matching Protean base, BaseAggregate here. mypy cannot see
that, because the decorator returns type[_T] for the class it was handed.
The mypy plugin fills the gap during semantic analysis.
The plugin used to copy the base's methods and attributes into the decorated
class's own namespace. It did not add the base to the class's bases, because
the Protean bases derive from pydantic's BaseModel and mypy then reported a
metaclass conflict. Copying made order.raise_() and order.to_dict()
visible, but the class was still not a subtype of its base. That left a
family of strict-mode errors in downstream code:
- A decorated element was rejected wherever a signature names its base:
domain.cache_for(Leaderboard),cache.add(...),ValueObject(Money),x: BaseValueObject = Money(...). - A property copied from the base kept the base as its
selftype, soorder.state_failed with "Invalid self argument". super().__init__(...)in a decorated domain service resolved toobject.__init__.- The copied names were in scope in the class body, so pydantic's
BaseModel.dictmethod shadowed the builtin inchanges: dict[str, Any] = Dict().
Widening each signature to type would have fixed only the first item, one
signature at a time.
Decision
In the get_customize_class_mro_hook, the plugin makes the Protean base the
decorated class's only base and recomputes the MRO. The decorator does the
same at runtime: it rebuilds the class as type(name, (base_cls,), namespace),
so the class's own bases are dropped there too. mypy then gives the class
pydantic's ModelMetaclass, the metaclass of its new base, so there is no
metaclass conflict and no need to copy symbols.
A decorated class that already inherits the base, such as a concrete
aggregate extending a decorated abstract one, keeps its bases, as it does at
runtime. Its synthesized __init__ includes the fields of decorated parents.
With the base in the MRO, mypy also applies the dataclass_transform that
BaseEntity declares. That transform now sets kw_only_default=True, which
is how pydantic constructs the model, so mypy does not apply positional field
ordering rules to Protean fields.
Consequences
- A decorated element is a subtype of its base for mypy, as it is at runtime. Signatures that name a base class accept it with no change to them.
- A mixin listed as a base of a decorated class is dropped for mypy, as it is at runtime, so its methods are reported as missing.
- mypy checks a decorated class against its base. A field that reuses the
name of a base attribute with a different type, such as a field named
jsonorcopy, is anassignmentoverride error. It was ano-redeferror before, so an ignore comment for that code no longer applies. - The framework bases declare
idasAny, since the runtime identity can bestr,intorUUID. An element without its ownidfield still seesid: str, and one that declaresid: intkeeps it. - The plugin still synthesizes a keyword
__init__from the field declarations. It takes precedence over the base's__init__(**data), so an unknown keyword is still an error. - IDE engines that do not run the plugin still see the undecorated class.
Alternatives Considered
Widening every public signature that takes a Protean base class to type or
object was rejected. It loses precision across the framework, and it leaves
the property, isinstance, super() and name-shadowing errors in place.
A synthetic intermediate base per element kind, carrying its own
dataclass_transform spec, was considered as a way to control the transform.
Declaring kw_only_default=True on BaseEntity gave the same result without
a synthetic class that mypy's cache would have to serialize.