Tracking changes to domain objects with NHibernate

I have recently been working on an application which required tracking of changes to about half the objects in my domain model.  I needed to track the following:

  • The object that was changed
  • When the object was changed
  • Who made the change
  • The property that was changed
  • What the old value was before the change
The sort of data you expect really.

The system is built using MS.NET 4.0 with a WCF interface over a domain model being persisted to an MS Sql Server database using NHibernate.  A pretty standard multi-tier approach.

My first thought was to use triggers to track and record the changes.  It is easy to identify when the change is being performed and easy to create the corresponding change record.  The changed records were modeled in the domain, but were read-only and were lazy loaded.  Even with first level caching on, using either session-per-call or session-per-request, propagating these changes to the objects would not be a problem, and there is always the option of Session.Refresh().

The trigger solution would look something like this:

IF OBJECT_ID ('TRG_person_personchanged','TR') IS NOT NULL
    DROP TRIGGER TRG_person_personchanged;
GO
CREATE TRIGGER TRG_person_personchanged
ON [dbo].[person]
AFTER UPDATE 
AS
SET NOCOUNT ON 
BEGIN
DECLARE @changeDate datetime
SET @changeDate = GETUTCDATE()

IF (UPDATE(dateOfBirth))
BEGIN
INSERT INTO persondatahistory 
  (personId,
  propertyName,
  valueReplaced,
  modifiedBy,
  dateModified)
  SELECT ins.personId,
  'dateOfBirth',
  ISNULL(del.dateOfBirth, ''),
  ins.lastModifiedBy,
  @changeDate
  FROM INSERTED ins INNER JOIN DELETED del ON ins.personId = del.personId
  WHERE ins.dateOfBirth <> del.dateOfBirth OR ins.dateOfBirth IS NULL OR del.dateOfBirth IS NULL   
END

IF (UPDATE(lastName))
BEGIN
INSERT INTO persondatahistory 
  (personId,
  propertyName,
  valueReplaced,
  modifiedBy,
  dateModified)
  SELECT ins.personId,
  'lastName',
  ISNULL(del.lastName, ''),
  ins.lastModifiedBy,
  @changeDate
  FROM INSERTED ins INNER JOIN DELETED del ON ins.personId = del.personId
  WHERE ins.lastName <> del.lastName OR ins.lastName IS NULL OR del.lastName IS NULL
END

IF (UPDATE(givenNames))
BEGIN
INSERT INTO persondatahistory 
  (personId,
  propertyName,
  valueReplaced,
  modifiedBy,
  dateModified)
  SELECT ins.personId,
  'givenNames',
  ISNULL(del.givenNames, ''),
  ins.lastModifiedBy,
  @changeDate
  FROM INSERTED ins INNER JOIN DELETED del ON ins.personId = del.personId
  WHERE ins.givenNames <> del.givenNames OR ins.givenNames IS NULL OR del.givenNames IS NULL   
END

   END
GO

However, I then started to think about second-level caching  - particularly using MemCached.  With the cache lifetime being based on the SessionFactory, then this mode of creating the change records was going to be a problem.

The standard, well documented approach to creating an audit log of changes to domain objects while using NHibernate involves the user of Interceptors or Event Listeners, as described well here.  However, from what I can tell, this only allows you modify the object itself.  Examples I saw involved a heavy change-oriented base class or just updating change dates.  I needed whole objects to track a series of changes over a long period of time, and I did not want to have to reference a (potentionally large) lazy loaded collection to add a new change record.

It became clear that to have a robust solution which supported second-level caching, I would have to do the work in the domain layer.  Conceptually this is not such a bad thing, as it is domain concern, but it would involve more intensive code than a simple trigger.  I share my solution below:

First I created some interfaces to ensure required fields were present in change records and domain objects that require change tracking:

public interface IAuditable
{
    DateTime? DateLastModified { get; set; }
    IPerson LastModifiedBy { get; set; }
}


public interface IAuditRecord
{
    Guid EntityId { get; set; }
    string PropertyName { get; set; }
    string ValueReplaced { get; set; }
    IPerson ModifiedBy { get; set; }
    DateTime DateModified { get; set; }
}

Next, I created some custom attributes to use when creating the change records:

[AttributeUsage(AttributeTargets.Class)]
public class AuditRecordAttribute : Attribute
{
    Type _auditRecordType;
    private string _entityIdPropertyName;


    public string EntityIdPropertyName
    {
        get { return _entityIdPropertyName; }
        set { _entityIdPropertyName = value; }
    }
        
    public Type AuditRecordType
    {
        get { return _auditRecordType; }
        set { _auditRecordType = value; }
    }


     public AuditRecordAttribute(Type auditRecordType, string idPropertyName)
    {
        _auditRecordType = auditRecordType;
        _entityIdPropertyName = idPropertyName;
    }
}


[AttributeUsage(AttributeTargets.Property)]
public class AuditablePropertyAttribute : Attribute
{
    public AuditablePropertyAttribute()
    { }
}


[AttributeUsage(AttributeTargets.Property)]
public class AuditableCollectionAttribute : Attribute
{
    private string _itemIdPropertyName;


    public string ItemIdPropertyName
    {
        get { return _itemIdPropertyName; }
        set { _itemIdPropertyName = value; }
    }


    public AuditableCollectionAttribute(string itemIdPropertyName)
    {
        _itemIdPropertyName = itemIdPropertyName;
    }
}

Then I updated my domain objects to implement the interfaces and use the attributes:

public class PersonAuditRecord : IAuditRecord
{
    public virtual int PersonAuditRecordId{ get; set; }


    public virtual Guid EntityId{ get; set; }


    public virtual string PropertyName{ get; set; }


    public virtual string ValueReplaced{ get; set; }


    public virtual DateTime DateModified{ get; set; }


    public virtual IPerson ModifiedBy{ get; set; }


    public PersonAuditRecord()
    {}
}



[AuditRecordAttribute(typeof(PersonAuditRecord), "PersonId")]
public class Person : DomainBase, IPerson, IAuditable
{
  public virtual Guid PersonId { get; set; }


    [AuditablePropertyAttribute()]
    public virtual string GivenNames{ get; set; }

[AuditablePropertyAttribute()]
    public virtual string LastName { get; set; }


public virtual IPerson LastModifiedBy { get; set; }


public virtual DateTime? DateLastModified { get; set; }      
      
public Person()
    {}      
}

Then comes the interesting part - creating the audit records.  I added the following method to my domain object base class:

public static IList TrackChanges(T persistedObject, T modifiedObject) where T : IAuditable
{
      AuditRecordAttribute changesAttribute = (AuditRecordAttribute)typeof(T).GetCustomAttributes(typeof(AuditRecordAttribute), false).FirstOrDefault();
      Type auditRecordType = changesAttribute.AuditRecordType;
      dynamic entityId = typeof(T).GetProperty(changesAttribute.EntityIdPropertyName).GetValue(persistedObject, null);


      List changes = new List();


      PropertyInfo[] properties = typeof(T).GetProperties();
      foreach (PropertyInfo property in properties)
      {
          dynamic[] attributes = property.GetCustomAttributes(false);
          foreach(dynamic att in attributes)
          {
               if (att is AuditablePropertyAttribute)
               {
                   dynamic oldValue = property.GetValue(persistedObject, null);
                   dynamic newValue = property.GetValue(modifiedObject, null);


                   // record changes to properties which are instances of domain objects
                   if (oldValue != null && oldValue is DomainBase)
                   {
                       IList domainChanges = ((DomainBase)oldValue).TrackChanges(oldValue, newValue);
                  changes.AddRange(domainChanges);
                   }
                   else
                   {
                       // record changes to POCO properties
                       if (oldValue != newValue)
                       {
                           IAuditRecord auditRecord = (IAuditRecord)Activator.CreateInstance(auditRecordType);
                           auditRecord.PropertyName = property.Name;
                           auditRecord.ValueReplaced = oldValue.ToString();
                           auditRecord.DateModified = DateTime.UtcNow;
                           auditRecord.ModifiedBy = ((IAuditable)modifiedObject).LastModifiedBy;
                           auditRecord.EntityId = entityId;
                           changes.Add(auditRecord);
                       }
                   }
               }
               else if (att is AuditableCollectionAttribute)
               {
                   // record changes to collections of domain objects, specifically IList implementations
                   List oldItems = (List)property.GetValue(persistedObject, null);
                   List newItems = (List)property.GetValue(modifiedObject, null);
                   string idPropertyName = (att as AuditableCollectionAttribute).ItemIdPropertyName;
                   Type itemType = null;
                      
                   if (oldItems.Count > 0)
                   {
                       itemType = oldItems[0].GetType();
                   }
                   else if (newItems.Count > 0)
                   {
                       itemType = newItems[0].GetType();
                   }


                   if (itemType != null)
                   {
                       foreach(dynamic old in oldItems)
                       {
                           dynamic oldId = itemType.GetProperty(idPropertyName).GetValue(old, null);
                           System.Linq.Expressions.LambdaExpression idPropertyExpression = DynamicExpression.ParseLambda(idPropertyName + " == @0", oldId);
                           var result = newItems.AsQueryable().Provider.Execute(idPropertyExpression);
                           if (result != null)
                           {
                               IList itemChanges = ((DomainBase)old).TrackChanges(old, result);
                               changes.AddRange(itemChanges);
                           }                              
                       }
                   }
               }
           }              
       }
       return changes;
   }

Some interesting work is done by the call to DynamicExpression.ParseLambda().  This extension to the framework is available with the code samples provided by Microsoft and can be downloaded here

All that remains is to invoke the method and save the change records:

using (ISession session = _factory.OpenSession())
{
        using (ITransaction trans = session.BeginTransaction())
        {
              Person persisted = session.Get<Person>(modified.PersonId);
              IList<dynamic> changes = Person.TrackChanges(persisted, modified as Person);
              foreach (dynamic change in changes)
              {
                  session.Save(change);
              }
              if (changes.Count > 0)
              {
                  if (persisted == null)
                  {
                       modified = (Person)session.Save(modified);
                  }
                  else
                  {
                       modified = (Person)session.Merge(modified);
                  }
              }
              trans.Commit();
          }
          session.Close();
}
return modified;

I am sure that others will encounter this same change tracking problem.  I have shared my solution in the hope that you may find it a useful launching pad for your own solution.

Comments

  1. You should take a look at NHibernate Envers
    It works roughly the same way except you don't need to define a xxxAuditRecord type for every type that needs auditing…

    ReplyDelete
    Replies
    1. Jan - thanks for posting. I was not aware of NHibernate Envers when I did this work. It certainly does look like a nice solution for this problem. If it does what you need then it would provide a good option to avoid 'rolling-your-own' implementation.

      Delete

Post a Comment