Defining Domain Concepts

DDD is all about representing domain concepts as closely as possible in code. To accomplish this, DDD outlines a set of tactical patterns that we could use to model the domain. When you want to model domain concepts that have a unique identity and that change continuously over a long period of time, you represent them as Aggregates.

Aggregates are fundamental, coarse-grained building blocks of a domain model. They are conceptual wholes - they enclose all behaviors and data of a distinct domain concept. Aggregates are often composed of one or more Aggregate Elements, that work together to codify the concept.

In a sense, Aggregates act as Root Entities - they manage the lifecycle of all Entities and Value Objects enclosed within them. Put another way, all elements enclosed within an Aggregate are only accessible through the Aggregate itself - it acts as a consistency boundary and protects data sanctity within the cluster.

Aggregates

Aggregates are defined with the aggregate() decorator:

from protean.domain import Domain
from protean.core.field.basic import Date, String

publishing = Domain(__name__)

@publishing.aggregate
class Post:
    name = String(max_length=50)
    created_on = Date()

In the example, Post is defined to be an Aggregate with two fields, name and created_on, and registered with the publishing domain.

You can also define the Aggregate as a subclass of BaseAggregate and register it manually with the domain:

>>> class Post(BaseAggregate):
...     name = String(max_length=50)
...     created_on = Date()

... publishing.register(Post)
<class '__main__.Post'>

Fields

Aggregates enclose a number of fields and associate behaviors with them to represent a domain concept. The fields declared in the aggregate are available as a map in declared_fields.

This example defines a Person Aggregate, which has a first_name and last_name.

@domain.aggregate
class Person:
    first_name = String(max_length=30)
    last_name = String(max_length=30)

first_name and last_name are fields of the aggregate. Each field is specified as a class attribute, that ultimately maps to a database column (or node if you are using a NoSQL database):

>>> Person.meta_.declared_fields
{'first_name': <protean.core.field.basic.String at 0x114a3be20>,
'last_name': <protean.core.field.basic.String at 0x114a3bc40>,
'id': <protean.core.field.basic.Auto at 0x114a3b310>}

The full list of available fields can be found in Data Fields.

Initialization

You can initialize the values of a post object as key-value pairs:

>>> person = Person(first_name="John", last_name="Doe")
>>> person.to_dict()
{'first_name': 'John',
'last_name': 'Doe',
'id': '6c5e7221-e0c6-4901-9a4f-c9218096b0c2'}

Identity

If you observe the output of person object carefully, you will see a field called id associated automatically with the Person aggregate.

Aggregates (and Entities) should always have a unique identity associated with them. By default, unique identifier field named id is added automatically by Protean. id is an Auto field and populated with the strategy specified for ID_STRATEGY in config.

The identifier field is also available among declared_fields, or you can access it via the special id_field meta attribute:

>>> Person.meta_.declared_fields
{'first_name': <protean.core.field.basic.String at 0x10a647c70>,
'last_name': <protean.core.field.basic.String at 0x10a6476d0>,
'id': <protean.core.field.basic.Auto at 0x10a647340>}
>>> Person.meta_.id_field
<protean.core.field.basic.Auto at 0x10a647340>

By default, Protean uses the UUID Identity strategy and aggregates generate UUID values on initialization:

>>> p = Person(first_name='John', last_name='Doe')
>>> p.to_dict()
{'first_name': 'John',
'last_name': 'Doe',
'id': '6667ec6e-d568-4ac5-9d66-0c9c4e3a571b'}

The identifier can be optionally overridden by setting identifier=True to a field. Fields marked as identifiers are both required and unique and can contain either protean.core.field.basic.Integer or protean.core.field.basic.String values.

In the example below, the default identifier has been overridden with an explicit email String field:

@domain.aggregate
class Person:
    email = String(identifier=True)
    first_name = String(max_length=30)
    last_name = String(max_length=30)

When overridden, the application is responsible for initializing the entity with a unique identifier value (unless the field is of type protean.core.field.basic.Auto):

>>> p = Person(first_name='John', last_name='Doe')
ValidationError                           Traceback (most recent call last)
...
ValidationError: {'email': ['is required']}

Inheriting Aggregates

Often, you may want to put some common information into a number of Aggregates into your domain. A Protean Aggregate can be inherited from another Aggregate class:

@domain.aggregate
class TimeStamped:
    created_at = DateTime(default=datetime.utcnow)
    updated_at = DateTime(default=datetime.utcnow)

@domain.aggregate
class User(TimeStamped):
    name = String(max_length=30)
    timezone = String(max_length=30)

The User aggregate will have three fields of its own including an identifier, and two derived from its parent class:

>>> User.meta_.declared_fields
{'name': <protean.core.field.basic.String at 0x10a80d8b0>,
'timezone': <protean.core.field.basic.String at 0x1063753d0>,
'created_at': <protean.core.field.basic.DateTime at 0x106375dc0>,
'updated_at': <protean.core.field.basic.DateTime at 0x10a80dd60>,
'id': <protean.core.field.basic.Auto at 0x10a83e6d0>}

