Skip to content

Entities

Every aggregate root is also an entity. So, these rules are valid for aggregate roots too unless aggregate root rules override them.

  • Do define entities in the domain layer.

Primary Constructor

  • Do define a primary constructor that ensures the validity of the entity on creation. Primary constructors are used to create a new instance of the entity by the application code.

  • Do define primary constructor as public, internal or protected internal based on the requirements. If it's not public, the entity is expected to be created by a domain service.

  • Do always initialize sub collections in the primary constructor.
  • Do not generate Guid keys inside the constructor. Get it as a parameter, so the calling code will use IGuidGenerator to generate a new Guid value.

Parameterless Constructor

  • Do always define a protected parameterless constructor to be compatible with ORMs.

References

  • Do always reference to other aggregate roots by Id. Never add navigation properties to other aggregate roots.

Other Class Members

  • Do always define properties and methods as virtual (except private methods, obviously). Because some ORMs and dynamic proxy tools require it.
  • Do keep the entity as always valid and consistent within its own boundary.
    • Do define properties with private, protected, internal or protected internal setter where it is needed to protect the entity consistency and validity.
    • Do define public, internal or protected internal (virtual) methods to change the properties (with non-public setters) if necessary.
    • Do return the entity object (this) from the setter methods.
    • Do list filter elements like TenantId, OwnerId which has default values as last item
    • Do create static validators whenever is needed in order keep entity valid

Aggregate Roots

An aggregate root is a concept in Domain-Driven Design (DDD) that represents the primary entity within an aggregate. It serves as the entry point for accessing and modifying the aggregate’s internal components. The aggregate root is responsible for ensuring the consistency and integrity of the aggregate as a whole.

aggregate-root

In an aggregate, there may be multiple entities and value objects that are related and dependent on the aggregate root. One of the key characteristics of an aggregate root is that it is the only object within the aggregate that can be accessed directly from outside the aggregate.

The aggregate root also defines the transactional boundary for the aggregate. All modifications and operations within the aggregate are treated as a single unit of work. Changes made within the aggregate are persisted or discarded together, ensuring transactional integrity and maintaining data consistency.

Primary Keys

  • Do always use a Id property for the aggregate root key.
  • Do not use composite keys for aggregate roots.
  • Do use Guid as the primary key of all aggregate roots.

Base Class

  • Do inherit from the AggregateRoot<TKey> or one of the audited classes (CreationAuditedAggregateRoot<TKey>, AuditedAggregateRoot<TKey> or FullAuditedAggregateRoot<TKey>) based on requirements.

Aggregate Boundary

  • Do keep aggregates as small as possible. Most of the aggregates will only have primitive properties and will not have sub collections. Consider these as design decisions:
    • Performance & memory cost of loading & saving aggregates (keep in mind that an aggregate is normally loaded & saved as a single unit). Larger aggregates will consume more CPU & memory.
    • Consistency & validity boundary.

Example

Aggregate Root

using JetBrains.Annotations;
using GridLab.PSSX.Elsa.States;
using GridLab.PSSX.ProjectPlanning.States;
using Stateless;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using Volo.Abp.Domain.Entities.Auditing;
using Volo.Abp.MultiTenancy;

namespace GridLab.PSSX.ProjectPlanning.Projects
{
    /* An aggregate root has responsibility for it's sub entities too. So, the aggregate root must always be in a valid state.
     * An aggregate root is treated as a single unit, with all the sub-collections and properties. It's retrieved and updated as a single unit. It's generally considered as a transaction boundary.
     * An aggregate root can be referenced by it's Id. Do not reference it by it's navigation property.
     * Work with sub-entities over the aggregate root. Do not modify them independently.
     */

    /* The project object defines what core attributes an electrical network project will have.
     */
    public class Project : AuditedAggregateRoot<Guid>, IMultiTenant, IRequireOrganizationUnitOrUserAcessControl
    {
        public virtual string Name { get; protected set; } // Should able set internally after project creation

        public virtual string Description { get; protected set; } // Should able set internally after project creation

        public virtual Guid BaseModelId { get; protected set; } // Navigation property for "Base Model" Aggreate Root, 

        public virtual Guid DefinitionId { get; protected set; } // Navigation property for "Workflow Definiton" of the Elsa Workflows, 

        public virtual Guid InstanceId { get; protected set; } // Navigation property for "Workflow Instance" of the Elsa Workflows, 

        public virtual Priority Priority { get; protected set; } = Priority.Medium;

        public virtual string CurrentState { get; protected set; } = string.Empty;

        public virtual int Version { get; protected set; } = 1;

        public virtual bool IsClosed { get; protected set; } = false;

        public virtual DateTime StartDate { get; protected set; }

        public virtual DateTime? EndDate { get; protected set; }

        public virtual DateTime? CommissionDate { get; protected set; }

        public virtual DateTime? InServiceDate { get; protected set; }

        [NotMapped]
        private StateMachine<string, string> _machine { get { return ConfigureStateMachine(); } }

