devLux

Self-Tracking Entities Part 2 - Entities

We have learned in the previous post, Part 1 - Fundamentals, a Unit of Work describes a component that supports change tracking. Normally developers have to manage the changes manually. By adding some logic to the entities we enable the container - the Repository - to handle certain changes automatically. We refer to this behavior as self-tracking entities. After having seen how to implement standard change tracking, we are now going to explain the concepts behind self-tracking entities and to evolve our Repository implementation into a completely self-tracked variant.

Introduction

The idea behind self-tracking entities is simple: The entities notify the Repository as soon as they are modified. Therefore we need to make them observable by exposing an event that can be consumed by an observer, e.g. the Repository. This leads us to the following interface, including some convenience methods to reset modifications or to clear the HasChanges flag.

public interface IEntity
{
  event EventHandler HasChangesChanged;
  bool HasChanges { get; }
  void AcceptChanges();
  void RejectChanges();
}

Usually you do not want all properties to be tracked, but only the ones that the user is able to change through the user interface. In a truly Aspect Oriented Programming (AOP) manner we allow entities to mark the properties that shall participate in change tracking with a custom property attribute called ChangeTracker.

Unit Tests

In the spirit of Test Driven Development (TDD) we describe the unit tests before starting the implementation. The unit tests reflect the expected behavior in detail and assert a high level of quality, especially if combined with Continous Integration (CI).

Before diving into the unit tests, let us first define a fake entity class. Please note that only public properties will participate in change tracking.

internal class FakeEntity : EntityBase
{
  public FakeEntity() { EnableChangeTracker(); }
  public FakeEntity(string name, string fullName, int year)
  {
    _name = name;
    _fullName = name;
    _year = year;

    EnableChangeTracker();
  }

  // Currently only public properties participate in change tracking
  [ChangeTracker]
  private DateTime? NotAccessibleProperty { get; set; }
  public void SetNotAccessibleProperty(DateTime date)
  {
    NotAccessibleProperty = date;
  }

  private string _name;

  [ChangeTracker]
  public string Name
  {
    get { return _name; }
    set 
    {
      _name = value;
      OnEntityValueChanged(() => Name);
    }
  }

  private string _fullName;

  [ChangeTracker]
  public string FullName
  {
    get { return _fullName; }
    set 
    { 
      _fullName = value;
      OnEntityValueChanged(() => FullName);
    }
  }

  // Private setter
  private string _privateName;

  [ChangeTracker]
  public string PrivateName
  {
    get { return _privateName; }
    private set 
    {
      _privateName = value;
      OnEntityValueChanged(() => PrivateName);
    }
  }
  public void SetPrivateName(string name)
  {
    PrivateName = name;
  }

  // Missing attribute
  private int _year;
  public int Year
  {
    get { return _year; }
    set 
    {
      _year = value;
      OnEntityValueChanged(() => Year);
    }
  }
}
[TestFixture]
public class EntityBaseTest
{
  [Test]
  public void Initializing_Automatic_Properties_Sets_HasChanges_To_True()
  {
    FakeEntity entity = new FakeEntity() { Name = "Hans", FullName = "Hans Müller", Year = 1957 };

    bool hasChanges = entity.HasChanges;

    Assert.IsTrue(hasChanges);
  }

  [Test]
  public void Changing_A_Tracked_Property_Sets_HasChanges_To_True()
  {
    FakeEntity entity = new FakeEntity("Hans", "Hans Müller", 1937);

    entity.Name = "Hans Peter";
    bool hasChanges = entity.HasChanges;

    Assert.IsTrue(hasChanges);
    Assert.IsTrue(entity.Name.Equals("Hans Peter", StringComparison.Ordinal));
  }
  
  [Test]
  public void Changing_A_NotAccessible_Tracked_Property_Keeps_HasChanges_False()
  {
    FakeEntity entity = new FakeEntity("Hans", "Hans Müller", 1937);

    entity.SetNotAccessibleProperty(DateTime.Now);
    bool hasChanges = entity.HasChanges;

    Assert.IsFalse(hasChanges);
  }

  [Test]
  public void Changing_A_Untracked_Property_Keeps_HasChanges_False()
  {
    FakeEntity entity = new FakeEntity("Hans", "Hans Müller", 1937);

    entity.Year = 1947;

    bool hasChanges = entity.HasChanges;

    Assert.IsFalse(hasChanges);
  }

