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
Currencyrow and anOrderStatusrow 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
Currencyconcept to import. So every module that needs currencies writes its ownfilter(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 theextragrab-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 naturalcode. When you read raw data, you now need a join to find out that row4471means"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
codeas 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 bareString(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:
- Replace Primitives with Value Objects: Extracting validated, composite values into value objects.
- Design Projection Granularity Around Consumer Needs: Shaping read models, including cache-backed projections for volatile data.
- Validation Layering: Where reference-data validity belongs across the four validation layers.
Concepts:
- Value Objects: Immutable descriptive objects without identity.
- Projections: Read-optimized views and their storage options.
Guides:
- Database Models: Custom
@domain.database_modelmappings andschema_name. - Repositories: Custom repository methods for retrieving collections.