Skip to content

Model Reference Data as Domain Concepts

The Problem

Every application accumulates reference data: the slowly-changing, enumerable values that other records point at: currencies, countries, order statuses, product categories, tax codes, units of measure. Developers used to database-first design often store all of it in one shared table:

# fragment
@domain.aggregate
class MasterData:
    master_id: Auto(identifier=True)
    category: String(required=True)      # "CURRENCY", "ORDER_STATUS", "COUNTRY"
    code: String(required=True)          # "USD", "SHIPPED", "IN"
    label: String(required=True)         # "US Dollar", "Shipped", "India"
    sort_order: Integer(default=0)
    active: Boolean(default=True)
    extra: Dict()                        # a grab-bag for the fields that don't fit

One table, every lookup. It feels economical. Over time, it causes these problems:

  • Type safety evaporates: A Currency row and an OrderStatus row are the same Python type. Nothing stops a handler from assigning a country code where a currency was expected. A type checker can't catch the mistake; only code that inspects the data at runtime can tell them apart.

  • Invariants degrade into deferred lookups: "An order's status must be one of the valid statuses" can no longer be a domain rule. It becomes a query against MasterData WHERE category='ORDER_STATUS' that runs at runtime, in code outside the aggregate that depends on the rule.

  • Every consumer reinvents the lookup: There is no Currency concept to import. So every module that needs currencies writes its own filter(category="CURRENCY"), its own caching, its own "is this code valid?" check. Developers copy this logic between modules, and the copies stop matching over time.

  • The schema becomes a lowest common denominator: A currency needs a symbol. A country needs an ISO-3 code and a dialing prefix. A tax code needs a rate. None of these fit the shared columns, so they all get stuffed into the extra grab-bag, where they sit untyped, unvalidated, and unsearchable.

  • The surrogate key leaks everywhere: Because the table has its own master_id, foreign keys point at that opaque integer instead of the natural code. When you read raw data, you now need a join to find out that row 4471 means "USD".

This is the One True Lookup Table anti-pattern (also called MUCK, the Massively Unified Code-Key table). It forces a dozen unrelated domain concepts into a single storage shape and, in doing so, makes a persistence convenience part of the domain model.

The root cause: a single physical table has been mistaken for a single domain concept.


The Pattern

Model each kind of reference data as its own domain type, with its own table.

One True Lookup Table:
  MasterData(category, code, label, ...)   # Currency, Country, Status... all one type

Domain concepts:
  Currency      # its own type, its own fields, its own rules
  Country       # its own type
  OrderStatus   # its own type

The right Protean construct depends on a single axis: does the set change at runtime?

Closed & static (known at design time)        Open & dynamic (admin-managed)
  order statuses, units of measure              currencies, countries, tax codes
  ────────────────────────────────             ──────────────────────────────────
  Enumeration value object                      Aggregate keyed by `code`
  or String(choices=[...])                      + repository + (optional) cache
  No persistence                                Persisted, queryable, evolvable
  • Closed and static: The values are part of the model's vocabulary and rarely change. Encode them in the type system: a String(choices=[...]) constraint for a bare code, or an enumeration value object when the value carries a label or behavior.

  • Open and dynamic: Operators add and retire values at runtime. Model each such kind of reference data as its own aggregate, with the natural code as its identity. A repository loads it; a cache-backed projection or a catalog service makes it available across the application.

Either way, the concept has its own name and its own type, defined in one place. Consumers import Currency, not MasterData WHERE category='CURRENCY'.


How Protean Supports This

Field-level choices for closed codes

When a value is just a constrained string, choices makes invalid values unrepresentable at the field level, with no extra type:

from protean import Domain
from protean.fields import Auto, String

domain = Domain(name="ReferenceDataChoices")


@domain.aggregate
class Order:
    order_id: Auto(identifier=True)
    status: String(choices=["draft", "placed", "shipped", "delivered"], default="draft")

Enumeration value objects for richer closed sets

When a closed value needs a label, a symbol, or behavior, model it as a value object with a frozen registry. It is immutable, equality-by-value, and carries no identity, exactly what reference data is.

Aggregates keyed by a natural code

Protean aggregates take a single surrogate identity, and that identity can be the natural code itself via String(identifier=True). Editable reference data becomes an ordinary aggregate with an ordinary repository, with no special machinery.

Custom repositories for "all of a kind"

A @domain.repository method expresses the "give me the whole set" and "give me the code → value map" queries once, in one place, instead of scattering filter(...) calls across consumers.

Cache-backed projections for app-wide reads

Reference data is read constantly and written rarely, which makes it an ideal projection. A cache-backed projection (@domain.projection(cache="redis")) keeps lookups off the write model and out of the hot path, refreshed by a projector when the underlying aggregate changes.


Applying the Pattern

Closed set: enumeration value object

