Log Data Changes in Entity Framework Core – Part 2 - Service Integration For Testing With An API

12th August 2020

File Downloads

We will be continuing to build the logic in Entity Framework to log data changes. In part 1, we went ahead and created our entities, DbContext's and migrations.

Going forward, we will be creating a service that will integrate with an ASP.NET Core MVC API. Inside the service, there will be methods that allow you to create, read, modify and soft delete (CRUD) from an entity. When I say soft delete, I mean marking it as deleted in the database, but the record still exists.

BaseChangeService

This is an abstract class that inherits BaseService. Inside our BaseService, which is also abstract, we have methods that allow an entity to be created, read, updated and deleted. The purpose of the BaseChangeService is to override the create, update and delete methods so we can build the functionality to log data changes.

A Number of Generic Types

The BaseChangeService has three generic types as you can see below:

// BaseChangeService.cs
public abstract partial class BaseChangeService<TDataDbContext, TChangeDbContext, TEntity> : BaseService<TDataDbContext, TEntity>, IBaseChangeService<TDataDbContext, TChangeDbContext, TEntity>
		where TDataDbContext : DataDbContext
		where TChangeDbContext : ChangeDbContext
		where TEntity : class, IChangeEntity<TEntity>, IBase, new()

We are having to include a TDataDbContext and TChangeDbContext. Why are we doing that? It's so we can create in-memory DbContext's to be supported for our testing. When testing this service, we want to be able to integrate a separate DbContext. This is so it doesn't communicate with our SQL Server database.

We are also include a TEntity generic type. This will be anything that inherits the IChangeEntity interface, like the VideoGame entity.

The whole point of this BaseChangeService is that we create a new service for each entity we use. Then, we can just inherit our new service with BaseChangeService and have all the CRUD methods available for that service.

GetChangeData

This is a method that is used by the create, update and delete methods in BaseChangeService. What this does is that it uses Entity Framework's EntityEntry class.

Now, when you add an entry to Entity Framework, it contains information about the properties that are being set. Not only does it contain the current values of your entry. But, if you are updating an entity, it also contains the previous values of your entry.

This functionality in Entity Framework allows us to log data changes a lot more easier. We will use this class and add each of the properties into a property in the ChangeData class.

// BaseChangeService.cs
protected void GetChangeData([NotNull] ChangeData changeData, EntityEntry entry, EntityState entityState)
{
	PopulateChangeProperties(changeData, entityState, entry.CurrentValues, ChangePropertyTypeEnum.Current);

	if (entityState == EntityState.Modified)
	{
		PopulateChangeProperties(changeData, entityState, entry.OriginalValues, ChangePropertyTypeEnum.Original);
	}
}

protected void PopulateChangeProperties([NotNull] ChangeData changeData, EntityState entityState, PropertyValues propertyValues, ChangePropertyTypeEnum changePropertyType)
{
	foreach (var property in propertyValues.Properties)
	{                
		var changePropertyName = property.Name;
		var changeProperty = changeData[changePropertyName];
		var changePropertyValue = propertyValues[property];

		if (changeProperty != null)
		{
			changeProperty.SetValue(changePropertyValue, changePropertyType);

			// Remove if the values remain the same.
			if (entityState == EntityState.Modified && Equals(changeProperty.Current, changeProperty.Original))
			{
				changeData.ChangeProperties.Remove(changeProperty);
			}
		}
		else
		{
			changeData.AddChangeProperty(new ChangeProperty(changePropertyName, changePropertyValue, changePropertyType));
		}
	}
}

Overriding Create Method

When creating an entity, we wish to store all the values of the entity to make it easier to trace which properties have been changed. Now, when overriding the create method, the first thing we need to do is to add the entity to our DbContext.

From there, we can run the GetChangeData method and use the CurrentValues property in Entity Framework's EntityEntry class.

Once that's done, we can save our changes to our DataDbContext.

But, there is something else we need to do after that. We need to log our data changes to our ChangeDbContext. Now this uses another protected method in BaseChangeService. Called CreateChangeAsync, it basically creates our entry to ChangeDbContext.

// BaseChangeService.cs
protected async Task CreateChangeAsync(int id, ChangeData changeData)
{
	if (changeData.ChangeProperties == null || changeData.ChangeProperties.Count == 0)
	{
		return;
	}
	await _changeDbContext.AddAsync(new BaseChange<TEntity>(id, changeData));
	await _changeDbContext.SaveChangesAsync();
}

We only want to call it if we actually have any properties that have changed. But we have to call it after we have saved the changes in our DataDbContext. The reason is that we need the ID to be able to reference the data change.

Once that's done, we can complete our implementation for creating an entity.

// BaseChangeService.cs
public override async Task<TEntity> CreateAsync(TEntity entity)
{
	var changeData = new ChangeData(EntityState.Added);
	await AddEntityToContextAsync(entity); // Add entity to DbContext

	GetChangeData(changeData, _dataDbContext.Entry(entity), EntityState.Added);

	await SaveChangesAsync(); // Save changes to DbContext
	await CreateChangeAsync(entity.Id, changeData);

	return entity;
}

Overriding Update Method

This is a little different to the create method. We need to pass in an ID, and the entity that we are updating. Just to note, the entity we have passed into our update method not been tracked by Entity Framework.

Firstly, we need to make sure that that the record exists in the DbContext. Assuming it is, it uses Entity Framework's SetValues method. The SetValues method takes our DbContext's reference of the entity and updates only the properties that have been changed. So, we are not overwriting data that may have been changed by another application.

Once we've done that, we can log our data changes by calling our GetChangeData method. Then, we save the changes and create a new log data change record, in the same way we do when creating an entity.

In this instance, we only log a data change if any of the properties have actually changed. We don't want to waste space on properties that have stayed the same.

