Repository Pattern is effective data access design pattern when we want to:
- Increase testability of the application. Make test repeatable without touching the data source;
- Implement Separation of Concerns between the business logic and data source or business logic and test units;
- Make the application work with different data source such SQL Server, mySQL, Oracle, web api endpoints, xml file and other RDBMS out in the market.
- Make our code reusable. Effective on CRUD.
Today, we are going to see how to implement this in ASP.NET MVC.
THE ARCHITECTURE
We are going to use Domain Centric Design architecture in this application so we need the following layers:
- Core = Contains the business logic that includes the Generic Repository interface and the POCO models.
- Data = Layer that handles SQL Data Access
- MVC = The UI
- Test = Unit Tests
THE CORE LAYER
The POCO Models:
Country.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System.Collections.Generic; | |
using System.ComponentModel; | |
using System.ComponentModel.DataAnnotations; | |
namespace ContactBook.Core.Models | |
{ | |
public class Country | |
{ | |
[Key] | |
public int Id { get; set; } | |
[Required(ErrorMessage = "*")] | |
[MaxLength(40)] | |
[DisplayName("Country")] | |
public string CountryName { get; set; } | |
public virtual ICollection<Client> Clients { get; set; } | |
} | |
} |
Client.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System.ComponentModel; | |
using System.ComponentModel.DataAnnotations; | |
using System.ComponentModel.DataAnnotations.Schema; | |
namespace ContactBook.Core.Models | |
{ | |
public class Client | |
{ | |
[Key] | |
public int id { set; get; } | |
[Required(ErrorMessage = "*")] | |
[MaxLength(30)] | |
[DisplayName("Last Name")] | |
public string LastName { set; get; } | |
[Required(ErrorMessage = "*")] | |
[MaxLength(30)] | |
[DisplayName("First Name")] | |
public string FirstName { set; get; } | |
[Required(ErrorMessage = "*")] | |
[MaxLength(40)] | |
[DisplayName("Address")] | |
public string Address { set; get; } | |
[MaxLength(30)] | |
[DisplayName("Contact #")] | |
public string ContactNo { set; get; } | |
[Required(ErrorMessage = "*")] | |
[MaxLength(100)] | |
[DataType(DataType.EmailAddress)] | |
[DisplayName("Email")] | |
public string Email { set; get; } | |
[DisplayName("Country")] | |
public int CountryId { get; set; } | |
[DisplayName("Remarks")] | |
[StringLength(300)] | |
[DataType(DataType.MultilineText)] | |
public string Remarks { get; set; } | |
[ForeignKey("CountryId")] | |
public virtual Country Country { get; set; } | |
} | |
} |
The Generic Repository Interface
In order to implement Dependency Injection and mocks, we need to use interface.
The advantage of using generics in repository is that is it reusable. We can reuse the repository by passing the class as parameter.
THE DATA LAYER
In the Data Layer we will use EF First Code approach. This is where we will implement the generic repository. We can use this in both Country and Client class.
EFClientDb.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Linq; | |
using System.Data.Entity; | |
using ContactBook.Core.Models; | |
using ContactBook.Core.Repository; | |
using System.Data.Entity.Validation; | |
namespace ContactBook.Data.Data | |
{ | |
public class EFClientDb : DbContext, IGenericRepo | |
{ | |
public DbSet<Client> Clients { get; set; } | |
public DbSet<Country> Countries { get; set; } | |
public IQueryable<T> Query<T>() where T : class | |
{ | |
return Set<T>(); | |
} | |
public IQueryable<T> QueryIncluding<T>(params System.Linq.Expressions.Expression<Func<T, object>>[] includeProperties) where T : class | |
{ | |
IQueryable<T> query = Set<T>(); | |
foreach (var includeProperty in includeProperties) | |
{ | |
query = query.Include(includeProperty); | |
} | |
return query; | |
} | |
public T GetById<T>(int id) where T : class | |
{ | |
return Set<T>().Find(id); | |
} | |
public void Add<T>(T entity) where T : class | |
{ | |
Set<T>().Add(entity); | |
} | |
public void Update<T>(T entity) where T : class | |
{ | |
Entry(entity).State = System.Data.Entity.EntityState.Modified; | |
} | |
public void Delete<T>(T entity) where T : class | |
{ | |
Set<T>().Remove(entity); | |
} | |
public void Delete<T>(int id) where T : class | |
{ | |
var entity = Set<T>().Find(id); | |
Set<T>().Remove(entity); | |
} | |
public bool Save() | |
{ | |
try | |
{ | |
return this.SaveChanges() > 0; | |
} | |
catch (DbEntityValidationException ex) | |
{ | |
Console.WriteLine("============E R R O R ======================"); | |
foreach (var entityValidationErrors in ex.EntityValidationErrors) | |
{ | |
foreach (var validationError in entityValidationErrors.ValidationErrors) | |
{ | |
Console.WriteLine("Property: {0} Error: {1}", | |
validationError.PropertyName, validationError.ErrorMessage); | |
} | |
} | |
//System.InvalidOperationException | |
Console.WriteLine("============E R R O R ======================"); | |
return false; | |
} | |
} | |
public void Dispose() | |
{ | |
Dispose(true); | |
GC.SuppressFinalize(this); | |
} | |
} | |
} |
THE MVC
For the purpose of demonstration, we will be having some business logic in the controller level. But in production, it is recommended to push this business logic to the core layer.
This is where the Generic Repository is at its best. We can just change the parameter with the class we are working without adding another layer of abstraction in order to be specific on the entity we are working on.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
ClientDb.Query<Country>().ToList() |
CountryController.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System.Linq; | |
using System.Web.Mvc; | |
using ContactBook.Core.Models; | |
using ContactBook.Data.Data; | |
using ContactBook.Core.Repository; | |
namespace ContactBook.MVC.Controllers | |
{ | |
public class CountryController : Controller | |
{ | |
private IGenericRepo ClientDb; | |
public CountryController() | |
{ | |
ClientDb = new EFClientDb(); | |
} | |
public CountryController(IGenericRepo clientDb) | |
{ | |
ClientDb = clientDb; | |
} | |
// GET: /Country/ | |
public ActionResult Index() | |
{ | |
return View(ClientDb.Query<Country>().ToList()); | |
} | |
// GET: /Country/Details/5 | |
public ActionResult Details(int id) | |
{ | |
Country country = ClientDb.GetById<Country>(id); | |
if (country == null) | |
{ | |
return HttpNotFound(); | |
} | |
return View(country); | |
} | |
// GET: /Country/Create | |
public ActionResult Create() | |
{ | |
return View(); | |
} | |
[HttpPost] | |
[ValidateAntiForgeryToken] | |
public ActionResult Create([Bind(Include="Id,CountryName")] Country country) | |
{ | |
if (ModelState.IsValid) | |
{ | |
ClientDb.Add<Country>(country); | |
ClientDb.Save(); | |
return RedirectToAction("Index"); | |
} | |
else | |
{ | |
ModelState.AddModelError("", "Error Saving Record. Please try again..."); | |
} | |
return View(country); | |
} | |
// GET: /Country/Edit/5 | |
public ActionResult Edit(int id) | |
{ | |
Country country = ClientDb.GetById<Country>(id); | |
if (country == null) | |
{ | |
return HttpNotFound(); | |
} | |
return View(country); | |
} | |
[HttpPost] | |
[ValidateAntiForgeryToken] | |
public ActionResult Edit([Bind(Include="Id,CountryName")] Country country) | |
{ | |
if (ModelState.IsValid) | |
{ | |
ClientDb.Update<Country>(country); | |
ClientDb.Save(); | |
return RedirectToAction("Index"); | |
} | |
else | |
{ | |
ModelState.AddModelError("", "Error Saving Record. Please try again..."); | |
} | |
return View(country); | |
} | |
// GET: /Country/Delete/5 | |
public ActionResult Delete(int id) | |
{ | |
Country country = ClientDb.GetById<Country>(id); | |
if (country == null) | |
{ | |
return HttpNotFound(); | |
} | |
return View(country); | |
} | |
// POST: /Country/Delete/5 | |
[HttpPost, ActionName("Delete")] | |
[ValidateAntiForgeryToken] | |
public ActionResult DeleteConfirmed(Country country) | |
{ | |
if (ModelState.IsValid) | |
{ | |
ClientDb.Delete<Country>(country.Id); | |
ClientDb.Save(); | |
return RedirectToAction("Index"); | |
} | |
else | |
{ | |
ModelState.AddModelError("", "Unable to delete record."); | |
} | |
return View(country); | |
} | |
} | |
} |
We can also apply this approach to ClientController. I will leave this to the readers. Try it yourself.
With regards with the views for this controller. It work like a normal mvc views.
THE TEST
This is another endpoint that we could expose the Repository. We can use fake data to test the controllers but we will be using Moq framework to test the CountryController (it much easier).
MoqCountryController.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System.Collections.Generic; | |
using Microsoft.VisualStudio.TestTools.UnitTesting; | |
using System.Linq; | |
using Moq; | |
using ContactBook.Core.Models; | |
using ContactBook.Core.Repository; | |
using ContactBook.MVC.Controllers; | |
using System.Web.Mvc; | |
namespace ContactBook.Test | |
{ | |
[TestClass] | |
public class MoqCountryController | |
{ | |
private Mock<IGenericRepo> GenericToMock; | |
private IList<Country> Countries; | |
private CountryController countryConroller; | |
[TestInitialize] | |
public void TestInitialize() | |
{ | |
GenericToMock = new Mock<IGenericRepo>(); | |
Countries = new List<Country>(); | |
countryConroller = new CountryController(GenericToMock.Object); | |
Countries.Add(new Country { Id = 1, CountryName = "Japan" }); | |
Countries.Add(new Country { Id = 2, CountryName = "Singapore" }); | |
} | |
[TestCleanup] | |
public void TestCleanup() | |
{ | |
GenericToMock = null; | |
Countries = null; | |
countryConroller = null; | |
} | |
#region Test for Index | |
[TestMethod] | |
public void CountryController_Index() | |
{ | |
//Arrange | |
GenericToMock | |
.Setup(r => r.Query<Country>()) | |
.Returns(Countries.AsQueryable()); | |
//Act | |
ViewResult result = countryConroller.Index() as ViewResult; | |
IEnumerable<Country> model = result.Model as IEnumerable<Country>; | |
// Assert | |
Assert.AreEqual(2, model.Count()); | |
GenericToMock.Verify(x => x.Query<Country>(), | |
Times.Exactly(1)); | |
} | |
#endregion End for Index | |
#region Test for Create | |
[TestMethod] | |
public void CountryController_Create() | |
{ | |
//Arrange | |
//Act | |
ViewResult result = countryConroller.Create() as ViewResult; | |
// Assert | |
Assert.IsInstanceOfType(result, typeof(ViewResult)); | |
} | |
[TestMethod] | |
public void CountryController_Create_Record_Redirect_To_Index_On_Success() | |
{ | |
//Arrange | |
var _country = new Country() { Id=4, CountryName = "Netherland" }; | |
// Act | |
var result = countryConroller.Create(_country) as RedirectToRouteResult; | |
// Assert | |
Assert.AreEqual("Index", result.RouteValues["Action"]); | |
GenericToMock.Verify(x => x.Add<Country>(It.IsAny<Country>())); | |
} | |
[TestMethod] | |
public void CountryController_Create_Save_Record_Save_Method_Was_Called_On_Success() | |
{ | |
//Arrange | |
var _country = new Country() { Id = 4, CountryName = "Netherland" }; | |
GenericToMock | |
.Setup(r => r.Save()) | |
.Returns(true); | |
//Act | |
var result = countryConroller.Create(_country) as RedirectToRouteResult; | |
// Assert | |
Assert.AreEqual("Index", result.RouteValues["Action"]); | |
GenericToMock.Verify(x => x.Save()); | |
} | |
[TestMethod] | |
public void CountryController_Create_Record_Return_View_On_Invalid() | |
{ | |
//Arrange | |
var _country = new Country() { Id = 0, CountryName=""}; | |
countryConroller.ViewData.ModelState.Clear(); | |
countryConroller.ModelState.AddModelError("Error","Model is invalid."); | |
//Act | |
var result = countryConroller.Create(_country) as ViewResult; | |
//Assert | |
Assert.AreEqual("", result.ViewName); | |
} | |
[TestMethod] | |
public void CountryController_Create_Record_Return_Same_Model_On_Invalid() | |
{ | |
//Arrange | |
var _country = new Country() { Id = 0, CountryName = "" }; | |
countryConroller.ViewData.ModelState.Clear(); | |
countryConroller.ModelState.AddModelError("Error", "Model is invalid."); | |
//Act | |
var result = countryConroller.Create(_country) as ViewResult; | |
var model = result.Model as Country; | |
//Assert | |
Assert.AreEqual(result.Model, _country); | |
Assert.AreEqual(0, model.Id); | |
//GenericToMock. | |
} | |
#endregion End for Create Test | |
#region Test for Details | |
[TestMethod] | |
public void CountryController_Details() | |
{ | |
//Arrange | |
GenericToMock | |
.Setup(r => r.GetById<Country>(1)) | |
.Returns(new Country{Id=1,CountryName="Japan"}); | |
//Act | |
ViewResult result = countryConroller.Details(1) as ViewResult; | |
Country model = result.Model as Country; | |
// Assert | |
Assert.AreEqual(1, model.Id); | |
Assert.AreEqual("Japan", model.CountryName); | |
GenericToMock.Verify(x => x.GetById<Country>(It.IsAny<int>())); | |
} | |
[TestMethod] | |
public void CountryController_Details_Returns_404_If_No_Country_Code() | |
{ | |
//Arrange | |
//Act | |
var result = countryConroller.Details(5); | |
//Assert | |
Assert.IsInstanceOfType(result, typeof(HttpNotFoundResult)); | |
} | |
#endregion End for Test for Details | |
#region Test for Edit | |
[TestMethod] | |
public void CountryController_Edit_Returns_CountryModel_On_Valid() | |
{ | |
//Arrange | |
GenericToMock | |
.Setup(r => r.GetById<Country>(1)) | |
.Returns(new Country { Id = 1, CountryName = "Japan" }); | |
//Act | |
ViewResult result = countryConroller.Edit(1) as ViewResult; | |
Country model = result.Model as Country; | |
// Assert | |
Assert.AreEqual(1, model.Id); | |
Assert.AreEqual("Japan", model.CountryName); | |
GenericToMock.Verify(x => x.GetById<Country>(It.IsAny<int>())); | |
} | |
[TestMethod] | |
public void CountryController_Edit_Returns_404_If_No_Country_Code_Found() | |
{ | |
//Arrange | |
//Act | |
var result = countryConroller.Edit(5); | |
//Assert | |
Assert.IsInstanceOfType(result, typeof(HttpNotFoundResult)); | |
} | |
[TestMethod] | |
public void CountryController_Edit_Save_Record_Redirect_To_Index_On_Success() | |
{ | |
//Assert | |
var _country = new Country() { Id = 4, CountryName = "Netherland" }; | |
// Act | |
var result = countryConroller.Edit(_country) as RedirectToRouteResult; | |
// Assert | |
Assert.AreEqual("Index", result.RouteValues["Action"]); | |
} | |
[TestMethod] | |
public void CountryController_Edit_Save_Record_Save_Method_Was_Called_On_Success() | |
{ | |
//Arrange | |
var _country = new Country() { Id = 4, CountryName = "Netherland" }; | |
GenericToMock | |
.Setup(r => r.Save()) | |
.Returns(true); | |
//Act | |
var result = countryConroller.Edit(_country) as RedirectToRouteResult; | |
//Assert | |
GenericToMock.Verify(x => x.Save()); | |
} | |
[TestMethod] | |
public void CountryController_Edit_Save_Record_Return_View_On_Invalid_Model() | |
{ | |
//Arrange | |
countryConroller = new CountryController(GenericToMock.Object); | |
var _country = new Country() { Id = 0, CountryName = "" }; | |
countryConroller.ViewData.ModelState.Clear(); | |
countryConroller.ModelState.AddModelError("Error", "Model is invalid."); | |
//Act | |
var result = countryConroller.Edit(_country) as ViewResult; | |
//Assert | |
Assert.AreEqual("", result.ViewName); | |
} | |
[TestMethod] | |
public void CountryController_Edit_Save_Record_Return_Same_Model_On_Invalid() | |
{ | |
//Arrange | |
var _country = new Country() { Id = 0, CountryName = "" }; | |
countryConroller.ViewData.ModelState.Clear(); | |
countryConroller.ModelState.AddModelError("Error", "Model is invalid."); | |
//Act | |
var result = countryConroller.Edit(_country) as ViewResult; | |
var model = result.Model as Country; | |
//Assert | |
Assert.AreEqual(result.Model, _country); | |
Assert.AreEqual(0, model.Id); | |
} | |
#endregion End for Test Edit | |
#region Test for Delete | |
[TestMethod] | |
public void CountryController_Delete_Returns_Same_Model_When_Found() | |
{ | |
//Arrange | |
GenericToMock | |
.Setup(r => r.GetById<Country>(1)) | |
.Returns(new Country { Id = 1, CountryName = "Japan" }); | |
countryConroller = new CountryController(GenericToMock.Object); | |
//Act | |
ViewResult result = countryConroller.Delete(1) as ViewResult; | |
Country model = result.Model as Country; | |
// Assert | |
Assert.AreEqual(1, model.Id); | |
Assert.AreEqual("Japan", model.CountryName); | |
GenericToMock.Verify(x => x.GetById<Country>(It.IsAny<int>())); | |
} | |
[TestMethod] | |
public void CountryController_Delete_Return_httpNoFound_When_Country_Code_Not_Found() | |
{ | |
//Arrange | |
//Act | |
var result = countryConroller.Delete(1); | |
//Assert | |
Assert.IsInstanceOfType(result, typeof(HttpNotFoundResult)); | |
} | |
#endregion End for Delete Method | |
#region Test for DeleteConfirm | |
[TestMethod] | |
public void CountryController_DeleteConfirmed_Redirect_To_Index_On_Success() | |
{ | |
//Assert | |
var _country = new Country() { Id = 4, CountryName = "Netherland" }; | |
// Act | |
var result = countryConroller.DeleteConfirmed(_country) as RedirectToRouteResult; | |
// Assert | |
Assert.AreEqual("Index", result.RouteValues["Action"]); | |
} | |
[TestMethod] | |
public void CountryController_DeleteConfirmed_Post_Action_Returns_RedirectToAction() | |
{ | |
//Arrange | |
GenericToMock.Setup(x => | |
x.Delete<Country>(1)); | |
var _country = new Country() { Id = 1, CountryName = "Japan" }; | |
//Act | |
var result = countryConroller.DeleteConfirmed(_country); | |
//Assert | |
Assert.IsInstanceOfType(result, typeof(RedirectToRouteResult)); | |
} | |
[TestMethod] | |
public void CountryController_DeleteConfirmed_Delete_Method_Was_Called() | |
{ | |
//Arrange | |
var _country = new Country() { Id = 4, CountryName = "Netherland" }; | |
GenericToMock | |
.Setup(r => r.Delete<Country>(4)); | |
//.Returns(true); | |
//Act | |
var result = countryConroller.DeleteConfirmed(_country) as RedirectToRouteResult; | |
//Assert | |
GenericToMock.Verify(x => x.Delete<Country>(It.IsAny<int>())); | |
} | |
[TestMethod] | |
public void CountryController_DeleteConfirmed_Save_Method_Was_Called_On_Success() | |
{ | |
//Arrange | |
var _country = new Country() { Id = 4, CountryName = "Netherland" }; | |
GenericToMock | |
.Setup(r => r.Save()) | |
.Returns(true); | |
//Act | |
var result = countryConroller.Edit(_country) as RedirectToRouteResult; | |
//Assert | |
GenericToMock.Verify(x => x.Save()); | |
} | |
[TestMethod] | |
public void CountryController_DeleteConfirmed_Return_Same_Model_On_Not_Found_Error() | |
{ | |
//Arrange | |
var _country = new Country() { Id = 0, CountryName = "" }; | |
countryConroller.ViewData.ModelState.Clear(); | |
countryConroller.ModelState.AddModelError("Error", "Model is invalid."); | |
//Act | |
var result = countryConroller.DeleteConfirmed(_country) as ViewResult; | |
var model = result.Model as Country; | |
//Assert | |
Assert.AreEqual(result.Model, _country); | |
Assert.AreEqual(0, model.Id); | |
Assert.IsTrue(countryConroller.ViewData.ModelState.Count == 2); | |
} | |
#endregion | |
} | |
} |
That's it! Thank you for time. I hope this article is helpful. Share it with your friends.