  [Test]
  public void Rejecting_Changes_Sets_HasChanges_To_False_And_Sets_Initial_Value_And_Does_Not_Affect_Untracked_Properties()
  {
    FakeEntity entity = new FakeEntity("Hans", "Hans Müller", 1937);

    entity.Name = "Hans Peter";
    entity.Year = 1947;

    entity.RejectChanges();

    bool hasChanges = entity.HasChanges;

    Assert.IsFalse(hasChanges);
    Assert.IsTrue(entity.Name.Equals("Hans", StringComparison.Ordinal));
    Assert.IsTrue(entity.Year == 1947);
  }

  [Test]
  public void Accepting_Changes_Sets_HasChanges_To_False_And_Keeps_Current_Value()
  {
    FakeEntity entity = new FakeEntity("Hans", "Hans Müller", 1937);

    entity.Name = "Hans Peter";

    entity.AcceptChanges();

    bool hasChanges = entity.HasChanges;

    Assert.IsFalse(hasChanges);
    Assert.IsTrue(entity.Name.Equals("Hans Peter", StringComparison.Ordinal));
  }

  [Test]
  public void Changing_Private_Property_Sets_HasChanges_To_True()
  {
    FakeEntity entity = new FakeEntity("Hans", "Hans Müller", 1937);

    entity.SetPrivateName("Toni");
    bool hasChanges = entity.HasChanges;

    Assert.IsTrue(hasChanges);
    Assert.IsTrue(entity.PrivateName != null && entity.PrivateName.Equals("Toni", StringComparison.Ordinal));
  }

  [Test]
  public void Rejecting_Changes_Of_Private_Property_Sets_HasChanges_To_False_And_Sets_Initial_Value()
  {
    FakeEntity entity = new FakeEntity("Hans", "Hans Müller", 1937);

    entity.SetPrivateName("Toni");

    entity.RejectChanges();
    bool hasChanges = entity.HasChanges;

    Assert.IsFalse(hasChanges);
    Assert.IsTrue(entity.PrivateName == null);
  }

  [Test]
  public void Accepting_Changes_Of_Private_Property_Sets_HasChanges_To_False_And_Keeps_Current_Value()
  {
    FakeEntity entity = new FakeEntity("Hans", "Hans Müller", 1937);

    entity.SetPrivateName("Toni");

    entity.AcceptChanges();
    bool hasChanges = entity.HasChanges;

    Assert.IsFalse(hasChanges);
    Assert.IsTrue(entity.PrivateName != null && entity.PrivateName.Equals("Toni", StringComparison.Ordinal));
  }
}
[TestFixture]
public class EntityRepositoryTest
{
  [Test]
  public void Adding_Unchanged_Entities_Keeps_HasChanges_False()
  {
    FakeEntity e1 = new FakeEntity("Hans", "Hans Müller", 1937);
    FakeEntity e2 = new FakeEntity("Toni", "Toni Müller", 1947);
    FakeEntity e3 = new FakeEntity("Markus", "Markus Müller", 1967);
    FakeEntity e4 = new FakeEntity("Sepp", "Sepp Müller", 1977);

    Repository.AddItem(e1);
    Repository.AddItem(e2);
    Repository.AddItem(e3);
    Repository.AddItem(e4);

    bool hasChanges = Repository.HasChanges;

    Assert.IsFalse(hasChanges);
  }

  [Test]
  public void Adding_Changed_Entities_Sets_HasChanges_To_True()
  {
    FakeEntity e1 = new FakeEntity("Hans", "Hans Müller", 1937);
    FakeEntity e2 = new FakeEntity("Toni", "Toni Müller", 1947);
    FakeEntity e3 = new FakeEntity("Markus", "Markus Müller", 1967);
    FakeEntity e4 = new FakeEntity("Sepp", "Sepp Müller", 1977);

    Repository.AddItem(e1);
    Repository.AddItem(e2);
    Repository.AddItem(e3);

    e4.Name = "Seppli";

    Repository.AddItem(e4);

    bool hasChanges = Repository.HasChanges;

    Assert.IsTrue(hasChanges);
  }