Order statuses are known at design time. They never get added by an operator. Model them as a value object with a registry, so the set is part of the type system and aggregate invariants can check it:

Before: a row in the lookup table

# fragment
# status is a foreign key into MasterData WHERE category='ORDER_STATUS'
order.status_id = 4471   # what does 4471 mean? a join will tell you.

After: an enumeration value object

from protean import Domain, invariant
from protean.exceptions import ValidationError
from protean.fields import Auto, String, ValueObject

domain = Domain(name="ReferenceDataEnumeration")


@domain.value_object
class OrderStatus:
    code: String(required=True)
    label: String(required=True)

    @invariant.post
    def code_must_be_known(self):
        if self.code not in _ORDER_STATUSES:
            raise ValidationError({"code": [f"Unknown order status: {self.code}"]})

    @classmethod
    def of(cls, code: str) -> "OrderStatus":
        return cls(code=code, label=_ORDER_STATUSES[code])

    @classmethod
    def all(cls) -> list["OrderStatus"]:
        return [cls(code=code, label=label) for code, label in _ORDER_STATUSES.items()]


_ORDER_STATUSES = {
    "draft": "Draft",
    "placed": "Placed",
    "shipped": "Shipped",
    "delivered": "Delivered",
}

Now the status is a typed concept, the registry is the single source of truth, and an aggregate holds it as a field:

@domain.aggregate
class Order:
    order_id: Auto(identifier=True)
    status = ValueObject(OrderStatus)


def valid_order() -> Order:
    return Order(status=OrderStatus.of("placed"))


def invalid_order() -> Order:
    # Raises ValidationError at construction, no database round-trip
    return Order(status=OrderStatus(code="teleported", label="?"))

Protean now checks an order status when the value object is constructed, as part of the always-valid guarantee, instead of leaving a foreign-key check to the database.

Editable reference: aggregate keyed by code

Currencies are administered at runtime: a back-office user adds a new one, deactivates an obsolete one, or fixes a symbol. That lifecycle makes it an aggregate. The natural code is its identity:

from protean import Domain, current_domain, invariant
from protean.exceptions import ValidationError
from protean.fields import Boolean, Integer, String

domain = Domain(name="ReferenceDataCurrencies")


@domain.event(part_of="Currency")
class CurrencyDeactivated:
    code: String(required=True)


@domain.aggregate
class Currency:
    code: String(identifier=True, max_length=3)  # "USD" is the identity
    name: String(required=True)
    symbol: String(max_length=4)
    minor_units: Integer(default=2)  # 2 for USD, 0 for JPY
    is_active: Boolean(default=True)

    @invariant.post
    def code_must_be_iso_4217(self):
        code = self.code
        if not (
            len(code) == 3 and code.isascii() and code.isalpha() and code.isupper()
        ):
            raise ValidationError(
                {"code": ["Currency code must be a 3-letter ISO 4217 code"]}
            )

    def deactivate(self) -> None:
        self.is_active = False
        self.raise_(CurrencyDeactivated(code=self.code))

Each kind of reference data is its own aggregate, with its own table (currency, country, tax_code), its own fields, and its own rules. That is the DDD-correct outcome: distinct concepts, distinctly stored. Seeding the set and loading one are ordinary repository calls:

def seed_currencies() -> None:
    repo = current_domain.repository_for(Currency)
    repo.add(Currency(code="USD", name="US Dollar", symbol="$"))
    repo.add(Currency(code="EUR", name="Euro", symbol="€"))
    repo.add(Currency(code="JPY", name="Japanese Yen", symbol="¥", minor_units=0))


def find_usd() -> Currency:
    repo = current_domain.repository_for(Currency)
    usd = repo.get("USD")  # the code IS the identity
    return usd

A list of a kind: the code → value map

The recurring need is rarely a single entry; it is "give me all active currencies" or "give me the code → Currency map for a dropdown." Express it once on a custom repository, not as a filter(...) copied into every consumer:

@domain.repository(part_of=Currency)
class CurrencyRepository:
    def all_active(self) -> list[Currency]:
        return self.query.filter(is_active=True).all().items

    def as_map(self) -> dict[str, Currency]:
        """code → Currency, ready for validation or a dropdown."""
        return {c.code: c for c in self.all_active()}

Consumers get a typed list or a typed map, never raw rows:

def currency_dropdown() -> list[Currency]:
    repo = current_domain.repository_for(Currency)
    return repo.all_active()  # list[Currency]


def validate_currency(code: str) -> Currency:
    repo = current_domain.repository_for(Currency)
    by_code = repo.as_map()  # {"USD": Currency(...), "EUR": ...}

    if code not in by_code:
        raise ValidationError({"currency": ["Unknown currency"]})
    return by_code[code]

App-wide read access: a cache-backed catalog projection

Reference data is read on nearly every request and written a handful of times a month. Reading it from the write aggregate on every call is wasteful. Project it into a cache-backed read model, refreshed by a projector when the aggregate changes:

# fragment
@domain.projection(cache="redis")
class CurrencyOption:
    code: String(identifier=True)
    name: String(required=True)
    symbol: String(max_length=4)


@domain.projector(projector_for=CurrencyOption, aggregates=[Currency])
class CurrencyCatalogProjector:
    @on(CurrencyAdded)
    def add_option(self, event: CurrencyAdded) -> None:
        cache = current_domain.cache_for(CurrencyOption)
        cache.add(CurrencyOption(code=event.code, name=event.name,
                                 symbol=event.symbol))

    @on(CurrencyDeactivated)
    def remove_option(self, event: CurrencyDeactivated) -> None:
        cache = current_domain.cache_for(CurrencyOption)
        cache.remove_by_key(f"currency_option:::{event.code}")

A cache-backed projection has no repository, so the projector writes through cache_for() and readers use view_for(). Cache keys have the form <prefix>:::<identifier>, where the prefix is the projection's class name in snake case (CurrencyOption becomes currency_option). Renaming the class changes the key the projector must remove.

The read side is now a fast, cache-resident code → option catalog, decoupled from the write model, with a single invalidation point (the projector). When consumers must also query the catalog by attributes or join it into reports, back the same projection with a database provider instead of a cache. The rest of the pattern stays the same.


A single physical table is not supported

Sometimes a legacy master_data table is an immovable infrastructure constraint: a shared database that other systems already read. Protean cannot map several aggregates onto that one table. Each aggregate gets its own table, and two database models cannot share a schema_name. With a SQL provider, the second model on the same schema_name fails at startup with SQLAlchemy's InvalidRequestError: Table 'master_data' is already defined.

Keep Currency and Country as separate aggregates with separate tables. If other systems must still read master_data, keep that table in step outside the domain model.


Anti-Patterns

Modeling the lookup table in the domain

# fragment
# Anti-pattern: the storage shape becomes a domain concept
@domain.aggregate
class MasterData:
    master_id: Auto(identifier=True)
    category: String()
    code: String()
    label: String()

This is the One True Lookup Table, declared as an aggregate with @domain.aggregate. MasterData is not a concept anyone in the business talks about; Currency and Country are. Model the concepts, not the table.

The discriminator as a domain field

# fragment
# Anti-pattern: leaking the storage discriminator into the model
@domain.aggregate
class Currency:
    code: String(identifier=True)
    category: String(default="CURRENCY")   # why does a Currency know this?

A Currency is always a currency. A category field that exists only to distinguish rows in a shared table brings a persistence concern into the domain.

Scattering raw lookups through domain logic

# fragment
# Anti-pattern: every consumer re-implements the lookup
valid = current_domain.repository_for(MasterData).query.filter(
    category="CURRENCY", active=True
).all().items
if any(c.code == command.currency for c in valid):
    ...

This duplicates the query, the caching, and the "is it valid?" logic in every module that touches currencies. Centralize it behind a named type and a repository method (CurrencyRepository.as_map()), written once.

Foreign keys to the surrogate id

# fragment
# Anti-pattern: pointing at the opaque lookup-table id
order.currency_id = 4471          # what currency is 4471? nobody knows without a join

Reference data has a perfectly good natural key, the code. Use it as the aggregate's identity and store currency_code = "USD". The data is readable on its own, and a missing currency is obvious instead of a dangling integer.


When Not to Use / Trade-offs

  • Trivial flags don't need a type: A two- or three-value set with no label and no behavior ("draft" | "active" | "archived") is well served by a bare String(choices=[...]). Promoting it to a value object adds ceremony without benefit.

  • Static vs. dynamic is a judgement call that can change: A set that starts closed (shipping carriers, say) may later need runtime administration. Starting with an enumeration value object and migrating to an aggregate is a deliberate refactor, so model for what you know now.

  • The cache is a freshness trade-off: A cache-backed catalog is fast but eventually consistent: a newly added currency appears once the projector runs. For data that must be immediately consistent on the write path, validate against the aggregate repository, not the cached projection.


Summary

Data character Construct Persistence Validity enforced
Bare closed code String(choices=[...]) None Field constraint
Closed code + label/behavior Enumeration value object None VO invariant, at construction
Editable, queryable Aggregate keyed by code Own table Aggregate invariant
Read everywhere, written rarely Cache-backed projection Cache/provider Refreshed by projector

One physical table is not one domain concept. Model each kind of reference data as its own type: an enumeration value object when it is closed and static, or an aggregate keyed by its natural code when it is editable.


Related reading

Patterns:

Concepts:

  • Value Objects: Immutable descriptive objects without identity.
  • Projections: Read-optimized views and their storage options.

Guides:

  • Database Models: Custom @domain.database_model mappings and schema_name.
  • Repositories: Custom repository methods for retrieving collections.