Ensure entities are always tracked for navigation properties operations by implementing a custom LazyLoadingIntercepter.
Ensure entities are always tracked for navigation properties operations by implementing a custom LazyLoadingIntercepter.
When designing a clean DDD solution, many times you need to pollute your domain with infrastructure code in order to get Entity Framework (EF) working. This blog series will look at some of these issues and how to resolve them.
Sometimes you need to delete or replace a navigation property of an entity. Even with lazy loading enabled the entity to replace or delete might not be tracked by the context while modifying the navigation property. This could lead to undesired behaviour (see Replacing One-To-One Entity does not delete old entity and throws unique constraint). There are workarounds to circumvent this, like eager loading the property in all EF queries. What we sometimes do is to make an explicit call to the navigation property getter so that lazy loading will kick in:
public class Car
{
public CarHolder? CarHolder { get; protected set; }
public void TrashCar()
{
// This triggers lazy loading and will ensure that CarHolder is tracked by the context
_ = CarHolder;
// This will now correctly remove the relation between the car and the current car holder
CarHolder = null;
}
}
Even though this works it is quite cumbersome especially for bigger models and there is a chance that it will be forgotten here and there. Less experienced EF developers might also be irritated by statements like this if not accompanied by comments like above. And of course it's another infrastructure detail polluting our domain which we want to get rid of as part of this series.
What if we would trigger lazy loading when accessing the setter? We accept the additional (maybe unnecessary) database roundtrip in favor of being able to just write CarHolder = null
that will work in all cases, regardless whether CarHolder
is already tracked by the context or not.
The responsible code of this lazy loading functionality is straightforward and consists of two files: an IProxyFactory and IInterceptor implementation. The interesting part is within the LazyLoadingInterceptor
where it intercepts property getter calls:
if (methodName.StartsWith("get_", StringComparison.Ordinal))
{
var navigationName = methodName.Substring(4);
var navigationBase = this.entityType.FindNavigation(navigationName) ??
(INavigationBase)this.entityType.FindSkipNavigation(navigationName);
if (navigationBase != null && !(navigationBase is INavigation navigation && navigation.ForeignKey.IsOwnership))
{
this.loader.Load(invocation.Proxy, navigationName);
}
}
The only thing we need to do here is to create our own CustomLazyLoadingInterceptor
, expand this block and also check for set_
:
if (methodName.StartsWith("get_", StringComparison.Ordinal) ||
methodName.StartsWith("set_", StringComparison.Ordinal))
{
// Trigger lazy loading
}
The ProxyFactory
is responsible for creating those LazyLoadingInterceptor
instances, so we have to provide our own implementation and replace LazyLoadingInterceptor
with our CustomLazyLoadingInterceptor
.
While configuring the DbContext
in the ServiceCollection
description you can now replace the existing with our custom implementation:
services.AddDbContext<SampleContext>(options =>
{
options.UseSqlite("DataSource=local.db").UseLazyLoadingProxies();
// Replace with our custom implementation
options.ReplaceService<IProxyFactory, CustomProxyFactory>();
}, ServiceLifetime.Transient);
Proxies created by EF Core will now also intercept setters for navigation properties and ensure those are tracked by the context before we apply any operation on it.
Instead of polluting our domain entities with navigation property operations that contain workarounds to ensure entities are always tracked, we end up with a clean domain entity class and slim and clear functions:
public class Car
{
public CarHolder? CarHolder { get; protected set; }
// This triggers lazy loading and will ensure that CarHolder is tracked by the context
// before setting it to null
public void TrashCar() => CarHolder = null;
}
The only thing you should take care of is to check occasionally for changes in ProxyFactory and LazyLoadingInterceptor (doesn't happen too often) and update your custom implementation if necessary.
The part 5 and end of our series will take a closer look on how to use pure readonly collections for collection navigation properties.