Domain Driven Design with Web API revisited Part 7: the aggregate root in our domain model
August 27, 2015 Leave a comment
Introduction
In the previous post we discussed the concept of associations and standalone classes. We saw why it was important to keep the coupling between domain objects at a minimum. We also built the central object in our load test domain, Loadtest.cs.
In this post we’ll take a step upwards and discuss the root of the load test aggregate.
However, in order to understand the purpose of our aggregate root we need to first repeat what we said about services in the original DDD series here.
Services
Services can come in several different forms in an application. Services are not associated with things, but rather actions and activities. Services are the “doers”, the manager classes and may span multiple domain objects. They define what they can DO for a client.
Services can come in various forms and we’ll revisit them later in this series. For the time being let’s just look at domain services:
Normally logic pertaining to a single domain should be encapsulated within the body of that domain. If the domain object needs other domain objects to perform some logic then you can send in those as parameters. E.g. to determine whether a Person domain can loan a certain book you can have the following method in the Person class:
private bool CanLoanBook(Book book) { if (!this.IsLateWithOtherBooks && !book.OnLoan) return true; return false; }
This is a very small and uncomplicated piece of code.
However, this is not always the case. It may be so that this logic becomes so involved that putting it in one domain object would make that domain bloated with logic that spans multiple other domains. Example: a money transfer may involve domain objects such as Bank, Account, Customer, Transfer and possibly more. The money may only be transferred if there’s enough deposit on one Account, if the Customer has preferred status, if the Bank is not located in a money-laundering paradise etc. Where should this validation logic go? Probably the best solution is to build a specially designated IMoneyTransferService interface with a method called MakeTransfer(params).
Domain services help you avoid bloating the logic of one domain object. Just like other service types, they come in the form of interfaces and implementations of those interfaces. They will have operations that do not fit the domain object it is acting on. However, be careful not to transfer all business rules of the domain to a service. These services will be part of the domain layer.
Domain service in our load testing domain
When inserting a new or updating an existing load test we have to go through the verification steps we mentioned before:
- A load test cannot start without a load test agent. Each agent can only perform 2 load tests at a time, i.e. two load tests can overlap, but not 3.
- If the customer requires an engineer then this engineer must be available for the duration of the load test
- A load test cannot start without a valid scenario which includes at least one URL to be tested
All those rules imply that we’ll need to know about a range of other load tests when inserting a new one. E.g. we’ll need to verify that there’s no double-booking of an engineer and we can only do that if we check the properties of the other load tests that should be executed in the same time period. Where do put that kind of logic? Also, how do we ensure that a caller doesn’t just bypass the validation steps? After all we can prepare some method called “RaiseExceptionIfInvalid” which checks whether all rules are enforced but it’s not guaranteed that this method will be called. We’ll end up with unwanted entries in the database that may be difficult to fix later on.
Therefore we need to design the validation in a way that it simply cannot be bypassed so easily. Sure, a programmer can always write some other class which directly contacts the database and inserts new records without any control. However, we should indicate through the public API of the domain layer that this is not intended. Also, this validation logic should only appear in one place, preferably in the domain layer.
There are certainly several ways to solve this. In our case we’ll go with the object that we mentioned before called timetable. It will act as an umbrella for all load tests. We can consider it as the mother object for all load tests. We can also look at it as a domain service for load test related operations. We won’t save it in the data store, I don’t see any point in doing that. There will only be a single timetable. Therefore it will lack any identity field. However, as it is the mother of all load tests it can act as the aggregate root for the load testing domain. Also, each load test object will need a reference to it. If we force the caller to include a valid Timetable object when building a Loadtest object we can make sure that all other relevant load tests will also be loaded before validating the entry or update of a new load test.
These things will become much clearer later on when we start building the repository layer for the domain.
Insert the following classes to the Domain layer:
public class Timetable : IAggregateRoot { public IList<Loadtest> Loadtests { get; private set; } public Timetable(IList<Loadtest> loadtests) { if (loadtests == null) loadtests = new List<Loadtest>(); Loadtests = loadtests; } public AddOrUpdateLoadtestsValidationResult AddOrUpdateLoadtests(IList<Loadtest> loadtestsAddedOrUpdated) { List<Loadtest> toBeInserted = new List<Loadtest>(); List<Loadtest> toBeUpdated = new List<Loadtest>(); List<Loadtest> failed = new List<Loadtest>(); StringBuilder resultSummaryBuilder = new StringBuilder(); string NL = Environment.NewLine; foreach (Loadtest loadtest in loadtestsAddedOrUpdated) { Loadtest existing = (from l in Loadtests where l.Id == loadtest.Id select l).FirstOrDefault(); if (existing != null) //update { LoadtestValidationSummary validationSummary = OkToAddOrModify(loadtest); if (validationSummary.OkToAddOrModify) { existing.Update (loadtest.Parameters, loadtest.AgentId, loadtest.CustomerId, loadtest.EngineerId, loadtest.LoadtestTypeId, loadtest.ProjectId, loadtest.ScenarioId); toBeUpdated.Add(existing); resultSummaryBuilder.Append(string.Format("Load test ID {0} (update) successfully validated.{1}", existing.Id, NL)); } else { failed.Add(loadtest); resultSummaryBuilder.Append(string.Format("Load test ID {0} (update) validation failed: {1}{2}.", existing.Id, validationSummary.ReasonForValidationFailure, NL)); } } else //insertion { LoadtestValidationSummary validationSummary = OkToAddOrModify(loadtest); if (validationSummary.OkToAddOrModify) { Loadtests.Add(loadtest); toBeInserted.Add(loadtest); resultSummaryBuilder.Append(string.Format("Load test ID {0} (insertion) successfully validated.{1}", loadtest.Id, NL)); } else { failed.Add(loadtest); resultSummaryBuilder.Append(string.Format("Load test ID {0} (insertion) validation failed: {1}{2}.", loadtest.Id, validationSummary.ReasonForValidationFailure, NL)); } } } return new AddOrUpdateLoadtestsValidationResult(toBeInserted, toBeUpdated, failed, resultSummaryBuilder.ToString()); } private LoadtestValidationSummary OkToAddOrModify(Loadtest loadtest) { LoadtestValidationSummary validationSummary = new LoadtestValidationSummary(); validationSummary.OkToAddOrModify = true; validationSummary.ReasonForValidationFailure = string.Empty; List<Loadtest> loadtestsOnSameAgent = (from l in Loadtests where l.AgentId == loadtest.AgentId && DatesOverlap(l, loadtest) select l).ToList(); if (loadtestsOnSameAgent.Count >= 2) { validationSummary.OkToAddOrModify = false; validationSummary.ReasonForValidationFailure += " The selected load test agent is already booked for this period. "; } if (loadtest.EngineerId.HasValue) { List<Loadtest> loadtestsOnSameEngineer = (from l in Loadtests where loadtest.EngineerId.HasValue && l.EngineerId.Value == loadtest.EngineerId.Value && DatesOverlap(l, loadtest) select l).ToList(); if (loadtestsOnSameEngineer.Any()) { validationSummary.OkToAddOrModify = false; validationSummary.ReasonForValidationFailure += " The selected load test engineer is already booked for this period. "; } } return validationSummary; } private bool DatesOverlap(Loadtest loadtestOne, Loadtest loadtestTwo) { return (loadtestOne.Parameters.StartDateUtc < loadtestTwo.Parameters.GetEndDateUtc() && loadtestTwo.Parameters.StartDateUtc < loadtestOne.Parameters.GetEndDateUtc()); } }
…where AddOrUpdateLoadtestsValidationResult and LoadtestValidationSummary are simple container objects:
public class AddOrUpdateLoadtestsValidationResult { public List<Loadtest> ToBeInserted { get; private set; } public List<Loadtest> ToBeUpdated { get; private set; } public List<Loadtest> Failed { get; private set; } public string OperationResultSummary { get; private set; } public bool ValidationComplete { get; private set; } public AddOrUpdateLoadtestsValidationResult(List<Loadtest> toBeInserted, List<Loadtest> toBeUpdated, List<Loadtest> failed, string operationResultSummary) { ToBeInserted = toBeInserted; ToBeUpdated = toBeUpdated; Failed = failed; OperationResultSummary = operationResultSummary; ValidationComplete = (toBeInserted != null && toBeUpdated != null && failed != null && !string.IsNullOrEmpty(operationResultSummary)); } }
public class LoadtestValidationSummary { public bool OkToAddOrModify { get; set; } public string ReasonForValidationFailure { get; set; } }
There’s quite a lot going on here. Let’s go through the elements step by step.
LoadtestValidationSummary.cs: this is a simple class where we can store whether it’s OK to insert or update a given load test. The ReasonForValidationFailure property will store the reason for failure. You can see some examples in the code, like ” The selected load test agent is already booked for this period. “.
The OkToAddOrModify method accepts a Loadtest object and return an object of type LoadtestValidationSummary. It checks two rules that we laid down before: first, whether there’s enough capacity on the selected load test agent and second, whether the selected engineer is free to take on the load test. This second condition is only checked if an engineer ID has been provided. The DatesOverlap helper method checks whether the start and end dates of two load tests overlap. If the OkToAddOrModify method finds at least 2 load tests booked for the same agent then the new load test cannot be inserted or updated to the new date. Similarly if the selected engineer is booked then the load test is rejected.
The AddOrUpdateLoadtestsValidationResult is also a container class for multiple useful properties. It holds three objects of type List of Loadtest: one for insertions, one for updates and one for failures. It also contains a property that describes the result of the validation process in clear text. The ValidationComplete property is set to true in the constructor if all the supplied objects in the constructor are valid. We’ll see the role of this property later on when we’re ready to implement the concrete timetable repository. Basically it is meant to serve as a flag to indicate to the repository whether the caller has followed the process of calling the AddOrUpdateLoadtests method before calling the repository. We’ll also see that this is not a perfect check but at least it’s not that easy to override the ValidationComplete property through a public setter.
Finally, the “mother” of the process is the AddOrUpdateLoadtests method which accepts a list of load test objects and returns an AddOrUpdateLoadtestsValidationResult object. It loops through the supplied load test list and validates the insertions and updates. If the Loadtests property includes a load test with a given ID then it’s assumed to be an update, otherwise we’re dealing with an insertion. The method calls OkToAddOrModify for every iteration and adds the load test object to the appropriate list of the AddOrUpdateLoadtestsValidationResult object. We’ll reuse the AddOrUpdateLoadtestsValidationResult in the timetable repository.
We’ll soon see all of this in action in a simple C# console application.
We’re done with our domain layer for the time being. We may need to come back later for modifications. In the next post we’ll start building the repository for our load tests.
View the list of posts on Architecture and Patterns here.