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:

platform/facets/models.py
@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:

todos/domain/facets.py
@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:

todos/application/use_cases/preferences.py
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:

Facet Storage
%%{init: {"flowchart": {"useMaxWidth": false}}}%% graph LR KEY["FacetKey
(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:

todos/domain/facets.py
@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:

export.py
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", ...}
# }
When to Use Facets

Use facets for preferences, settings, and profiles that extend the owner. Don't use facets for domain entities—those belong in component-specific repositories.

Rules
  • 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
Pitfalls
  • 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 namessettings will collide; use todos.settings