Entities
DDD CQRS ES
Aggregates cluster multiple domain elements together to represent a concept. They are usually composed of two kinds of elements - those with unique identities (Entities) and those without (Value Objects).
Entities represent unique objects in the domain model just like Aggregates, but they don't own the lifecycle of other objects. Just like Aggregates, Entities are identified by unique identities that remain the same throughout their life. Their attributes or values do not define them. For example, a passenger in the airline domain is an Entity. The passenger's identity remains the same across multiple seat bookings, even if her profile information (name, address, etc.) changes over time.
Note
In Protean, Aggregates are actually entities that have taken on the additional responsibility of managing the lifecycle of one or more related entities.
Definition
An Entity is defined with the Domain.entity decorator:
from protean.domain import Domain
from protean.fields import Date, String
publishing = Domain()
@publishing.aggregate
class Post:
name: String(max_length=50)
created_on: Date()
@publishing.entity(part_of=Post)
class Comment:
content: String(max_length=500)
An Entity has to be associated with an Aggregate. If part_of is not
specified while defining the entity, you will see an IncorrectUsageError:
>>> @publishing.entity
... class Comment:
... content = String(max_length=500)
...
Traceback (most recent call last):
...
protean.exceptions.IncorrectUsageError: Entity `Comment` needs to be associated with an Aggregate
An Entity cannot directly enclose an Aggregate. Trying to do so will
throw IncorrectUsageError.
However, entities can enclose other entities using HasOne and HasMany
relationships, as described in the Associations section below.
Configuration
Similar to an aggregate, an entity's behavior can be customized by passing
options to its decorator. Protean does not read an inner class Meta: on an
entity, so options placed there are ignored.
Available options are:
auto_add_id_field
If True (the default), Protean automatically adds an identifier field
(acting as primary key) to the entity. Set to False to suppress automatic
identity generation, useful when the entity defines its own explicit identifier
field.
schema_name
The name to store and retrieve the entity from the persistence store. By
default, schema_name is the snake case version of the Entity's name.
provider
Inherited from the parent aggregate. Entities are always persisted in the same persistence store as their aggregate. You cannot configure a separate provider for an entity.
limit
The maximum number of entity instances returned by default queries
(default: 100). Set to None or a negative value to remove the limit.
Note
An Entity is always persisted in the same persistence store as its Aggregate.
indexes
Declares indexes for the entity's own table, using the same
Index declarations as
aggregates:
from protean import Index
@domain.aggregate
class Order:
number: String(max_length=20)
line_items = HasMany("LineItem")
@domain.entity(part_of=Order, indexes=[Index("sku", unique=True)])
class LineItem:
sku = String(max_length=64)
quantity = Integer()
See Declaring Indexes for the full workflow.
Custom Database Models
Protean builds the entity's database model from its fields. To control the
mapping yourself, register your own model with
@domain.database_model(part_of=<Entity>), as described in
Database Models.
Sharing Fields Through a Base Class
@domain.entity has no abstract option. Passing it raises
ConfigurationError. To share fields and behavior between entities, put them
on a subclass of BaseEntity and leave that class undecorated. Decorate only
the concrete subclasses:
from protean.core.entity import BaseEntity
from protean.utils.reflection import declared_fields
class BaseLineItem(BaseEntity):
quantity: Integer(required=True, min_value=1)
unit_price: Decimal(precision=19, scale=4, required=True)
@property
def subtotal(self):
return self.quantity * self.unit_price
@domain.entity(part_of=Order)
class ProductLineItem(BaseLineItem):
sku: String(max_length=64, required=True)
print(list(declared_fields(ProductLineItem)))
# ['quantity', 'unit_price', 'id', 'sku', 'order']
The base class is not registered with the domain and gets no table of its own.
It must subclass BaseEntity: a plain mixin class contributes no fields.
Entity Lifecycle
Protean tracks the lifecycle state of every entity instance internally. The state determines what happens when the aggregate is persisted:
| State | Property | Meaning |
|---|---|---|
| New | _state.is_new |
Freshly constructed, not yet persisted. Inserted when the aggregate is next persisted with repository.add(). |
| Persisted | _state.is_persisted |
Loaded from or saved to the database. No pending changes. |
| Changed | _state.is_changed |
Modified since last persistence. Updated when the aggregate is next persisted with repository.add(). |
| Destroyed | _state.is_destroyed |
Marked for deletion. Deleted when the aggregate is next persisted with repository.add(). |
State transitions happen automatically. You don't need to set the state yourself. Creating an entity marks it as new; modifying an attribute marks it as changed; removing it from a collection marks it as destroyed; persisting the aggregate marks surviving entities as persisted.
Raising Events from Entities
Entities can raise domain events using the raise_() method, just like
aggregates. However, the event is always registered on the aggregate root,
not on the entity itself. The root is the owner of the event stream.
@domain.aggregate
class Order:
number: String(max_length=20)
items = HasMany("OrderItem")
@domain.event(part_of=Order)
class OrderItemQuantityChanged:
order_id: Identifier(required=True)
product_name: String(max_length=100)
new_quantity: Integer()
@domain.entity(part_of=Order)
class OrderItem:
product_name: String(max_length=100)
quantity: Integer()
def update_quantity(self, new_qty):
self.quantity = new_qty
self.raise_(
OrderItemQuantityChanged(
order_id=str(self._owner.id),
product_name=self.product_name,
new_quantity=new_qty,
)
)
The event must be associated with the aggregate (part_of=Order), not
with the entity. Access the owning aggregate via self._owner.
Invariants
Entities support the same invariant mechanism as aggregates. Use
@invariant.post to enforce rules that must always hold:
from protean.exceptions import ValidationError
@domain.entity(part_of=Order)
class OrderItem:
product_name: String(max_length=100)
quantity: Integer()
@invariant.post
def quantity_must_be_positive(self):
if self.quantity is not None and self.quantity <= 0:
raise ValidationError({"quantity": ["Quantity must be positive"]})
Entity invariants are checked whenever entity state changes. They work alongside aggregate-level invariants. Both must pass for the aggregate cluster to be in a valid state. See the Invariants guide for details.
The defaults() Hook
Override the defaults() method to set computed defaults that depend on
other field values:
@domain.entity(part_of=Order)
class OrderItem:
product_name: String(max_length=100)
quantity: Integer(default=1)
unit_price: Decimal(precision=19, scale=4)
line_total: Decimal(precision=19, scale=4)
def defaults(self):
if self.line_total is None and self.unit_price is not None:
self.line_total = self.quantity * self.unit_price
defaults() runs during initialization, after all field values have been
set but before invariants are checked. Aggregates, entities, and value
objects all support this hook.
Persistence
Entities are always persisted as part of their parent aggregate's transaction. In relational databases (SQLAlchemy provider), each entity type gets its own table with a foreign key back to the aggregate. In document databases (Elasticsearch provider), entities are typically stored as nested documents within the aggregate's document.
You never persist an entity directly, always persist through the aggregate's repository:
domain.init(traverse=False)
with domain.domain_context():
order = Order(number="ORD-1", items=[OrderItem(product_name="Widget", quantity=2)])
repo = domain.repository_for(Order)
repo.add(order) # Persists the order AND all its OrderItems
Constructing from Value Objects
When commands and events carry entity data as value objects (see
Projecting Entities into Value Objects),
use the from_value_object() classmethod to convert them back:
from protean import handle
from protean.fields import List, ValueObjectFromEntity
@domain.command(part_of=Order)
class PlaceOrder:
number: String(max_length=20)
items: List(content_type=ValueObjectFromEntity(OrderItem))
@domain.command_handler(part_of=Order)
class PlaceOrderHandler:
@handle(PlaceOrder)
def handle_place_order(self, command: PlaceOrder):
items = [OrderItem.from_value_object(item) for item in command.items]
order = Order(number=command.number, items=items)
domain.repository_for(Order).add(order)
The conversion itself needs no handler. With OrderItem as defined above:
from protean import value_object_from_entity
OrderItemData = value_object_from_entity(OrderItem)
with domain.domain_context():
data = OrderItemData(product_name="Widget", quantity=2)
item = OrderItem.from_value_object(data)
print(item.product_name, item.quantity) # Widget 2
from_value_object() calls vo.to_dict() and constructs an entity instance.
Protean strips identity fields with None values, so the auto-generated
defaults apply. This means each converted entity gets a fresh identity rather
than failing validation.
Associations
Entities can enclose other entities within them using HasOne and HasMany relationships, similar to aggregates. Additionally, entities automatically receive Reference fields that establish inverse relationships to their parent aggregate.
Automatic Reference Fields
When an entity is associated with an aggregate, Protean automatically creates a Reference field that points back to the parent:
@domain.aggregate
class Order:
number: String(max_length=20)
items = HasMany("OrderItem")
@domain.entity(part_of=Order)
class OrderItem:
product_name: String(max_length=100)
quantity: Integer()
# Automatically gets: order = Reference(Order)
# Automatically gets: order_id, a shadow field holding the order's id
Explicit Reference Fields
You can also define the reference field yourself, for example to rename its
shadow field, the attribute that holds the parent's identity. Tell the
aggregate's HasMany about the new name with via:
@domain.aggregate
class Order:
number: String(max_length=20)
items = HasMany("OrderItem", via="parent_order_id")
@domain.entity(part_of=Order)
class OrderItem:
product_name: String(max_length=100)
quantity: Integer()
order = Reference(Order, referenced_as="parent_order_id")
# Creates shadow field 'parent_order_id' instead of 'order_id'
A Reference field may point only to the entity's parent: the aggregate root,
or the parent entity when the entity is nested. To link to a different
aggregate, store its identity in an Identifier field.
Navigation Between Entities
Reference fields enable navigation from child entities back to their parent aggregate:
with domain.domain_context():
# Access parent aggregate from entity
order_item = order.items[0]
parent_order = order_item.order # Order object
order_id = order_item.order_id # Order's ID value
For comprehensive relationship documentation, see Expressing Relationships and Association Fields.
Common Errors
| Exception | When it occurs |
|---|---|
IncorrectUsageError |
Entity defined without part_of; every entity must be associated with an aggregate. |
IncorrectUsageError |
Entity's part_of names a class that is neither an aggregate nor an entity. |
ValidationError |
Field validation fails during construction (e.g. missing required field). Contains a messages dict. |
ValidationError |
An @invariant.post check on the entity raises a validation error. |
ConfigurationError |
An unknown option is passed to @domain.entity, such as abstract. |
ConfigurationError |
Entity raises an event not associated with its aggregate root (part_of mismatch). |
See also
Concept overview: Entities: What entities are and how they relate to aggregates.
Decision guidance: Choosing Element Types: When to use an entity vs. an aggregate vs. a value object.
Related guides:
- Invariants: Enforcing business rules on entities and aggregates.
- Raising Events: How entities raise events through their aggregate root.
- Expressing Relationships: Full relationship and association documentation.
Patterns:
- Design Small Aggregates: Drawing the right boundaries between aggregates and entities.
- Encapsulate State Changes: Named methods for controlled mutation.