        public virtual IReadOnlyList<EdgeDefinition> Edges { get { return _edges; } } // Owns Many

        public virtual IReadOnlyList<Guid> SuccessorIds { get { return _successorIds; } }

        public virtual IReadOnlyList<Guid> PredecessorIds { get { return _predecessorIds; } }

        public virtual Guid? RequiredChangesId { get; protected set; } // Navigation property for "Required Changes" Aggreate Root

        public virtual Guid? OwnerId { get; protected set; } // Access control property, OwnerId has no difference between LastModifierId or CreatorId. Can be null

        public virtual Guid? OrganizationUnitId { get; protected set; } // Access control property

        public virtual Guid? TenantId { get; protected set; }

        private List<EdgeDefinition> _edges;

        private readonly List<Guid> _successorIds;

        private readonly List<Guid> _predecessorIds;

        // ORM Constructor
        protected Project()
        {
            /* This constructor is for ORMs to be used while getting the entity from database.
             * No need to initialize any collection 
             * It is protected since proxying and deserialization tools may not work with private constructors
             */
        }

        // Primary Constructor
        public Project(
            Guid id,
            [NotNull] string name,
            string description,
            Guid baseModelId,
            [NotNull]List<EdgeDefinition> edges,
            Priority priority,
            DateTime startDate,
            DateTime? endDate = null,
            DateTime? commissionDate = null,
            DateTime? inServiceDate = null,
            Guid? requiredChangesId = null,
            Guid? ownerId = null,
            Guid? organizationUnitId = null,
            Guid? tenantId = null)
                : base(id)
        {
            Id = id;
            TenantId = tenantId;

            SetName(name);
            SetDescription(description);
            SetBaseModelId(baseModelId);
            SetPriority(priority);
            SetStartDate(startDate);
            SetEndDate(endDate);
            SetCommissionDate(commissionDate);
            SetInServiceDate(inServiceDate);
            SetRequiredChangesId(requiredChangesId);
            SetOwnerId(ownerId);
            SetOrganizationUnitId(organizationUnitId);
            SetInitialState(edges);

            _successorIds = new List<Guid>();
            _predecessorIds = new List<Guid>();
        }

        public virtual void SetName([NotNull] string name)
        {
            Name = ProjectValidator.CheckName(name);
        }

        public virtual void SetDescription([NotNull] string description)
        {
            Description = ProjectValidator.CheckDescription(description);
        }

        public virtual void SetBaseModelId(Guid baseModelId)
        {
            BaseModelId = ProjectValidator.CheckEmptyGuid(baseModelId);
        }

        public virtual void SetDefinitionId(Guid definitionId)
        {
            DefinitionId = ProjectValidator.CheckEmptyGuid(definitionId);
        }

        public virtual void SetInstanceId(Guid instanceId)
        {
            InstanceId = ProjectValidator.CheckEmptyGuid(instanceId);
        }

        public virtual void SetPriority(Priority priority)
        {
            Priority = ProjectValidator.CheckPriority(priority);
        }

        public virtual void SetVersion(int version)
        {
            if (Version > version)
            {
                throw new InvalidVersionException();
            }

            Version = version;
        }

        public virtual bool Close()
        {
            IsClosed = true;

            return IsClosed;
        }

        public virtual void SetStartDate(DateTime startDate)
        {
            if (EndDate.HasValue)
            {
                int result = DateTime.Compare(startDate, (DateTime)EndDate);
                if (result == 0 | result > 0)
                {
                    throw new InvalidDateTimeException();
                }
            }

            StartDate = startDate;
        }

        public virtual void SetEndDate(DateTime? endDate)
        {
            if (endDate.HasValue)
            {
                int result = DateTime.Compare((DateTime)endDate, StartDate);
                if (result == 0 | result < 0)
                {
                    throw new InvalidDateTimeException();
                }
            }

            EndDate = endDate;
        }

        public virtual void SetCommissionDate(DateTime? commissionDate)
        {
            if (commissionDate.HasValue)
            {
                int result = 0;

                if (InServiceDate.HasValue)
                {
                    result = DateTime.Compare((DateTime)commissionDate, (DateTime)InServiceDate);
                    if (result > 0)
                    {
                        throw new InvalidDateTimeException();
                    }
                }

                result = DateTime.Compare((DateTime)commissionDate, StartDate);
                if (result == 0 | result < 0)
                {
                    throw new InvalidDateTimeException();
                }
            }

            CommissionDate = commissionDate;
        }

        public virtual void SetInServiceDate(DateTime? inserviceDate)
        {
            if (inserviceDate.HasValue)
            {
                int result = 0;

                if (CommissionDate.HasValue == true)
                {
                    result = DateTime.Compare((DateTime)inserviceDate, (DateTime)CommissionDate);
                    if (result < 0)
                    {
                        throw new InvalidDateTimeException();
                    }
                }

                result = DateTime.Compare((DateTime)inserviceDate, StartDate);
                if (result == 0 | result < 0)
                {
                    throw new InvalidDateTimeException();
                }
            }

            InServiceDate = inserviceDate;
        }

