15.10.2025 • 2 min read

Vellum Part 2: Architecture Decisions That Almost Broke Us

Three problems nearly broke Vellum during development: a metaclass conflict, a build system trap, and a subtle data mutation bug. Here’s how each one happened and how we fixed it.


1. The Metaclass Conflict

We needed three things simultaneously:

  • Pydantic’s BaseModel for field validation
  • A HooksMixin for lifecycle hooks
  • A custom metaclass to intercept field references

The problem: you can’t reference Product.name inside the class body — Product doesn’t exist yet. The class definition order prevents forward references to incomplete classes.

The Solution: Two APIs

Rather than forcing incompatible inheritance, we implemented two complementary patterns:

Settings.indexes — static definitions using string references, safe inside the class body:

class Product(VellumBaseModel):
    name: str
    price: float

    class Settings:
        indexes = [
            Index("name"),
            Index(("price", 1), ("name", 1)),
        ]

__init_indexes__ — a classmethod that runs after class creation, where type-safe field references are valid:

class Product(VellumBaseModel):
    name: str
    price: float

    @classmethod
    def __init_indexes__(cls):
        return [
            Index(cls.name),
            Index(cls.price, cls.name),
            Index(cls.price.desc()),
        ]

The VellumMetaclass invokes __init_indexes__ post-initialization and merges results with static definitions.


2. The Build System Trap

Using poetry-core with a src/ directory layout and hyphenated package naming (vellum-odm) prevented proper package discovery during builds entirely.

The Fix: Switch to Hatchling

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "vellum-odm"
version = "0.1.0"

Hatchling natively handles PEP 621, src/ layouts, and the distinction between package names (hyphenated) and import names (underscores) — automatically.


3. The $push Bug

MongoDB’s $push operator behaves differently for single values vs. arrays. Our UpdateBuilder.push() incorrectly wrapped single values in arrays:

# Bug: tags became ["mobile", "5g", ["sale"]] instead of ["mobile", "5g", "sale"]

The Fix

Differentiate single and multiple values explicitly:

if self._pushes:
    push_update = {}
    for f, values in self._pushes.items():
        if len(values) == 1:
            push_update[f] = values[0]          # single: {"$push": {"tags": "sale"}}
        else:
            push_update[f] = {"$each": values}  # multiple: {"$push": {"tags": {"$each": [...]}}}
    update["$push"] = push_update

Next: Part 3 — What We Won, What We Lost