Skip to content

Identity

DDD CQRS ES

Every aggregate and entity in Protean has a unique identity that is generated at construction time, before the object is ever persisted. Every aggregate and entity needs an identity, and Protean gives it one without being asked. Here is how to accept that default, name the identifier field, choose a different type, plug in your own generator, and use natural keys that come from your users.

For the design rationale. Why identity is generated on construction and why composite keys aren't supported, see Identity and the Creating Identities Early pattern.

Accept the defaults

If you don't declare an identifier field, Protean adds one for you. It's an Auto field named id, generated as a UUID rendered as a string:

from protean import Domain
from protean.fields import String

domain = Domain(name="Sales")


@domain.aggregate
class Customer:
    name: String(max_length=100, required=True)
In [1]: customer = Customer(name="Jane Doe")

In [2]: customer.to_dict()
Out[2]: {'name': 'Jane Doe', 'id': '9cf4ddc4-2919-4021-bd1a-c8083b5fdda7', '_version': -1}

This is the right choice for most aggregates: UUIDs are globally unique, can be generated without coordination, and are safe to reference across aggregates, events, and bounded contexts.


Name the identifier field

When you want the identifier to have a domain-meaningful name, declare it explicitly with Auto(identifier=True):

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

domain = Domain(name="Accounts")


@domain.aggregate
class User:
    user_id: Auto(identifier=True)
    name: String(required=True)
In [1]: user = User(name="John Doe")

In [2]: user.to_dict()
Out[2]: {'user_id': '9cf4ddc4-2919-4021-bd1a-c8083b5fdda7', 'name': 'John Doe', '_version': -1}

The default id field is dropped once you declare your own identifier.

Only one identifier per aggregate

Protean does not support composite keys. Declaring more than one field with identifier=True raises NotSupportedError. If a concept needs two or more attributes to be unique together, model them as a value object and give the aggregate a surrogate identity.


Change the identity type

Identities are strings by default. Switch the type domain-wide by setting identity_type in configuration:

# domain.toml
identity_strategy = "uuid"
identity_type = "uuid"          # UUID rendered as a string

Supported types are string, uuid, and integer:

Type Generated value Use when...
string (default) "9cf4ddc4-2919-..." You want portability across every adapter
uuid "9cf4ddc4-..." (a string; stored in a native UUID column) Your database has a native UUID column (e.g. PostgreSQL)
integer 154702789254628181... Your persistence layer requires integer ids (on SQL, see below)

An integer identity under the default uuid strategy is a 128-bit number. The memory provider holds it, and Elasticsearch stores it as a string. On a SQL provider no integer column holds it, so Domain.init() raises ConfigurationError. There, pair integer with identity_strategy = "function" or with Auto(increment=True). See the note under identity_type.

You can also override the type for a single aggregate without changing the domain default:

@domain.aggregate
class Reading:
    reading_id: Auto(identifier=True, identity_type="integer")
    value: String(max_length=50)

See identity_strategy and identity_type in Configuration for the complete listing.


Use a custom identity function

When UUIDs don't fit. You need epoch-millisecond IDs, snowflake IDs, a prefixed business key, or anything else, switch the strategy to function and supply a callable.

At the domain level

Set the strategy in config and pass identity_function= when constructing the domain. Every aggregate in the domain will use this function:

import time

from protean import Domain
from protean.fields import String


def epoch_ms_id() -> int:
    return int(time.time() * 1000)


domain = Domain(
    name="Launches",
    config={
        "identity_strategy": "function",
        "identity_type": "integer",
    },
    identity_function=epoch_ms_id,
)


@domain.aggregate
class Event:
    name: String(max_length=100, required=True)
In [1]: event = Event(name="launch")

In [2]: event.to_dict()
Out[2]: {'name': 'launch', 'id': 1718139167980, '_version': -1}

Protean does not check the function's return value against identity_type. Make the function return the type you configured: a function that returns a string while identity_type="integer" gives the aggregate a string id.