>>> user = User(name='John Doe', address='101, Timbuktu St.')
>>> user.to_dict()
{'name': 'John Doe',
'timezone': None,
'created_at': datetime.datetime(2021, 7, 7, 16, 35, 10, 799318),
'updated_at': datetime.datetime(2021, 7, 7, 16, 35, 10, 799327),
'id': '557770a2-5f34-4f80-895b-c38f2679766b'}

If you do not want the parent Aggregate to be instantiable, you can mark it as abstract.

Declaring Abstract Base Aggregates

By default, Protean Aggregates and Entities are concrete and instantiable:

@domain.aggregate
class Person:
    first_name = String(max_length=30)
    last_name = String(max_length=30)

Person is concrete and can be instantiated:

>>> Person.meta_.abstract
False
>>> person = Person(first_name='John', last_name='Doe')
>>> person.to_dict()
{'first_name': 'John',
'last_name': 'Doe',
'id': '6667ec6e-d568-4ac5-9d66-0c9c4e3a571b'}

You can optionally declare an Aggregate as abstract with the abstract Meta option:

@domain.aggregate
class AbstractPerson:
    first_name = String(max_length=30)
    last_name = String(max_length=30)

    class Meta:
        abstract = True

An Aggregate marked as abstract cannot be instantiated. It’s primary purpose is to serve as a base class for other aggregates.

>>> AbstractPerson.meta_.abstract
True

Trying to instantiate an abstract Aggregate will raise a NotSupportedError error:

.. code-block:: python
>>> person = AbstractPerson()
NotSupportedError                         Traceback (most recent call last)
...
NotSupportedError: AbstractPerson class has been marked abstract and cannot be instantiated

An Aggregate derived from an abstract parent is concrete by default:

class Adult(AbstractPerson):
    age = Integer(default=21)

Adult class is instantiable:

.. code-block:: python
>>> Adult.meta_.abstract
False
>>> adult = Adult(first_name='John', last_name='Doe')
>>> adult.to_dict()
{'first_name': 'John',
'last_name': 'Doe',
'age': 21,
'id': '6667ec6e-d568-4ac5-9d66-0c9c4e3a571b'}

An Aggregate can be marked as abstract at any level of inheritance.

An important point to note is that Aggregates marked abstract do not have an identity.

@domain.aggregate
class TimeStamped(BaseAggregate):
    created_at = DateTime(default=datetime.utcnow)
    updated_at = DateTime(default=datetime.utcnow)

    class Meta:
        abstract=True

In this example, the base Aggregate TimeStamped will not have an automatically generated id field:

>>> TimeStamped.meta_.declared_fields
{'created_at': <protean.core.field.basic.DateTime at 0x1101cce50>,
'updated_at': <protean.core.field.basic.DateTime at 0x1101cc040>}

Abstract Aggregates cannot have an explicit identifier field either:

@domain.aggregate
class User(BaseAggregate):
    email = String(identifier=True)
    name = String(max_length=55)

    class Meta:
        abstract=True

Trying to declare one will throw an IncorrectUsageError exception.

Metadata

Aggregate metadata is available under the meta_ attribute of an aggregate object in runtime, and is made up of two parts:

Meta options

Options that control Aggregate behavior, such as its database provider, the name used to persist the aggregate entity, or if the Aggregate is abstract. These options can be overridden with an inner class Meta, like so:

@domain.aggregate
class Person:
    first_name = String(max_length=30)
    last_name = String(max_length=30)

    class Meta:
        provider = 'nosql'

The overridden attributes are reflected in the meta_ attribute:

>>> Person.meta_.provider
'nosql'

Available options are:

abstract

The flag used to mark an Aggregate as abstract. If abstract, the aggregate class cannot be instantiated and needs to be subclassed. Refer to the section on entity-abstraction for a deeper discussion.

@domain.aggregate
class Person:
    first_name = String(max_length=30)
    last_name = String(max_length=30)

    class Meta:
        abstract = True

Trying to instantiate an abstract Aggregate will throw a NotSupportedError:

>>> p = Person(first_name='John', last_name='Doe')
NotSupportedError                         Traceback (most recent call last)
...
NotSupportedError: Person class has been marked abstract and cannot be instantiated
provider

The database that the aggregate is persisted in.

Aggregates are connected to underlying data stores via providers. The definitions of these providers are supplied within the DATABASES key as part of the Domain’s configuration during initialization. Protean identifies the correct data store, establishes the connection and takes the responsibility of persisting the data.

Protean requires at least one provider, named default, to be specified in the configuration. When no provider is explicitly specified, Aggregate objects are persisted into the default data store.

Configuration:

...
DATABASES = {
    'default': {
        'PROVIDER': 'protean_sqlalchemy.provider.SAProvider'
    },
    "nosql": {
        "PROVIDER": "protean.adapters.repository.elasticsearch.ESProvider",
        "DATABASE": Database.ELASTICSEARCH.value,
        "DATABASE_URI": {"hosts": ["localhost"]},
    },
}
...