// BaseChangeService.cs
public override async Task<TEntity> UpdateAsync(int id, TEntity updateEntity)
{
	// Check that the record exists.
	var entity = await ReadAsync(id);

	if (entity == null)
	{
		throw new Exception("Unable to find record with id '" + id + "'.");
	}

	var entityEntry = _dataDbContext.Entry(entity);

	// Update changes if any of the properties have been modified.
	_dataDbContext.Entry(entity).CurrentValues.SetValues(updateEntity);
	_dataDbContext.Entry(entity).State = EntityState.Modified;

	var changeData = new ChangeData(EntityState.Modified);
	GetChangeData(changeData, entityEntry, EntityState.Modified);

	if (entityEntry.Properties.Any(property => property.IsModified))
	{
		await SaveChangesAsync();
		await CreateChangeAsync(entity.Id, changeData);
	}
	return entity;
}

Overriding Delete Method

This one is a little more simple. We actually call the base DeleteAsync method, the one that exists in our BaseService class. From there we then create a new property in our ChangeData class, which dictates that are entity has been deleted.

// BaseChangeService.cs
public override async Task DeleteAsync(int id)
{
	await base.DeleteAsync(id);

	var changeData = new ChangeData(EntityState.Modified);
	changeData.AddChangeProperty(new ChangeProperty("Deleted", true, false));

	await CreateChangeAsync(id, changeData);
}

Putting it Together in VideoGameService

Now that we have created our BaseChangeService, it's now a simple case of creating a VideoGameService to support the CRUD methods for our VideoGame entity.

// VideoGameService.cs
public partial class VideoGameService : BaseChangeService<DataDbContext, ChangeDbContext, VideoGame>, IVideoGameService
{
	public VideoGameService(DataDbContext dataDbContext, ChangeDbContext changeDbContext) : base(dataDbContext, changeDbContext) { }

As you can see, we've passed in the relevant DbContext's into BaseChangeService. In addition, we have added the VideoGame entity, so we know we are performing the CRUD operations on that entity.

Testing with Our API

We have created an ASP.NET Core MVC API, which adds the VideoGameService as a service through dependency injection.

In addition, we have created two abstract controllers. In a similar architecture style to the service, we have a BaseChangeController which inherits BaseController. The BaseController has all the methods to run our CRUD operations, and uses the BaseService to do this.

Then, we have created a VideoGameController, which routes to /api/video-game. This inherits the BaseChangeController that allows us to test our methods. Once again, we are using generic types to pass in the DbContext's and the entity, like we did for the service.

// BaseController.cs
[ApiController]
public abstract partial class BaseController<TDataDbContext, TEntity, TService> : ControllerBase
	where TDataDbContext : DataDbContext
	where TEntity : class, IBase
	where TService : class, IBaseService<TDataDbContext, TEntity>
{
	protected readonly TService _service;

	public BaseController([NotNull] TService service)
	{
		_service = service;
	}

	[HttpGet("{id:int}")]
	public async Task<IActionResult> ReadAsync(int id)
	{
		var entity = await _service.ReadAsync(id);

		if (entity == null)
		{
			return NotFound();
		}

		return Ok(entity);
	}

	[HttpPost]
	public async Task<IActionResult> CreateAsync(TEntity entity)
	{
		entity = await _service.CreateAsync(entity);

		return Ok(entity);
	}

	[HttpPatch("{id:int}")]
	public async Task<IActionResult> UpdatePartialAsync(int id, [FromBody] JsonPatchDocument<TEntity> patchEntity)
	{
		var entity = await _service.ReadAsync(id, false);

		if (entity == null)
		{
			return NotFound();
		}

		patchEntity.ApplyTo(entity, ModelState);
		entity = await _service.UpdateAsync(id, entity);

		return Ok(entity);
	}

	[HttpDelete("{id:int}")]
	public async Task<IActionResult> DeleteAsync(int id)
	{
		var entity = await _service.ReadAsync(id);

		if (entity == null)
		{
			return NotFound();
		}

		await _service.DeleteAsync(id);

		return Ok();
	}
}
// BaseChangeController.cs
public abstract class BaseChangeController<TDataDbContext, TChangeDbContext, TEntity, TService> : BaseController<TDataDbContext, TEntity, TService>
		where TDataDbContext : DataDbContext
		where TChangeDbContext : ChangeDbContext
		where TEntity : class, IChangeEntity<TEntity>, IBase, new()
		where TService : class, IBaseChangeService<TDataDbContext, TChangeDbContext, TEntity>
{
	public BaseChangeController([NotNull] TService service) : base(service) { }
}
// VideoGameController.cs
[Route("api/video-game")]
public class VideoGameController : BaseChangeController<DataDbContext, ChangeDbContext, VideoGame, IVideoGameService>
{
	public VideoGameController([NotNull] IVideoGameService service) : base(service) { }
}

See The Application In Action

We use Postman to run our ASP.NET Core MVC API and run the create, update and delete endpoints.

You'll be able to see the records that get created in the "change" database, and we will examine the JSON that gets created in the ChangeData column.

Next time...

That's the application complete, but there are a couple of additions we need to make. We need to ignore properties that we don't wish to log data changes to. As well, we need the ability to log data changes where for a reference in an entity. In addition, we will translate JSON keys into SQL Server columns so we can create a SQL Server query from our JSON string. Lastly, we will also examine testing and why it's important for an application like this.

About the author

David Grace

David Grace

Senior .NET web developer | ASP.NET Core | C# | Software developer

Free .NET videos

  • Do you want to watch free videos featuring .NET 7 new features?
  • How about what's new in C# 11?
  • Or a recap on the SOLID principles?
Watch our .NET videos