Per aggregate

To override the default for one aggregate (for example, to mint a prefixed key) set the strategy and function directly on the field:

import uuid

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

domain = Domain(name="Billing")


def invoice_number() -> str:
    return f"INV-{uuid.uuid4().hex[:12].upper()}"


@domain.aggregate
class Invoice:
    invoice_number: Auto(
        identifier=True,
        identity_strategy="function",
        identity_function=invoice_number,
        identity_type="string",
    )
    customer_name: String(max_length=100, required=True)
In [1]: Invoice(customer_name="Acme Corp").to_dict()
Out[1]: {'invoice_number': 'INV-A3F2B9C81D4E', 'customer_name': 'Acme Corp', '_version': -1}

Field-level settings take precedence over domain configuration, so this Invoice generates prefixed IDs while every other aggregate in the domain keeps the default UUID strategy.


Accept identities from the outside

Not every identifier is generated by your system. When users supply the identity (email addresses, SKUs, tax IDs, tenant-assigned tokens), declare the field by its own type and mark it identifier=True:

from protean import Domain
from protean.fields import String

domain = Domain(name="Accounts")


@domain.aggregate
class User:
    email: String(identifier=True, required=True, max_length=254)
    name: String(required=True)

Nothing generates a value for that field. If you construct a User without an email, Protean raises ValidationError:

In [1]: User(name="John Doe")
...
ValidationError: {'email': ['is required']}

In [2]: User(email="john@example.com", name="John Doe").to_dict()
Out[2]: {'email': 'john@example.com', 'name': 'John Doe', '_version': -1}

Do not use Identifier(identifier=True) for a natural key. That field behaves like Auto: leave the value out and Protean generates one with the configured identity generator, a UUID by default.

Natural keys are rarely stable

If the "natural" identifier of a concept can change (an email address being the classic example) then it is not an identity. Treat it as a regular attribute and keep a surrogate Auto identifier. See Identity Should Be Immutable.


Let the database generate the id

Some teams prefer auto-incrementing integer IDs generated by the database itself. Set increment=True on the Auto field. The value is left unset at construction time and assigned on persistence:

@domain.aggregate
class AuditEntry:
    entry_id: Auto(identifier=True, increment=True, identity_type="integer")
    message: String(max_length=500, required=True)

This only works when:

  • The underlying adapter supports auto-incrementing columns (e.g. PostgreSQL, SQLite).
  • The database schema declares the column as SERIAL, AUTOINCREMENT, or equivalent. Protean does not create the sequence for you.

Because the id isn't available until the aggregate is saved, this mode trades away the "identity at creation" guarantee. Once you persist the aggregate with repository.add(), the generated id is reflected back onto the same instance, so you can reference or re-fetch it afterwards. This holds whether the add runs on its own or inside a UnitOfWork. Prefer uuid or a custom function unless you have a strong reason to defer identity generation to the database.


Common errors

Exception When it occurs
NotSupportedError Two or more fields on the same aggregate are declared identifier=True. Protean doesn't support composite keys, use a value object for multi-attribute uniqueness.
ValidationError An identity field that generates nothing (for example String(identifier=True, required=True)) is left empty. The caller must supply a value.
ConfigurationError identity_strategy="function" is configured but no identity_function was passed to Domain(...) or Auto(...).
ConfigurationError An unknown identity_strategy or identity_type value is set in configuration.
ConfigurationError On a SQL provider, a generated identifier gets an integer identity under the uuid strategy. Raised by Domain.init().
ConfigurationError On a SQL provider with identity_type = "integer", a generated identifier sets identity_type="string" or "uuid" itself. Its column is an integer column, so the value does not fit. Raised by Domain.init().

See also

Concept overview: Identity: Why identity is fundamental to DDD and what makes an identity stable.

Pattern: Creating Identities Early: Why Protean generates identities at construction time.

Reference:

Related guides:

  • Fields: Declaring attributes on domain elements.
  • Value Objects: Modeling concepts without identity.