Skip to content

Expressing Relationships

DDD CQRS ES

Relationships live inside aggregate boundaries

In DDD, associations (HasOne, HasMany, Reference) only connect objects within the same aggregate cluster. Aggregates never hold direct object references to other aggregates — they reference each other by identity. This rule keeps transaction boundaries clean and prevents hidden coupling between independently consistent clusters.

See Cross-Aggregate References for the identity-based pattern.

Protean provides a relationship system for modeling associations between domain entities within an aggregate cluster. Relationships are expressed through association fields (HasOne, HasMany) and their corresponding reference fields (Reference), which work together to establish bidirectional linkages.

Relationship Types

One-to-One (HasOne)

A HasOne relationship represents a one-to-one association between an aggregate and a child entity. The aggregate can have at most one instance of the related entity.

@domain.aggregate
class Blog:
    title: String(max_length=100)
    settings = HasOne("BlogSettings")

@domain.entity(part_of=Blog)
class BlogSettings:
    theme: String(max_length=50)
    allow_comments: Boolean(default=True)

One-to-Many (HasMany)

A HasMany relationship represents a one-to-many association where an aggregate can contain multiple instances of a child entity.

@domain.aggregate
class Post:
    title: String(max_length=100)
    comments = HasMany("Comment")

@domain.entity(part_of=Post)
class Comment:
    content: String(max_length=500)
    author: String(max_length=50)

Value Object Embedding

Value objects are embedded using the ValueObject field type, not HasOne/HasMany. Unlike entity associations, value objects are stored inline with the parent — they don't have their own identity or separate table.

@domain.value_object
class Address:
    street: String(max_length=200)
    city: String(max_length=100)
    zip_code: String(max_length=10)

@domain.aggregate
class Customer:
    name: String(max_length=100)
    billing_address = ValueObject(Address)

See the Value Objects guide for details on embedding and initialization.

Reference Fields

Every association automatically creates a corresponding Reference field in the child entity that points back to the parent aggregate. This establishes the inverse relationship and provides access to the parent from the child.

Automatic Reference Creation

Protean automatically adds a Reference field to entities based on the aggregate they belong to:

# After registration, Comment automatically gets:
# post = Reference(Post)  # Field name derived from aggregate name
# post_id = String()      # Shadow field for the foreign key

Explicit Reference Fields

You can explicitly define reference fields for more control:

@domain.entity(part_of=Post)
class Comment:
    content: String(max_length=500)
    post = Reference(Post)  # Explicit reference field

Shadow Fields

Reference fields automatically create shadow fields (foreign key attributes) that store the actual identifier values:

  • Reference field: comment.post → Contains the Post object
  • Shadow field: comment.post_id → Contains the Post's ID value

Customizing Relationships

The via Parameter

The via parameter allows you to specify which field in the child entity should be used as the foreign key, instead of the default naming convention:

@domain.aggregate
class Product:
    name: String(max_length=100)
    sku: String(identifier=True, max_length=20)
    reviews = HasMany("Review", via="product_sku")

@domain.entity(part_of=Product)
class Review:
    content: String(max_length=1000)
    rating: Integer(min_value=1, max_value=5)
    product_sku: String()  # Custom foreign key field

Without via, the foreign key would be product_id. With via="product_sku", it uses product_sku instead.

The referenced_as Parameter

The referenced_as parameter in Reference fields allows you to specify a custom name for the shadow field:

@domain.entity(part_of=Order)
class OrderItem:
    product_name: String(max_length=100)
    order = Reference(Order, referenced_as="order_number")
    # Creates shadow field named 'order_number' instead of 'order_id'

When using both via and referenced_as, they must agree: the via value on the HasOne/HasMany side should match the referenced_as value on the Reference side so both ends resolve to the same field.

Auto-Generated Helper Methods

For HasMany associations, Protean automatically generates helper methods on the parent object. Given a field named comments, the following methods are created:

Method Behavior
add_comments(item_or_list) Append one or more entities to the collection
remove_comments(item_or_list) Remove one or more entities from the collection
get_one_from_comments(**kwargs) Return a single entity matching the criteria (raises if zero or multiple matches)
filter_comments(**kwargs) Return all entities matching the criteria

The method names are derived from the field name: add_<field>, remove_<field>, get_one_from_<field>, filter_<field>.

post = Post(title="New Post")

# Add comments
post.add_comments(Comment(content="First comment", author="alice"))
post.add_comments([
    Comment(content="Second comment", author="bob"),
    Comment(content="Third comment", author="alice"),
])

# Query within the collection
alice_comments = post.filter_comments(author="alice")
bob_comment = post.get_one_from_comments(author="bob")

# Remove
post.remove_comments(bob_comment)

Note

HasOne fields do not generate helper methods — assign directly (e.g. blog.settings = BlogSettings(...)).

Bidirectional Navigation

Relationships in Protean are bidirectional, allowing navigation in both directions:

# From parent to child
post = Post(title="My Post")
comments = post.comments  # List of Comment objects

# From child to parent
comment = Comment(content="Great post!")
post = comment.post  # Post object
post_id = comment.post_id  # Post's ID value

Dictionary Assignment

You can assign a plain dictionary where an entity or value object is expected. Protean will automatically convert it:

post.stats = {"likes": 10, "dislikes": 1}
# Equivalent to: post.stats = Statistic(likes=10, dislikes=1)

This also works during aggregate initialization for nested structures.

Association Constraints

All association fields (HasOne, HasMany, Reference) are implicitly optional — Protean sets required=False on them internally. There is currently no way to make an association required at the field level; enforce mandatory children through aggregate invariants instead:

@domain.aggregate
class Order:
    items = HasMany("OrderItem")

    @invariant.post
    def must_have_at_least_one_item(self):
        if not self.items:
            raise ValidationError({"items": ["Order must have at least one item"]})

Cross-Aggregate References

Aggregates are independent consistency boundaries. They should never hold direct object references (HasOne, HasMany, Reference) to other aggregates — doing so would create hidden transactional coupling.

Instead, reference another aggregate by storing its identity as a simple Identifier or String field:

@domain.aggregate
class Order:
    customer_id = Identifier(required=True)  # References Customer aggregate
    items = HasMany("OrderItem")

@domain.aggregate
class Customer:
    name: String(max_length=100)
    email = ValueObject("Email")

When you need to load the referenced aggregate, do so explicitly through its repository:

customer = domain.repository_for(Customer).get(order.customer_id)

This keeps each aggregate independently loadable, persistable, and deployable. For keeping aggregates in sync after state changes, use domain events.

Cascade Behavior

When an aggregate is persisted, all enclosed entities (connected via HasOne/HasMany) are persisted together as part of the same transaction. When an aggregate is deleted, its enclosed entities are deleted with it — they cannot exist independently.

Removing an entity from a HasMany collection (via remove_<field>) marks it for deletion during the next persistence operation.

Loading Behavior

Entity associations within an aggregate are loaded eagerly. When you retrieve an aggregate from a repository, all its enclosed entities are loaded in the same operation. There is no lazy loading — the entire aggregate graph is materialized at once.

This is by design: an aggregate is a consistency boundary, and partial loading would make it impossible to enforce invariants that span the root and its children.

Event Sourcing Considerations

For event-sourced aggregates (is_event_sourced=True), associations behave differently because state is reconstructed from events rather than loaded from a database:

  • Entity collections (HasMany, HasOne) start empty after event replay begins and are populated only by @apply handlers that process the relevant events.
  • There are no database-level foreign keys or joins — the aggregate's entire state (including its entities) is rebuilt from its event stream.
  • Cross-aggregate references by identity work the same way as in standard aggregates.

See also

Concept overview: Aggregates — How aggregates define consistency boundaries that contain entities and value objects.

Related guides:

  • Entities — Define entities with identity within an aggregate.
  • Value Objects — Embed immutable descriptive objects in aggregates.

Reference:

  • Association Fields — Full API reference for HasOne, HasMany, Reference, and ValueObject fields.

Patterns: