Facets
Typed extension points for preferences and settings
The Problem
Components need to store user preferences and settings. Where should these live?
- Not in Identity—Person shouldn't know about todo preferences
- Not as top-level entities—they're extensions of the owner, not independent things
- Not as JSON blobs—we need schema validation and versioning
Facets solve this: typed, versioned payloads attached to a Person or Group.
Facet Structure
A facet is identified by three things:
@dataclass(frozen=True) class FacetKey: owner_type: str # "person" or "group" owner_id: UUID # The person or group ID facet_name: str # "todos.preferences", "budget.settings"
The facet_name is namespaced by component to avoid collisions.
Defining a Facet
Each component defines its facet payloads as dataclasses implementing FacetPayload:
@dataclass class TodoPreferences(FacetPayload): """User preferences for the Todos component.""" default_container_id: UUID | None = None show_completed: bool = True sort_by: str = "due_date" theme: str = "light" @property def schema_version(self) -> int: return 1 def to_dict(self) -> dict: return asdict(self)
The schema_version enables migrations when the payload structure changes.
Reading and Writing
Use cases interact with facets through ports:
class GetPreferences: def __init__(self, facet_reader: FacetReaderPort): self.facet_reader = facet_reader def execute(self, owner_id: UUID) -> TodoPreferences: key = FacetKey("person", owner_id, "todos.preferences") data = self.facet_reader.get(key) if data is None: return TodoPreferences() # defaults return TodoPreferences(**data) class UpdatePreferences: def __init__(self, facet_writer: FacetWriterPort): self.facet_writer = facet_writer def execute(self, owner_id: UUID, prefs: TodoPreferences): key = FacetKey("person", owner_id, "todos.preferences") self.facet_writer.put(key, prefs.to_dict())
Storage Model
Facets are stored in a single table with the composite key and JSON payload:
(owner_type, owner_id, facet_name)"] STORE["FacetStore"] PAYLOAD["JSON Payload
(schema_version + data)"] KEY --> STORE STORE --> PAYLOAD style KEY fill:#f3e8ff,stroke:#9333ea style STORE fill:#fef3c7,stroke:#d97706 style PAYLOAD fill:#ecfeff,stroke:#0891b2
This design allows:
- Discovery — List all facets for an owner
- Isolation — Components can't see each other's facets without the name
- Flexibility — Any JSON-serializable payload
Schema Versioning
When you add or remove fields, bump the schema version and handle migration:
@dataclass class TodoPreferences(FacetPayload): # v2: added 'notifications_enabled' default_container_id: UUID | None = None show_completed: bool = True sort_by: str = "due_date" theme: str = "light" notifications_enabled: bool = True # new field @property def schema_version(self) -> int: return 2 @classmethod def from_dict(cls, data: dict, version: int): if version == 1: # migrate: add default for new field data["notifications_enabled"] = True return cls(**data)
Discovery for Export
For GDPR exports and data discovery, facets can be listed by owner:
def export_user_facets(owner_id: UUID) -> dict: facets = facet_store.list_by_owner("person", owner_id) return { facet.facet_name: facet.payload for facet in facets } # Returns: # { # "todos.preferences": {"show_completed": true, ...}, # "budget.settings": {"weekly_reset_day": "monday", ...} # }
Use facets for preferences, settings, and profiles that extend the owner. Don't use facets for domain entities—those belong in component-specific repositories.
- Facet names MUST be namespaced:
component.name - Facet payloads MUST include
schema_version - Adding fields MUST provide defaults for migration
- Removing fields MUST handle missing data gracefully
- Don't store entities as facets — Tasks aren't preferences; they're domain objects
- Don't skip versioning — You will need to migrate eventually
- Don't use generic names —
settingswill collide; usetodos.settings