        public virtual void SetRequiredChangesId(Guid? requiredChangesId)
        {
            if (requiredChangesId.HasValue)
            {
                ProjectValidator.CheckEmptyGuid(requiredChangesId.Value);
            }

            RequiredChangesId = requiredChangesId;
        }

        public virtual void SetOwnerId(Guid? ownerId)
        {
            if (ownerId.HasValue)
            {
                ProjectValidator.CheckEmptyGuid(ownerId.Value);
            }

            OwnerId = ownerId;
        }

        public virtual void SetOrganizationUnitId(Guid? organizationUnitId)
        {
            if (organizationUnitId.HasValue)
            {
                ProjectValidator.CheckEmptyGuid(organizationUnitId.Value);
            }

            OrganizationUnitId = organizationUnitId;
        }

        public virtual void AddSuccessorRecord(Guid? successorId)
        {
            if (successorId.HasValue)
            {
                ProjectValidator.CheckEmptyGuid(successorId.Value);
            }

            _successorIds.AddIfNotContains(successorId.Value);
        }

        public virtual void AddPredecessorRecord(Guid? predecessorId)
        {
            if (predecessorId.HasValue)
            {
                ProjectValidator.CheckEmptyGuid(predecessorId.Value);
            }

            _predecessorIds.AddIfNotContains(predecessorId.Value);
        }

        private StateMachine<string, string> ConfigureStateMachine()
        {
            // Create machine according current state of the project
            var stateMachine = new StateMachine<string, string>(() => CurrentState, s => CurrentState = s);

            foreach (EdgeDefinition edge in _edges)
            {
                stateMachine.Configure(edge.Source)
                    .Permit(edge.Transition, edge.Target);
            }

            stateMachine.OnUnhandledTrigger((state, trigger) => OnUnhandledTransition(state, trigger));

            return stateMachine;
        }

        private void OnUnhandledTransition(string state, string trigger)
        {
            throw new InvalidTransitionException();
        }

        private void SetInitialState([NotNull]List<EdgeDefinition> edges)
        {
            _edges = edges;

            CurrentState = _edges.FirstOrDefault().Source;

            ConfigureStateMachine();
        }

        public virtual void ChangeState(string transition)
        {
            if (IsClosed)
                throw new ProjectAlreadyClosedException();

            var transitions = _machine.PermittedTriggers;

            if (!transitions.Contains(transition))
                throw new InvalidTransitionException();

            _machine.Fire(transition);
        }

        public virtual bool CanChangeState(string transition)
        {
            return _machine.CanFire(transition);
        }

        public virtual void ForceState(string state)
        {
            var states = GetStates();

            if (!states.Contains(state))
                throw new InvalidStateException();

            CurrentState = state;
        }

        public virtual List<string> GetStates()
        {
            var stateMachineInfo = _machine.GetInfo();

            // We don't get the name of the empty vertex since it is not drawn by the user as State activity.
            var states = stateMachineInfo.States.Where(x => x.UnderlyingState.ToString() != StateConsts.EmptyVertexName).ToList();

            List<string> _states = new List<string>();

            foreach (var state in states)
            {
                _states.Add(state.UnderlyingState.ToString());
            }

            return _states;
        }

        public virtual List<string> GetPossibleTransitions(string state)
        {
            var stateMachineInfo = _machine.GetInfo();

            var stateInfo = stateMachineInfo.States.FirstOrDefault(x => x.UnderlyingState.ToString() == state);

            if (stateInfo == null)
            {
                throw new StateDoesNotExistException();
            }

            var fixedTransitions = stateInfo.FixedTransitions.ToList();

            List<string> _possibleTransactions = new List<string>();

            foreach (var transition in fixedTransitions)
            {
                _possibleTransactions.Add(transition.Trigger.UnderlyingTrigger.ToString());
            }

            return _possibleTransactions;
        }

        public virtual List<string> GetNextPossibleStates(string state)
        {
            var stateMachineInfo = _machine.GetInfo();

            var stateInfo = stateMachineInfo.States.FirstOrDefault(x => x.UnderlyingState.ToString() == state);

            if (stateInfo == null)
            {
                throw new StateDoesNotExistException();
            }

            List<string> _nextPossibleStates = new List<string>();

            var fixedTransitions = stateInfo.FixedTransitions
                // We don't get the name of the empty vertex since it is not drawn by the user as State activity.
                .Where(x => x.DestinationState.UnderlyingState.ToString() != StateConsts.EmptyVertexName)
                .Select(x => x.DestinationState)
                .ToList();

            foreach (var transition in fixedTransitions)
            {
                _nextPossibleStates.Add(transition.UnderlyingState.ToString());
            }

            return _nextPossibleStates;
        }
    }
}

How to Design Multi-Lingual Entity

If you want to open up to the global market these days, end-to-end localization is a must. ABP provides an already established infrastructure for static texts. However, this may not be sufficient for many applications. You may need to fully customize your app for a particular language and region.

See How to Design Multi-Lingual Entity?

References