  [Test]
  public void Changing_Entity_Sets_HasChanges_To_True()
  {
    FakeEntity e1 = new FakeEntity("Hans", "Hans Müller", 1937);
    FakeEntity e2 = new FakeEntity("Toni", "Toni Müller", 1947);
    FakeEntity e3 = new FakeEntity("Markus", "Markus Müller", 1967);
    FakeEntity e4 = new FakeEntity("Sepp", "Sepp Müller", 1977);

    Repository.AddItem(e1);
    Repository.AddItem(e2);
    Repository.AddItem(e3);
    Repository.AddItem(e4);

    e1.Name = "Hansli";
    bool hasChanges = Repository.HasChanges;

    Assert.IsTrue(hasChanges);
  }

  [Test]
  public void Rejecting_Changes_On_A_Changed_Entity_Sets_HasChanges_To_False_And_Clears_ChangeTracker()
  {
    FakeEntity e1 = new FakeEntity("Hans", "Hans Müller", 1937);
    FakeEntity e2 = new FakeEntity("Toni", "Toni Müller", 1947);
    FakeEntity e3 = new FakeEntity("Markus", "Markus Müller", 1967);
    FakeEntity e4 = new FakeEntity("Sepp", "Sepp Müller", 1977);

    Repository.AddItem(e1);
    Repository.AddItem(e2);
    Repository.AddItem(e3);
    Repository.AddItem(e4);

    e1.Name = "Hansli";
    e1.RejectChanges();

    bool hasChanges = Repository.HasChanges;
    
    Assert.IsFalse(hasChanges);

    Assert.IsFalse(e1.HasChanges);
    Assert.IsTrue(e1.Name == "Hans");

    Assert.IsTrue(Repository.ChangeTracker.Inserted.Count() == 0);
    Assert.IsTrue(Repository.ChangeTracker.Changed.Count() == 0);
    Assert.IsTrue(Repository.ChangeTracker.Deleted.Count() == 0);
  }

  [Test]
  public new void Rejecting_Changes_On_A_Changed_Repository_Sets_HasChanges_To_False_And_Clears_ChangeTracker()
  {
    FakeEntity e1 = new FakeEntity("Hans", "Hans Müller", 1937);
    FakeEntity e2 = new FakeEntity("Toni", "Toni Müller", 1947);
    FakeEntity e3 = new FakeEntity("Markus", "Markus Müller", 1967);

    Repository.AddItem(e1);
    Repository.AddItem(e2);
    Repository.AddItem(e3);

    e1.Name = "Hansli";
    Repository.Update(e1);

    e2.Name = "Tönchen";
    Repository.Update(e2);
    Repository.Delete(e2);

    Repository.Delete(e3);

    FakeEntity e4 = new FakeEntity("Sepp", "Sepp Müller", 1977);
    Repository.Insert(e4);

    Repository.RejectChanges();

    bool hasChanges = Repository.HasChanges;

    Assert.IsFalse(hasChanges);
    Assert.IsTrue(e1.Name.Equals("Hans", StringComparison.Ordinal));
    Assert.IsTrue(e2.Name.Equals("Toni", StringComparison.Ordinal));

    Assert.IsTrue(Repository.GetAll().Count() == 3);

    Assert.IsTrue(Repository.ContainsItem(e1));
    Assert.IsTrue(Repository.ContainsItem(e2));
    Assert.IsTrue(Repository.ContainsItem(e3));

    Assert.IsFalse(Repository.ContainsItem(e4));

    Assert.IsTrue(Repository.ChangeTracker.Inserted.Count() == 0);
    Assert.IsTrue(Repository.ChangeTracker.Changed.Count() == 0);
    Assert.IsTrue(Repository.ChangeTracker.Deleted.Count() == 0);
  }

  [Test]
  public void Accepting_Changes_On_A_Changed_Entity_Sets_HasChanges_To_False_And_Clears_ChangeTracker()
  {
    FakeEntity e1 = new FakeEntity("Hans", "Hans Müller", 1937);
    FakeEntity e2 = new FakeEntity("Toni", "Toni Müller", 1947);
    FakeEntity e3 = new FakeEntity("Markus", "Markus Müller", 1967);
    FakeEntity e4 = new FakeEntity("Sepp", "Sepp Müller", 1977);

    Repository.AddItem(e1);
    Repository.AddItem(e2);
    Repository.AddItem(e3);
    Repository.AddItem(e4);

    e1.Name = "Test";
    e1.Name = "Hansli";
    e1.AcceptChanges();

    bool hasChanges = Repository.HasChanges;
    Assert.IsFalse(hasChanges);
    Assert.IsTrue(e1.Name.Equals("Hansli", StringComparison.Ordinal));

    Assert.IsTrue(Repository.ChangeTracker.Inserted.Count() == 0);
    Assert.IsTrue(Repository.ChangeTracker.Changed.Count() == 0);
    Assert.IsTrue(Repository.ChangeTracker.Deleted.Count() == 0);
  }

  [Test]
  public new void Accepting_Changes_On_A_Changed_Repository_Sets_HasChanges_To_False_And_Clears_ChangeTracker()
  {
    FakeEntity e1 = new FakeEntity("Hans", "Hans Müller", 1937);
    FakeEntity e2 = new FakeEntity("Toni", "Toni Müller", 1947);
    FakeEntity e3 = new FakeEntity("Markus", "Markus Müller", 1967);

    Repository.AddItem(e1);
    Repository.AddItem(e2);
    Repository.AddItem(e3);

    e1.Name = "Hansli";
    Repository.Update(e1);

    e2.Name = "Tönchen";
    Repository.Update(e2);
    Repository.Delete(e2);
    e2.Name = "Test";

    Repository.Delete(e3);

    FakeEntity e4 = new FakeEntity("Sepp", "Sepp Müller", 1977);
    Repository.Insert(e4);

    Repository.AcceptChanges();

    bool hasChanges = Repository.HasChanges;

    Assert.IsFalse(hasChanges);
    Assert.IsTrue(e1.Name.Equals("Hansli", StringComparison.Ordinal));

    Assert.IsTrue(Repository.GetAll().Count() == 2);

    Assert.IsFalse(Repository.ContainsItem(e2));
    Assert.IsFalse(Repository.ContainsItem(e3));

    Assert.IsTrue(Repository.ContainsItem(e1));
    Assert.IsTrue(Repository.ContainsItem(e4));

    Assert.IsTrue(Repository.ChangeTracker.Inserted.Count() == 0);
    Assert.IsTrue(Repository.ChangeTracker.Changed.Count() == 0);
    Assert.IsTrue(Repository.ChangeTracker.Deleted.Count() == 0);
  }

  [Test]
  public void Removing_An_Changed_Entity_Sets_HasChanges_To_False()
  {
    FakeEntity e1 = new FakeEntity("Hans", "Hans Müller", 1937);
    FakeEntity e2 = new FakeEntity("Toni", "Toni Müller", 1947);
    FakeEntity e3 = new FakeEntity("Markus", "Markus Müller", 1967);
    FakeEntity e4 = new FakeEntity("Sepp", "Sepp Müller", 1977);

    Repository.AddItem(e1);
    Repository.AddItem(e2);
    Repository.AddItem(e3);
    Repository.AddItem(e4);

    e1.Name = "Hansli";
    Repository.Update(e1);

    Repository.RemoveItem(e1);

    e1.Name = "Test";

    bool hasChanges = Repository.HasChanges;

    Assert.IsFalse(hasChanges);
    Assert.IsFalse(Repository.ContainsItem(e1));
    Assert.IsTrue(Repository.ChangeTracker.Changed.Count() == 0);
  }
}

Entity

To be observable the entities have to trigger the HasChangesChanged when a change occurs but only once for each status transition. This could be done in each relevant property setter as described in the following code listing.

public string Name
{
  get { return _name; }
  set 
  {
    if (_name != value)
    {
      HasChanges = true;
    }
    _name = value;
  }
}
public bool HasChanges
{
  get { return _hasChanges; }
  private set 
  {
    if (_hasChanges != value)
    {
      HasChangesChanged(this, new EventHandler());
    }
    _hasChanges = value;
  }
}

There is some room for improvement: First the HasChanges property could be extracted into an abstract base class. Second we could simplify the entity implementation further by using property attributes to mark properties that shall participate in change tracking. As this anyhow requires run-time reflection, we could also use the same mechanism to store the previous values.

Based on those findings we create an abstract base class EntityBase that implements the IEntity interface as follows.

