Skip to content

[Refactor] Decouple Core Interfaces from Pydantic (Support Dataclasses, TypedDict, etc.) #26

@vadikko2

Description

@vadikko2

[Refactor] Decouple Core Interfaces from Pydantic (Support Dataclasses, TypedDict, etc.)

🚀 Feature Request

Is your feature request related to a problem? Please describe.

Currently, the cqrs library is tightly coupled with pydantic. The core base classes (Request, Response, Event, NotificationEvent) in src/cqrs/base.py inherit directly from pydantic.BaseModel.

This imposes several limitations:

  1. Performance: Pydantic introduces validation overhead that may not be necessary for all use cases (e.g., internal events trusted by the producer).
  2. Flexibility: Users cannot use Python's native dataclasses, attrs, msgspec, NamedTuple, or TypedDict.
  3. Dependency Conflicts: Forcing a specific version of Pydantic can cause version conflicts in consuming projects.

Describe the solution you'd like

I propose making the library agnostic to the data modeling library used by the client. The core logic (dispatching, mediation, transportation) should work with protocols or standard Python structures rather than concrete Pydantic implementations.

The library should support:

  • Pydantic Models (Backward compatibility/Opt-in)
  • Python Standard dataclasses
  • TypedDict / dict
  • NamedTuple
  • Plain Python Classes

🛠 Implementation Plan

1. Modify Base Interfaces (src/cqrs/base.py)

Remove the inheritance from pydantic.BaseModel. Base classes should become lightweight Mixins or Protocols to act as markers for the dispatcher.

# Current:
class Request(pydantic.BaseModel):
    ...
# Proposed:
# As a Mixin/Marker
class Request:
    pass

Or as a Protocol

class IRequest(typing.Protocol):
pass#### 2. Abstract Serialization/Deserialization
Refactor serializers (src/cqrs/serializers) and deserializers (src/cqrs/deserializers) to use a strategy pattern or a unified helper that detects the object type.

The logic needs to handle extraction and instantiation dynamically:

  • If Pydantic: Use .model_dump() / .model_validate()
  • If Dataclass: Use dataclasses.asdict() / Constructor
  • If Dict: Pass through
  • If Plain Class: Use __dict__ or constructor kwargs

3. Update Dispatchers and Brokers

Update dispatcher and message_brokers to rely on the abstract serializer rather than calling Pydantic methods directly on the message objects.


🔄 Migration Guide & Examples

This change will require minor updates in client code, primarily making inheritance explicit.

Scenario 1: Continuing with Pydantic

Users who want to keep using Pydantic will need to explicitly inherit from BaseModel.

# Before:
from cqrs import Request

class CreateUserCmd(Request):
    name: str
# After:
from pydantic import BaseModel
from cqrs import Request

# Explicitly inherit from BaseModel. Request becomes just a marker.
class CreateUserCmd(BaseModel, Request):
    name: str

Scenario 2: Switching to Dataclasses

Users can now use standard library dataclasses.

from dataclasses import dataclass
from cqrs import Request

@dataclass
class CreateUserCmd(Request):
    name: str

Scenario 3: Using TypedDict

For simple payloads without behavioral logic.

from typing import TypedDict

class CreateUserCmd(TypedDict):
    name: str

# The handler simply accepts the dict
# async def handle(command: CreateUserCmd): ...

Additional context

Internal library models (like SagaResult or OutboxedEvent) may continue to use Pydantic for internal robustness, but this implementation detail should not leak into the public API required for defining Domain Events or Commands.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions