Unit Of Work is another great pattern that every software engineer needs to have in his tool box. So what's a purpose of Unit Of Work?
“Maintains a list of objects affected by a business transaction and coordinates the writing out of changes and the resolution of concurrency problems.” – Patterns of enterprise application architecture, Martin Fowler
Sounds like a lot of work, managing object state, relationships, concurrency and transactions can be cumbersome. However we are in luck, modern ORM's like NHibernate and Entity Framework support implicit Unit Of Work. In NHibernate it’s part of the ISession and in Entity Framework it’s part of the Context.
NHibernate
using(ISession session = sessionFactory.OpenSession()) using(ITransaction tx = session.BeginTransaction()) { Customer customer = new Customer { FirstName = "Peter" }; session.Save(customer); tx.Commit(); }
Entity Framework
using (SomeContext context = new SomeContext()) { Customer customer = new Customer { FirstName = "Peter" }; context.Customer.Add(customer); context.SaveChanges(); }
ORM's have built in Unit Of Work, ideally these need to be abstracted away. Abstraction will allow us to move to a different ORM platform in the future and yield cleaner architecture. Lets take a look at the abstracted example:
Customer customer = new Customer { FirstName = "Peter"}; this.repositoryCustomer.Add(customer); this.unitOfWork.Commit();
When it comes to working with databases it's important to read and write within a transaction scope. This ensures that there are no data inconsistencies, performance issues and second level cache is used. Please read this article for more information. So we need to open a transaction, but when do we do it?
- Open transaction manually in the service layer - Default Unit Of Work interface doesn't support .BeginTransaction, I don't believe it should be controlled by the user. Although this is a clean option.
- Open transaction on the page context creation - This feels dirty, each time some one hits your website your ORM will open a connection and begin a transaction. Not great for performance and is dangerous.
- Open transaction in the repository on the first read or write - Best option, it's automatic, and the only down side is that there will be transaction null check each time (singleton). But you get cleaner service implementation.
In this article I am going to be focusing on point 3.
NHibernate Abstraction
Configuration Singleton
public static class NHConfigurationSingleton { private static Configuration configuration = null; private static object lockObj = new object(); public static Configuration Configuration { get { lock (lockObj) { if (configuration == null) { string[] resourceNames; string nHResource = string.Empty; Assembly[] asmArray = AppDomain.CurrentDomain.GetAssemblies(); foreach (Assembly asm in asmArray) { resourceNames = asm.GetManifestResourceNames(); nHResource = resourceNames.FirstOrDefault(x => x.ToLower().Contains("hibernate.config")); if (!string.IsNullOrEmpty(nHResource)) { using (Stream resxStream = asm.GetManifestResourceStream(nHResource)) { configuration = new Configuration(); configuration.Configure(new XmlTextReader(resxStream)); } } } } } return configuration; } } }
Session Factory Singleton
public static class NHSessionFactorySingleton { private static ISessionFactory sessionFactory = null; private static object lockObj = new object(); public static ISessionFactory SessionFactory { get { lock (lockObj) { if (sessionFactory == null) { sessionFactory = NHConfigurationSingleton.Configuration.BuildSessionFactory(); } } return sessionFactory; } } }
Unit Of Work
public interface IUnitOfWork : IDisposable { void Commit(); void Rollback(); } public class NHUnitOfWork : IUnitOfWork { private ISession session; private ITransaction transaction; public ISession Session { get { return this.session; } } public NHUnitOfWork() { } public void OpenSession() { if (this.session == null || !this.session.IsConnected) { if (this.session != null) this.session.Dispose(); this.session = NHSessionFactorySingleton.SessionFactory.OpenSession(); } } public void BeginTransation(IsolationLevel isolationLevel = IsolationLevel.ReadCommitted) { if (this.transaction == null || !this.transaction.IsActive) { if (this.transaction != null) this.transaction.Dispose(); this.transaction = this.session.BeginTransaction(isolationLevel); } } public void Commit() { try { this.transaction.Commit(); } catch { this.transaction.Rollback(); throw; } } public void Rollback() { this.transaction.Rollback(); } public void Dispose() { if (this.transaction != null) { this.transaction.Dispose(); this.transaction = null; } if (this.session != null) { this.session.Dispose(); session = null; } } }
Repository
public class NHRepository<TEntity> : IRepoistory<TEntity> where TEntity : IDomainEntity { readonly NHUnitOfWork nhUnitOfWork; public NHRepository(NHUnitOfWork nhUnitOfWork) { this.nhUnitOfWork = nhUnitOfWork; } public void Add(TEntity entity) { this.nhUnitOfWork.OpenSession(); this.nhUnitOfWork.BeginTransation(); this.nhUnitOfWork.Session.Save(entity); } public void Remove(TEntity entity) { this.nhUnitOfWork.OpenSession(); this.nhUnitOfWork.BeginTransation(); this.nhUnitOfWork.Session.Delete(entity); } public void Update(TEntity entity) { this.nhUnitOfWork.OpenSession(); this.nhUnitOfWork.BeginTransation(); this.nhUnitOfWork.Session.Update(entity); } public IEnumerable<TEntity> GetAll() { this.nhUnitOfWork.OpenSession(); this.nhUnitOfWork.BeginTransation(); return this.nhUnitOfWork.Session.Query<TEntity>(); } }
Entity Framework Abstraction
Unit Of Work
public class EFUnitOfWork : IUnitOfWork { DbContext context; DbContextTransaction transaction; public DbContext Context { get { return this.context; } } public EFUnitOfWork() { this.context = new ECommerceEntities(); } public void OpenTransaction(IsolationLevel isolationLevel = IsolationLevel.ReadCommitted) { if (this.transaction == null) { if (this.transaction != null) this.transaction.Dispose(); this.transaction = this.context.Database.BeginTransaction(isolationLevel); } } public void Commit() { try { this.context.SaveChanges(); this.transaction.Commit(); } catch { this.transaction.Rollback(); throw; } } public void Rollback() { this.transaction.Rollback(); } public void Dispose() { if(this.transaction != null) { this.transaction.Dispose(); this.transaction = null; } if(this.context != null) { this.context.Database.Connection.Close(); this.context.Dispose(); this.context = null; } } }Repository
public class EFRepository<TEntity> : IRepoistory<TEntity> where TEntity : class, IDomainEntity { readonly EFUnitOfWork unitOfWork; public EFRepository(EFUnitOfWork unitOfWork) { this.unitOfWork = unitOfWork; } public void Add(TEntity entity) { this.unitOfWork.OpenTransaction(); this.unitOfWork.Context.Set<TEntity>().Add(entity); } public void Remove(TEntity entity) { this.unitOfWork.OpenTransaction(); this.unitOfWork.Context.Set<TEntity>().Remove(entity); } public IEnumerable<TEntity> GetAll() { this.unitOfWork.OpenTransaction(); return this.unitOfWork.Context.Set<TEntity>().ToList(); } }
Example usage (see SQL trace output below)
public class CustomerService { readonly IUnitOfWork unitOfWork; readonly IRepoistory<Customer> repositoryCustomer; public CustomerService(IUnitOfWork unitOfWork, IRepoistory<Customer> repositoryCustomer) { this.unitOfWork = unitOfWork; this.repositoryCustomer = repositoryCustomer; } public CustomerDto Add(string firstName, string lastName) { int existingWithSameName = this.repositoryCustomer.GetAll() .Count(customer => customer.FirstName == firstName && customer.LastName == lastName); if (existingWithSameName != 0) throw new Exception("User already exists with this name"); Customer customerNew = new Customer(firstName, lastName); this.repositoryCustomer.Add(customerNew); this.unitOfWork.Commit(); return new CustomerDto(customerNew.Id, customerNew.FirstName, customerNew.LastName); } }
NHibernate SQL Trace Output
To see exactly what's going on i've decided to go through SQL logs. It seems to behave correctly, it connects, starts a transaction, performs required operations, commits transaction and closes all connections.
2013-12-17 23:24:24.09 spid51 ODS Event: Login connection 51 2013-12-17 23:24:24.09 spid51 ODS Event: Remote_ods : Xact 0 ORS#: 1, connId: 0 2013-12-17 23:24:24.09 spid51 Xact BEGIN for Desc: 3300000001 2013-12-17 23:24:25.19 spid51 ODS Event: language_exec : Xact 3300000001 ORS#: 1, connId: 0 2013-12-17 23:24:25.19 spid51 Text:select customer0_.Id as Id0_, customer0_.FirstName as FirstName0_, customer0_.LastName as LastName0_ from ECommerce.dbo.Customer customer0_ 2013-12-17 23:24:25.35 spid51 Parameter# 0: Name=,Flags=0,Xvt=231,MaxLen=166,Len=166,Pxvar Value=INSERT INTO ECommerce.dbo.Customer (FirstName, LastName, Id) VALUES (@p0, @p1, @p2) 2013-12-17 23:24:25.36 spid51 Parameter# 1: Name=,Flags=0,Xvt=231,MaxLen=116,Len=116,Pxvar Value=@p0 nvarchar(4000),@p1 nvarchar(4000),@p2 uniqueidentifier 2013-12-17 23:24:25.36 spid51 Parameter# 2: Name=@p0,Flags=0,Xvt=231,MaxLen=8000,Len=10,Pxvar Value=ZanNH 2013-12-17 23:24:25.36 spid51 Parameter# 3: Name=@p1,Flags=0,Xvt=231,MaxLen=8000,Len=14,Pxvar Value=70ba671 2013-12-17 23:24:25.36 spid51 Parameter# 4: Name=@p2,Flags=0,Xvt=36,MaxLen=16,Len=16,Pxvar Value=E5EF2FD7-532B-4CEA-80F4-8B095D1F983A 2013-12-17 23:24:25.36 spid51 IPC Name: sp_executesql 2013-12-17 23:24:25.36 spid51 ODS Event: execrpc : Xact 3300000001 ORS#: 1, connId: 0 2013-12-17 23:24:25.36 spid51 ODS Event: Remote_ods : Xact 3300000001 ORS#: 1, connId: 0 2013-12-17 23:24:25.36 spid51 Xact COMMIT for Desc: 3300000001 2013-12-17 23:24:27.71 Server ODS Event: Exit spid 51 2013-12-17 23:24:27.71 spid51 ODS Event: Logout connection 51 2013-12-17 23:24:27.71 Server ODS Event: Close conn 0 on spid 51
Entity Framework SQL Trace Output
2013-12-17 23:27:06.21 spid51 ODS Event: Login connection 51 2013-12-17 23:27:06.22 Server Connection 0x00000001EE6433F0 connId:1, spid:51 New MARS Session 2013-12-17 23:27:06.22 spid51 ODS Event: Remote_ods : Xact 0 ORS#: 1, connId: 1 2013-12-17 23:27:06.22 spid51 Xact BEGIN for Desc: 3300000001 2013-12-17 23:27:08.06 Server Connection 0x00000001F2F317A0 connId:2, spid:51 New MARS Session 2013-12-17 23:27:08.06 spid51 ODS Event: language_exec : Xact 3300000001 ORS#: 1, connId: 2 2013-12-17 23:27:08.06 spid51 Text:SELECT [Extent1].[Id] AS [Id], [Extent1].[FirstName] AS [FirstName], [Extent1].[LastName] AS [LastName] FROM [dbo].[Customer] AS [Extent1] 2013-12-17 23:27:08.56 spid51 Parameter# 0: Name=,Flags=0,Xvt=231,MaxLen=154,Len=154,Pxvar Value=INSERT [dbo].[Customer]([Id], [FirstName], [LastName]) VALUES (@0, @1, @2) 2013-12-17 23:27:08.56 spid51 Parameter# 1: Name=,Flags=0,Xvt=231,MaxLen=102,Len=102,Pxvar Value=@0 uniqueidentifier,@1 varchar(256),@2 varchar(256) 2013-12-17 23:27:08.56 spid51 Parameter# 2: Name=@0,Flags=0,Xvt=36,MaxLen=16,Len=16,Pxvar Value=4318564C-304D-4792-85C5-F0113A873700 2013-12-17 23:27:08.56 spid51 Parameter# 3: Name=@1,Flags=0,Xvt=167,MaxLen=256,Len=5,Pxvar Value=ZanEF 2013-12-17 23:27:08.56 spid51 Parameter# 4: Name=@2,Flags=0,Xvt=167,MaxLen=256,Len=7,Pxvar Value=2c22266 2013-12-17 23:27:08.56 spid51 IPC Name: sp_executesql 2013-12-17 23:27:08.56 spid51 ODS Event: execrpc : Xact 3300000001 ORS#: 1, connId: 2 2013-12-17 23:27:08.58 spid51 ODS Event: Remote_ods : Xact 3300000001 ORS#: 1, connId: 1 2013-12-17 23:27:08.58 spid51 Xact COMMIT for Desc: 3300000001 2013-12-17 23:27:08.61 Server ODS Event: Close conn 1 on spid 51 2013-12-17 23:27:08.61 Server ODS Event: Close conn 2 on spid 51 2013-12-17 23:27:08.61 Server ODS Event: Exit spid 51 2013-12-17 23:27:08.61 spid51 ODS Event: Logout connection 51 2013-12-17 23:27:08.61 Server ODS Event: Close conn 0 on spid 51
We can pass NHUnitOfWork or EFUnitOfWork in to CustomerService and it will work as expected. Do avoid calling Unit Of Work within the Repository i.e. this.repositoryCustomer.Commit(), I've seen this a lot around the web. Unit Of Work should not be bound directly to it.
*Note: Code in this article is not production ready and is used for prototyping purposes only. If you have suggestions or feedback please do comment.
References:
- Unit Of Work definition - Martin Fowler
- NHibernate Unit Of Work - Gabriel Schenker
- Don't write your own ORM - Jimmy Bogard
- How to trace SQL without using SQL Profiler - CodeProject
- Use of implicit transactions is discouraged