[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public sealed class ChangeTrackerAttribute : Attribute
{ }

public abstract class EntityBase : IEntity
{
  private readonly EntityStateManager _changeTracker;

  protected EntityBase()
  {
    _changeTracker = new EntityStateManager(this);
  }

  protected void EnableChangeTracker()
  {
    _changeTracker.Initialize();
    AcceptChanges();
  }

  protected virtual void OnEntityValueChanged(string propertyName)
  {
    if (propertyName != null && _changeTracker.ContainsProperty(propertyName))
    {
      NotifyHasChangesChangedListeners();
    }
  }
  
  public event EventHandler HasChangesChanged;
  protected void NotifyHasChangesChangedListeners()
  {
    EventHandler handler = HasChangesChanged;
    if (handler != null)
    {
      handler(this, new EventArgs());
    }
  }

  public virtual bool HasChanges
  {
    get 
    {
      return _changeTracker.HasChanges;
    }
  }

  public virtual void AcceptChanges()
  {
    _changeTracker.AcceptChanges();
    NotifyHasChangesChangedListeners();
  }

  public virtual void RejectChanges()
  {
    _changeTracker.RejectChanges();
    NotifyHasChangesChangedListeners();
  }
}

Again we moved the change tracking concern to an own component EntityStateManager. Calling EnableChangeTracker() inspects the entity through run-time reflection and stores the values of the fields marked with the corresponding property attribute. But only when OnEntityValueChanged is invoked, e.g. inside a property setter, we are able to decide whether the modification is change tracking relevant and if the event HasChangesChanged has to be raised.

protected virtual void OnEntityValueChanged(string propertyName)
{
  if (propertyName != null && _changeTracker.ContainsProperty(propertyName))
  {
    NotifyHasChangesChangedListeners();
  }
}

A nice small addition is the following method that allows us to pass a Lambda Expression instead of the property name. This prevents typos and comes handy in case of refactorings.

protected void OnEntityValueChanged<TEntity>(Expression<Func<TEntity>> property)
{
  if (property == null)
  {
    throw new ArgumentNullException("property");
  }

  MemberExpression member = property.Body as MemberExpression;
  if (member == null || member.Member == null || member.Member.MemberType != MemberTypes.Property)
  {
    throw new ArgumentException("Provide a property", "property");
  }

  OnEntityValueChanged(member.Member.Name);
}

Therefore a possible entity class could be implemented as follows. Do not forget to call EnableChangeTracker in the constructor since by default change tracking is disabled.

public class FakeEntity : EntityBase
{
  public FakeEntity() { EnableChangeTracker(); }
  public FakeEntity(string name)
  {
    _name = name;
    EnableChangeTracker();
  }

  private string _name;

  [ChangeTracker]
  public string Name
  {
    get { return _name; }
    set 
    {
      _name = value;
      OnEntityValueChanged(() => Name);
    }
  }
}

EntityStateManager

The entity’s change tracker keeps the name, initial value (the one that was set when EnableChangeTracker was invoked on the class EntityBase), and the current value for each property. When calling the AcceptChanges the initial value is set to the current value, when calling RejectChanges then the initial value is set directly on the entity’s property.

public sealed class EntityStateManager
{
  private readonly IEntity _entity;
  private readonly IDictionary<string, EntityProperty> _properties = new Dictionary<string, EntityProperty>();

  public EntityStateManager(IEntity entity)
  {
    _entity = entity;
  }

  public void Initialize()
  {
    foreach (PropertyInfo propertyInfo in _entity.GetType().GetProperties())
    {
      if (propertyInfo.GetCustomAttributes(typeof(ChangeTrackerAttribute), true).Length != 0)
      {
        EntityProperty property = new EntityProperty(_entity, propertyInfo);
        _properties.Add(property.Name, property);
      }
    }
  }

  public bool ContainsProperty(string propertyName)
  {
    return _properties.ContainsKey(propertyName);
  }
  public void AcceptChanges()
  {
    foreach (EntityProperty property in _properties.Values)
    {
      if (property.HasChanges)
      {
        property.AcceptChanges();
      }
    }
  }
  public void RejectChanges()
  {
    foreach (EntityProperty property in _properties.Values)
    {
      if (property.HasChanges)
      {
        property.RejectChanges();
      }
    }
  }
  public bool HasChanges
  {
    get
    {
      return _properties.Values.Where(p => p.HasChanges).Count() > 0;
    }
  }

  private class EntityProperty
  {
    private readonly IEntity _entity;
    private readonly PropertyInfo _propertyInfo;
    
    private object _initialValue;

    public EntityProperty(IEntity entity, PropertyInfo propertyInfo)
    {
      _entity = entity;
      _propertyInfo = propertyInfo;

      AcceptChanges();
    }

    public object CurrentValue
    {
      get
      {
        return _propertyInfo.GetValue(_entity, null);
      }
    }

    public string Name
    {
      get
      {
        return _propertyInfo.Name;
      }
    }

    public void AcceptChanges()
    {
      _initialValue = CurrentValue;
    }
    public void RejectChanges()
    {
      if (_propertyInfo.GetSetMethod(true) != null)
      {
        _propertyInfo.SetValue(_entity, _initialValue, null);
      }
    }

    public bool HasChanges
    {
      get
      {
        if (_initialValue == null)
        {
          return CurrentValue != null;
        }

        if (object.ReferenceEquals(_initialValue, CurrentValue))
        {
          return false;
        }
        
        return !_initialValue.Equals(CurrentValue);
      }
    }
  }
}

Repository

The Repository for self-tracking entities extends our original RepositoryBase class. Through the virtual methods OnItemAdding and OnItemRemoved the new implementation is able to hook into the workflow and register an event handler for the entity’s HasChangesChanged event.

When handling this event we have to check whether the HasChange was set to FALSE or to TRUE. In the first case we have to remove the entity from our set of modified entities.

private void Entity_HasChangesChanged(object sender, EventArgs e)
{
  TEntity entity = sender as TEntity;

  lock (SyncRoot)
  {
    if (entity.HasChanges)
    {
      base.Update(entity);
    }
    else if (ChangeTracker.ContainsChanged(entity))
    {
      ChangeTracker.RemoveChanged(entity);
    }

    NotifyHasChangesChangedListeners();
  }
}

As the entities have an own change tracking component we should not forget to call AcceptChanges or RejectChanges when the corresponding methods are invoked on the Repository. That’s why we have to override those methods as well.

public abstract class EntityRepositoryBase<TEntity> : RepositoryBase<TEntity> where TEntity : class, IEntity
{
  protected override void OnItemAdded(TEntity entity)
  {
    base.OnItemAdded(entity);

    if (entity != null)
    {
      entity.HasChangesChanged += new EventHandler(Entity_HasChangesChanged);

      if (entity.HasChanges)
      {
        base.Update(entity);
      }
    }
  }

  protected override void OnItemRemoved(TEntity entity)
  {
    base.OnItemRemoved(entity);

    if (entity != null)
    {
      entity.HasChangesChanged -= Entity_HasChangesChanged;
    }
  }

  private void Entity_HasChangesChanged(object sender, EventArgs e)
  {
    TEntity entity = sender as TEntity;

    lock (SyncRoot)
    {
      if (entity.HasChanges)
      {
        base.Update(entity);
      }
      else if (ChangeTracker.ContainsChanged(entity))
      {
        ChangeTracker.RemoveChanged(entity);
      }

      NotifyHasChangesChangedListeners();
    }
  }

  public override void AcceptChanges()
  {
      IEnumerable<TEntity> modifiedItems = ChangeTracker.Inserted.Union(ChangeTracker.Changed);
      foreach (TEntity entity in modifiedItems)
      {
        entity.AcceptChanges();
      }

      base.AcceptChanges();
  }

  public override void RejectChanges()
  {
      IEnumerable<TEntity> modifiedItems = ChangeTracker.Deleted.Union(ChangeTracker.Changed);
      foreach (TEntity entity in modifiedItems)
      {
        entity.RejectChanges();
      }

      base.RejectChanges();
  }
}

Review and Outlook

That was pretty easy and fun to implement! Do not forget to download the sources and to run the unit tests again before you continue reading. In the next post, Part 3 - Validation, we are going to add the validation infrastructure.

References

  1. Observer Pattern
  2. Aspect Oriented Programming (AOP)
  3. Test Driven Development (TDD)
  4. Continous Integration (CI)
  5. Lambda Expressions