Search code examples
entity-framework-coredomain-driven-designabp-framework

Should I enforce referential integrity between my related aggregate roots? If so, where?


This seems like a fundamental question and I have not worked much with DDD. The context of this question is in regards to working with the ABP Framework, its layers, and its generated code.

Should I enforce referential integrity between my related aggregate roots? If so, where?

After using ABP Suite to generate my initial entities, many of which are aggregate roots (AR), I began to implement the navigation properties between them. My initial approach was to modify the constructors of each entity/AR to include the Guid IDs of the dependent entities/ARs.

Original Approach

public Address(Guid id, string name, AddressTypes addressType,
    string line1, string line2, string line3,
    string city, string postalCode, 
    Guid stateId, Guid countryId, // <-- dependent AR IDs
    bool primary = false, bool active = true)
{
    //other properties set
    StateId = stateId;
    CountryId = countryId;
    //etc
}

I got my domain data seed logic working along with my data seed contributors and then I moved on to working on the test data.

Very quickly I realized that I was going to have to create each of the dependent entities/ARs and pass their IDs to the constructors of each entity under test.

That sent me on a search through the documentation for an example or best practice. One thing I came across is this statement from the Entity Best Practices & Conventions page:

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

Okay. So that would seem to suggest that I should have nullable ID properties in my principal/parent AR for each of my dependent/child ARs. Fully managed entities under the AR might be free to have navigation properties, but not the ARs.

Because I want to manage the AR relationships at a higher level, it seems to follow that I should remove the dependent ARs from my constructor or at least make them nullable.

Revised Approach 1

public Address(Guid id, string name, AddressTypes addressType,
    string line1, string line2, string line3, 
    string city, string postalCode, 
    Guid? stateId = null, Guid? countryId = null, // <-- nullable dependent AR IDs
    bool primary = false, bool active = true)
{
    //other properties set
    StateId = stateId;
    CountryId = countryId;
    //etc
}

Revised Approach 2

public Address(Guid id, string name, AddressTypes addressType,
    string line1, string line2, string line3, 
    string city, string postalCode, 
    bool primary = false, bool active = true)
{
    //other properties set, not setting dependent IDs
}

Obviously Revised Approach 2 would require the application service to set the dependent ID properties after constructing the object:

Address address = null;
address = await _addressRepository.InsertAsync(new Address
(
    id: _guidGenerator.Create(),
    name: "DEMO Contact Home Address",
    addressType: AddressTypes.Home,
    line1: "123 Main St",
    line2: "",
    line3: "",
    city: "Anytown",
    postalCode: "00000",
    primary: true,
    active: true
), autoSave: true);
address.StateId = alState.Id; // set here
address.CountryId = usId; // and here

Am I on track so far?

So, if I want to enforce referential integrity, I should not do that through Entity Framework (or the ORM of choice). I should enforce referential integrity at the Application Service layer instead.

[Authorize(MyProductPermissions.Addresses.Create)]
public virtual async Task<AddressDto> CreateAsync(AddressCreateDto input)
{
    if (input.StateId == default)
    {
        throw new UserFriendlyException(L["The {0} field is required.", L["State"]]);
    }
    if (input.CountryId == default)
    {
        throw new UserFriendlyException(L["The {0} field is required.", L["Country"]]);
    }

    var address = ObjectMapper.Map<AddressCreateDto, Address>(input);
    address.TenantId = CurrentTenant.Id;
    
    // BEGIN Referential Integrity Logic 

    // Assumes that the dependent IDs will be set when the DTO is mapped to the entity
    if (address.StateId == null)
    {
        // log and throw 500 error
    }
    if (address.CountryId == null)
    {
        // log and throw 500 error
    }

    // END Referential Integrity Logic
    
    address = await _addressRepository.InsertAsync(address, autoSave: true);
    return ObjectMapper.Map<Address, AddressDto>(address);
}

Is this a correct understanding?

If my dependent IDs are nullable, I can continue to use the ABP Suite generated test code.

