Domain model purity and the current time
I’m continuing the topic of domain model purity. This time, we’ll look at it with regards to getting the current date and time.
By the way, be sure to subscribe to my email list. Not all discussions fit the format of a blog post (including some shorter takes on the topic of domain model purity vs completeness). I send those out as emails instead.
Time as an ambient context
I received a couple of interesting questions to the previous post that I thought I would address with this article.
Here’s the first one:
I have some thoughts about abstracting time and what the best solution would be -–
IDateTimeService
or maybe Ambient Context. Does addingIDateTimeService
as a dependency make the service impure?
Let’s first discuss what an Ambient Context is. In the context of time, the ambient context would be a custom class that you’d use instead of the framework’s built-in DateTime.Now
:
public static class DateTimeServer
{
private static Func<DateTime> _func;
public static DateTime Now => _func(); public static void Init(Func<DateTime> func)
{
_func = func;
}
}// Initialization code for production
DateTimeServer.Init(() => DateTime.Now);// Initialization code for unit tests
DateTimeServer.Init(() => new DateTime(2020, 1, 1));
With this implementation, you can refer to the current time as DateTimeServer.Now
.
The use of an ambient context is an anti-pattern. There are several issues at hand here:
- This implementation pollutes the production code: it introduces production code that’s only needed for testing. Mixing up test and production code increases the maintenance costs of the latter.
- The static field becomes a shared dependency, thus transitioning the tests using it into the sphere of integration testing because they can no longer run in parallel.
- The Ambient Context pattern in general and this implementation in particular masks potential problems in code. If injecting a dependency explicitly becomes so inconvenient that you have to resort to ambient context, that’s a certain sign of trouble. Most likely, you have too many layers of indirection.
Time as an explicit dependency
A better approach is to inject the time dependency explicitly, either as a service or as a plain value:
public interface IDateTimeServer
{
DateTime Now { get; }
}
public class DateTimeServer : IDateTimeServer
{
public DateTime Now => DateTime.Now;
}
public class MyController
{
private readonly IDateTimeServer _dateTimeServer;
public MyController(
IDateTimeServer dateTimeServer) // Injecting time as a service
{
_dateTimeServer = dateTimeServer;
}
public void MyAction(int id)
{
MyDomainClass entity = GetById(id);
entity.DoAction(_dateTimeServer.Now); // Injecting time as a plain value
Save(entity);
}
}
Now, to the second part of the question:
Does adding
IDateTimeService
as a dependency make the service impure?
In the previous article, I defined a pure domain model as a model that doesn’t reach out to out-of-process dependencies. According to this definition, injecting IDateTimeService
doesn't make the service impure because the IDateTimeService
doesn't call out-of-process dependencies to get the current time.
But that’s just because this definition is rather incomplete. A more strict definition (and the correct one) of domain model purity is lack of any hidden inputs and outputs other than the state of the domain model itself.
The call to _dateTimeServer.Now
is an example of such a hidden input - the Now
property is a proxy to data that is not yet in memory. (An example of a hidden output is a side effect, for instance the Save(entity)
call in the listing above.)
Notice that this definition of purity is not as strict as in functional programming, where you should get rid of any hidden inputs and outputs, including referring to and changing of the state of domain objects. You don’t have to be that strict, though, especially when using a programming language that doesn’t support immutability as a language feature.
By this updated definition, injecting an IDateTimeService
does make a class impure. That's why you should avoid injecting it into the domain layer.
How to get the current time in the domain layer, then? Inject it as a plain value instead.
A good rule of thumb here is this: inject the time as a service at the start of a business operation and then pass it as a value in the remainder of that operation. This will help you to keep both domain model purity and testability intact. You can see this approach in the above listing: the controller accepts IDateTimeServer
(the service) but then passes DateTime
(the value) to the domain class.
A more complex example
The second question is a more complex example regarding this guideline:
Problem: I’d like to have my validation in the domain layer. Some date/time validations become a lot easier to test with an injected
IDateTimeServer
of some sort. I shouldn’t want to injectIDateTimeServer
into an entity class.So how should I do it?
A somewhat contrived example below. Two properties on entity,
StartDate
andEndDate
.StartDate
can only be set to dates not in the past.EndDate
can only be set five days after the ServiceAgreement has started.Specifically, I can’t set
StartDate
in the past — but having passed theStartDate
by five days is required for us to set theEndDate
.
public class ServiceAgreementEntity
{
public DateTime StartDate { get; }
public DateTime EndDate { get; private set; } public ServiceAgreementEntity(DateTime startDate)
{
if (startDate < DateTime.Today)
throw new ArgumentException(); StartDate = startDate;
} public void SetEndDate(DateTime endDate)
{
if (StartDate > DateTime.Today.AddDays(-5))
throw new ArgumentException(); EndDate = endDate;
}
}
That’s an interesting example because it represents a dilemma:
- On the one hand, you can’t set a past date to the
StartDate
property - But on the other, a past
StartDate
is required in order to test theSetEndDate
method
To overcome this issue, you need to pass not only the start and end dates, but also the current date and time as a plain value, which the domain class can use for validation:
public class ServiceAgreementEntity
{
public DateTime StartDate { get; }
public DateTime EndDate { get; private set; } public ServiceAgreementEntity(DateTime startDate, DateTime now)
{
if (startDate < now.Date)
throw new ArgumentException(); StartDate = startDate;
} public void SetEndDate(DateTime endDate, DateTime now)
{
if (StartDate > now.Date.AddDays(-5))
throw new ArgumentException(); EndDate = endDate;
}
}
This will simplify testing as well, since you will have full control over what the entity will deem as the current date and time.
Summary
- Representing the current time as an ambient context pollutes the production code and makes testing more difficult.
- Inject time as an explicit dependency — either as a service or as a plain value.
- Inject it as a service into controllers.
- Inject it as a plain value in domain classes.
Subscribe
Subscribe to read more articles like this: https://enterprisecraftsmanship.com/subscribe
Originally published at https://enterprisecraftsmanship.com.