You can then connect the provider explicitly to an Aggregate by its provider Meta option:

@domain.aggregate
class Person:
    first_name = String(max_length=30)
    last_name = String(max_length=30)

    class Meta:
        provider = 'nosql'

Refer to user-persistence for an in-depth discussion about persisting to databases.

model

Protean automatically constructs a representation of the aggregate that is compatible with the configured database. While the generated model suits most use cases, you can also explicitly construct a model and associate it with the aggregate.

import sqlalchemy

@domain.aggregate
class Person:
        first_name = String(max_length=30)
        last_name = String(max_length=30)

@domain.model(entity_cls=Person)
class PersonModel:
    name = sqlalchemy.Column(sqlalchemyText)

Note that custom models are associated with a specific database type. The model is used only when database of the right type is active. Refer to aggregate-custom-models for more information.

schema_name

The name to store and retrieve the aggregate from the persistence store. By default, schema_name is the snake case version of the Aggregate’s name.

@domain.aggregate
class UserProfile:
    name = String()

schema_name is available under meta_:

>>> UserProfile.meta_.schema_name
'user_profile'

Reflection

Aggregates are decorated with additional attributes that you can use to examine the aggregate structure in runtime. The following meta attributes are available:

declared_fields

A map of fields explicitly declared in the Aggregate.

>>> @domain.aggregate
... class Person:
...     first_name = String(max_length=30)
...     last_name = String(max_length=30)
...
>>> Person.meta_.declared_fields
{'first_name': <protean.core.field.basic.String at 0x10a647c70>,
'last_name': <protean.core.field.basic.String at 0x10a6476d0>,
'id': <protean.core.field.basic.Auto at 0x10a647340>}
id_field

The identifier field configured for the Entity or Aggregate. A field can be marked as an identifier by setting the identifier=True option.

>>> @domain.aggregate
... class Person:
...     email = String(identifier=True)
...     first_name = String(max_length=30)
...     last_name = String(max_length=30)
...
>>> Person.meta_.id_field
<protean.core.field.basic.String at 0x10b8f67c0>
>>> Person.meta_.id_field.attribute_name
'email'

When not explicitly identified, an identifier field named id of type Auto is added automatically to the Aggregate:

>>> @domain.aggregate
... class Person:
...     first_name = String(max_length=30)
...     last_name = String(max_length=30)
...
>>> Person.meta_.declared_fields
{'first_name': <protean.core.field.basic.String at 0x10a647c70>,
'last_name': <protean.core.field.basic.String at 0x10a6476d0>,
'id': <protean.core.field.basic.Auto at 0x10a647340>}
>>> Person.meta_.id_field
<protean.core.field.basic.Auto at 0x10a647340>
attributes

A map of all fields, including user-aggregate-meta-value-object-fields and user-aggregate-meta-reference-fields fields. These attribute names are used during persistence of Aggregates, unless overridden by referenced_as.

@domain.entity(aggregate_cls="Account")
class Profile:
    email = String(required=True)
    name = String(max_length=50)
    password = String(max_length=50)

@domain.value_object
class Balance:
    currency = String(max_length=3)
    amount = Float()

@domain.aggregate
class Account:
    account_type = String(max_length=25)
    balance =  ValueObject(Balance)
    profile = Reference(Profile)

All fields are available under meta_:

>>> Account.meta_.attributes
{'account_type': <protean.core.field.basic.String at 0x111ff3cd0>,
'balance_currency': <protean.core.field.embedded._ShadowField at 0x111fe9d60>,
'balance_amount': <protean.core.field.embedded._ShadowField at 0x111fe9df0>,
'profile_id': <protean.core.field.association._ReferenceField at 0x111fe9cd0>,
'id': <protean.core.field.basic.Auto at 0x111fe9be0>}
value_object_fields

A map of fields derived from value objects embedded within the Aggregate.

@domain.value_object
class Balance:
    currency = String(max_length=3)
    amount = Float()

@domain.aggregate
class Account:
    account_type = String(max_length=25)
    balance =  ValueObject(Balance)

The fields are now available as part of meta_ attributes:

>>> Account.meta_.value_object_fields
{'balance_currency': <protean.core.field.embedded._ShadowField at 0x106d4d2e0>,
'balance_amount': <protean.core.field.embedded._ShadowField at 0x106d4d310>}
reference_fields

A map of reference fields (a.k.a Foreign keys, if you are familiar with the relational world) embedded within the Aggregate.

@domain.aggregate
class Post:
    content = Text(required=True)
    author = Reference("Author")

@domain.entity(aggregate_cls="Post")
class Author:
    first_name = String(required=True, max_length=25)
    last_name = String(max_length=25)

An attribute named author_id (<Entity Name>_<Identifier>) is automatically generated and attached to the Aggregate:

>>> Post.meta_.reference_fields
{'author_id': <protean.core.field.association._ReferenceField at 0x105c65760>}