await _addressRepository.InsertAsync(new Address
(
    Guid.Parse("ca846f1a-8bbd-4e2c-afbd-8e40a03ae18f"),
    "7d7b348e410d48ee89e1807beb2f2ac0bd66af4ea82943ec8eee3a52962577b1",
    default,
    "de5ec0226aba4c1a837c9716b21af6551d10436756724d4fa507028eaaddcdadec779bea0ef04922992f9d2432068b180e6fe95f425f47c68559c1dbd4360fdb",
    "53bc12edeb4544158147f3b835b0c4ce5e581844f5c248d69647d80d398706f5ee1c769e4ee14bd0a1e776a369a96ea3c0582b659ce342bdbdf40e6668f3b9f9",
    "117880188dfd4a6f96892fea3e62a16f057748ebe76b4dd0a4402918e2fee9055272ff81c53d4c28825cc20d01918386864efd54e1aa458bb449a1d12b349d40",
    "866a81007219411a971be2133bf4b5882d4ef612722a45ac91420e0b30d774ed",
    "93bba338449444f5",
    true,
    true
));

If they are not, I will have to augment the test code to create at least one dependent entity for each dependent entity type associated with my AR.

// Create dependent State entity
var alState = //...

// Create dependent Country entity
var country = //...

Address address = null;
address = await _addressRepository.InsertAsync(new Address
(
    Guid.Parse("ca846f1a-8bbd-4e2c-afbd-8e40a03ae18f"),
    "7d7b348e410d48ee89e1807beb2f2ac0bd66af4ea82943ec8eee3a52962577b1",
    default,
    "de5ec0226aba4c1a837c9716b21af6551d10436756724d4fa507028eaaddcdadec779bea0ef04922992f9d2432068b180e6fe95f425f47c68559c1dbd4360fdb",
    "53bc12edeb4544158147f3b835b0c4ce5e581844f5c248d69647d80d398706f5ee1c769e4ee14bd0a1e776a369a96ea3c0582b659ce342bdbdf40e6668f3b9f9",
    "117880188dfd4a6f96892fea3e62a16f057748ebe76b4dd0a4402918e2fee9055272ff81c53d4c28825cc20d01918386864efd54e1aa458bb449a1d12b349d40",
    "866a81007219411a971be2133bf4b5882d4ef612722a45ac91420e0b30d774ed",
    "93bba338449444f5",
    true,
    true
));
address.StateId = state.Id;
address.CountryId = country.Id;

That can become a lot of objects in my hierarchy which has about 30 entities/ARs as it stands currently. This is exacerbated by multilevel dependencies.

Please help me understand the best practice in a DDD world. I need to get this right before I move into implementing 30 some-odd constructors and application services.


Solution

  • If your Address entity must be created with specified StateId and CountryId you need to use the original approach and force to set their value while object creation. Because, an Aggregate Root is responsible to preserve its own integrity. (See the related documentation for more info)

    • I guess you also asking what will happen if the StateId does not exist in your database and if it is a just simple GUID. In such a case, if you've set your StateId as a foreign key it won't be added to your database. But if you want to query it in any way and throw an exception if it does not exist, you can create a Domain Service and check if there is a state with the given stateId and if it exists pass it to the Address constructor (if not throw an exception) and create a new address record in your database.
    public class AddressManager : DomainService 
    {
       private readonly IAddressRepository _addressRepository;
       private readonly IStateRepository _stateRepository;
       private readonly ICountryRepository _countryRepository;
    
       public AddressManager(IAddressRepository addressRepository, IStateRepository stateRepository, ICountryRepository countryRepository)
       {
          _addressRepository = addressRepository;
          _stateRepository = stateRepository;
          _countryRepository = countryRepository;
       }
    
    
       public async Task CreateAsync(string name, AddressTypes addressType,
        string line1, string line2, string line3,
        string city, string postalCode, 
        Guid stateId, Guid countryId)
       {
          if(await _stateRepository.FindAsync(stateId))
          {
             //throw exception
             return;
          }
    
          if(await _countryRepository.FindAsync(stateId))
          {
             //throw exception
             return;
          }
    
          var address = new Address(GuidGenerator.Create(), AddressTypes.Typee, "line1", "line2", "line3", "city", "postalCode", stateId, countryId);
    
          await _addressRepository.InsertAsync(address);
       }
    }
    
    

    And when creating a new address call AddressManager's CreateAsync method in your app service. (You may want to set your Address entity constructor to internal instead of public to prevent creating an Address object by mistake in the application layer.)