Part 2: Architecture Decisions That Almost Broke Us
The hard problems behind building a type-safe MongoDB ODM on top of pydantic v2.
Building Vellum looked straightforward on paper: wrap Motor, add type hints, call it a day. In practice, every layer of the stack pushed back. The metaclass conflicted with pydantic. The Index design fought with Python's class-body scoping rules. The build system silently refused to find our package.
Here's how we solved each one — and what we'd do differently next time.
1. The Metaclass Conflict
The Problem
We wanted three things that don't naturally coexist:
- pydantic's
BaseModel— field validation, serialisation, schema generation - A
HooksMixin— lifecycle hooks likebefore_insert,after_insert - A custom metaclass — intercept
cls.fieldnameto returnFieldRef
The MRO looked innocent enough:
class VellumBaseModel(HooksMixin, BaseModel, metaclass=VellumMetaclass):
...
But HooksMixin inherited from type (making it a metaclass), and BaseModel uses ModelMetaclass. You can't have a class with two metaclass parents unless one derives from the other.
But you CAN'T reference Product.name inside the class body — Product doesn't exist yet.
Class Body (Product doesn't exist yet)
Index(Product.name) → NameError: Product not defined
After class creation
Product.price < 10 → Works fine via metaclass
The Solution
Two APIs that cover both use cases:
1. Settings.indexes — for simple, static indexes:
class Product(VellumBaseModel):
name: str
price: float
category: str
class Settings:
indexes = [
Index("name"), # string ref — works in class body
Index(("price", 1), ("name", 1)), # tuples — no FieldRef needed
[("category", 1)],
]
2. __init_indexes__ — classmethod that runs AFTER the class exists, so cls.name works:
class Product(VellumBaseModel):
name: str
price: float
category: str
@classmethod
def __init_indexes__(cls):
return [
Index(cls.name), # cls.name → FieldRef — works here
Index(cls.price, cls.name),
Index(cls.price.desc()),
]
This works because VellumMetaclass.__init__ calls __init_indexes__ after pydantic has fully set up the class:
class VellumMetaclass(ModelMetaclass):
def __init__(cls, name, bases, namespace, **kwargs):
super().__init__(name, bases, namespace, **kwargs)
init_indexes = cls.__init_indexes__()
if init_indexes:
current = getattr(cls.Settings, "indexes", [])
cls.Settings.indexes = [*current, *init_indexes]
The Index class itself uses lazy validation — it stores the raw fields and validates them in ensure_indexes() against the actual model schema:
class Index:
def __init__(self, *fields: FieldRef | SortSpec | str, **options):
self._raw_fields = list(fields)
self._options = options
@classmethod
def resolve(cls, value):
if isinstance(value, Index):
return value
if isinstance(value, dict):
return cls(**value) # may fail later
return value
def validate(self, model_cls):
for field in self._raw_fields:
if isinstance(field, FieldRef):
assert field._field in model_cls.__pydantic_fields__
This design means:
- String refs work in class body (no forward reference)
- Type-safe cls.name syntax works via __init_indexes__
- Raw dict format ([("category", 1)]) still supported
- Validation happens at runtime in ensure_indexes(), not at class creation
4. The Build System Trap
The Problem
We used poetry-core as the build backend. Our package was named vellum-odm with a src/ layout:
vellum-odm/
├── pyproject.toml
└── src/
└── vellum/
├── __init__.py
└── ...
Poetry couldn't find the package. Poetry's build backend expects the package directory to match the package name, or to be in the project root. src/ layout + hypenated name was a dead end.
The Solution
We switched to hatchling, which handles PEP 621 natively:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "vellum-odm"
version = "0.1.0"
Hatchling respects src/ layout out of the box. The package name in pyproject.toml (vellum-odm) and the import name (vellum) can differ without issue — hatchling auto-discovers packages in src/.
5. The $push Bug
The Problem
The UpdateBuilder.push() method collected values into a list, then assigned the dict directly to $push:
# After: UpdateBuilder.push(Product.tags, "sale")
self._pushes = {"tags": ["sale"]}
# Generated: {"$push": {"tags": ["sale"]}}
# MongoDB interprets this as pushing ["sale"] as a SINGLE element
The result: tags became ["mobile", "5g", ["sale"]] instead of ["mobile", "5g", "sale"].
The Fix
Check how many values are being pushed — if it's one, use the value directly; if multiple, wrap in $each:
if self._pushes:
push_update = {}
for f, values in self._pushes.items():
if len(values) == 1:
push_update[f] = values[0]
else:
push_update[f] = {"$each": values}
update["$push"] = push_update
Now a single push emits {"$push": {"tags": "sale"}} (correct), and multiple calls emit {"$push": {"tags": {"$each": ["sale", "clearance"]}}}.
Summary: Architecture at a Glance

In Part 3, we'll look at the tradeoffs — what we won, what we lost, and when you should (and shouldn't) use this approach.