Skip to content

Custom Database Models

DDD CQRS

Protean auto-generates database models for every aggregate and entity, and for every projection stored in a database rather than a cache. Custom database models let you override the default storage schema when you need adapter-specific tuning, custom table names, Elasticsearch analyzers, or a different model for each database type.


When to use custom models

Most applications don't need custom models. Use them when:

  • You need to override the table or collection name
  • You need adapter-specific field types (e.g., Elasticsearch Text with a custom analyzer)
  • One aggregate runs on different database types in different deployments (e.g., PostgreSQL in one, Elasticsearch in another), and each needs its own model
  • You need partial field mapping (persist only a subset of fields)

If your fields map 1:1 to standard database types, the auto-generated model is sufficient.


Defining a custom model

Subclass BaseDatabaseModel and register it with part_of:

from protean.core.database_model import BaseDatabaseModel

@domain.aggregate
class Product:
    name = String(required=True)
    description = Text()
    price = Float()


class ProductModel(BaseDatabaseModel):
    pass  # Empty -- just override the schema name


domain.register(ProductModel, part_of=Product, schema_name="products")

Overriding field types

Map aggregate fields to adapter-specific types:

from elasticsearch.dsl import Keyword
from elasticsearch.dsl import Text as ESText


class ProductSearchModel(BaseDatabaseModel):
    name = Keyword()  # Exact match, no analysis
    description = ESText(analyzer="standard")  # Full-text search


domain.register(
    ProductSearchModel,
    part_of=Product,
    database="elasticsearch",
)

Partial field mapping

A model can map fewer fields than the aggregate. Protean auto-generates the mapping for each field the model leaves out:

from elasticsearch.dsl import Text as ESText


@domain.aggregate
class Article:
    title = String(required=True)
    body = Text()
    category = String()


class ArticleSearchModel(BaseDatabaseModel):
    body = ESText(analyzer="english")  # Override only this field
    # title and category use the default mapping


domain.register(ArticleSearchModel, part_of=Article)

Registration options

Option Type Description
part_of class or str Required. The aggregate, entity or projection this model maps to. A string names the element and is resolved when domain.init() runs, so the model can be declared before its target.
schema_name str Override the storage table/collection name
database str The database type this model applies to: memory, sqlite, postgresql, mysql, mssql or elasticsearch. This is not a provider name. It has no default: without it, the model is used for any database type that has no model of its own.
class ProductReportingModel(BaseDatabaseModel):
    pass


domain.register(
    ProductReportingModel,
    part_of=Product,
    schema_name="product_reports",
    database="postgresql",
)

Multi-database deployment

Register multiple models for the same aggregate, each targeting a different database type:

@domain.aggregate
class Customer:
    name = String(required=True)
    email = String()


class CustomerWriteModel(BaseDatabaseModel):
    pass


class CustomerSearchModel(BaseDatabaseModel):
    name = Keyword()


domain.register(
    CustomerWriteModel,
    part_of=Customer,
    database="postgresql",
    schema_name="customers",
)
domain.register(
    CustomerSearchModel,
    part_of=Customer,
    database="elasticsearch",
    schema_name="customer_index",
)

An aggregate is stored in one provider, set with its provider option. Its repository uses the model whose database matches that provider's database type. For example, Customer above uses CustomerSearchModel on Elasticsearch and CustomerWriteModel on PostgreSQL. The aggregate is not written to both.


Validation rules

  • Model fields must be a subset of the aggregate's fields. Defining a field that doesn't exist on the aggregate raises IncorrectUsageError.
  • A model can have fewer fields than the aggregate (partial mapping).
  • A model cannot add fields not present on the aggregate.

See also