EF Core for Enterprise

0
48

Introduction

The design for enterprise architect is a common question in software development and how we can solve this issue in the best way following best practices.

In this guide, we’ll take a look at the common requirements for design of enterprise architect.

Background

The architecture for enterprise application should have the following levels:

  1. Entity Layer: Contains entities (POCOs)
  2. Data Layer: Contains all code related to database access
  3. Business Layer: Contains definitions and validations related to business
  4. Services Layer (optional): Contains invocations for external services (ASMX, WCF, RESTful)
  5. Common: Contains common classes and interfaces for all layers (e.g. Loggers, Mappers, Extensions)
  6. Tests (QA): Contains automated tests
  7. Presentation Layer: This is the UI

Skills Prerequisites

  • OOP (Object Oriented Programming)
  • AOP (Aspect Oriented Programming)
  • ORM (Object Relational Mapping)
  • Design Patterns: Domain Driven Design, Repository & Unit of Work and IoC

Software Prerequisites

  • Visual Studio 2015 with Update 3
  • Local SQL Server instance

Using the Code

Step 01 – Create Database

In this guide, we’ll use a sample database to understand each component in our architecture. This is the script for database:

use master
go

drop database Store
go

create database Store
go

use Store
go

create schema HumanResources
go

create schema Production
go

create schema Sales
go

create table [EventLog]
(
 [EventLogID] int not null identity(1, 1),
 [EventType] int not null,
 [Key] varchar(255) not null,
 [Message] varchar(max) not null,
 [EntryDate] datetime not null
)

create table [ChangeLog]
(
 [ChangeLogID] int not null identity(1, 1),
 [ClassName] varchar(255) not null,
 [PropertyName] varchar(255) not null,
 [Key] varchar(255) not null,
 [OriginalValue] varchar(max) null,
 [CurrentValue] varchar(max) null,
 [UserName] varchar(25) not null,
 [ChangeDate] datetime not null
)

create table [HumanResources].[Employee]
(
 [EmployeeID] int not null identity(1, 1),
 [FirstName] varchar(25) not null,
 [MiddleName] varchar(25) null,
 [LastName] varchar(25) not null,
 [BirthDate] datetime not null,
 [CreationUser] varchar(25) not null,
 [CreationDateTime] datetime not null,
 [LastUpdateUser] varchar(25) null,
 [LastUpdateDateTime] datetime null,
 [Timestamp] rowversion null
)

create table [Production].[ProductCategory]
(
 [ProductCategoryID] int not null identity(1, 1),
 [ProductCategoryName] varchar(100) not null,
 [CreationUser] varchar(25) not null,
 [CreationDateTime] datetime not null,
 [LastUpdateUser] varchar(25) null,
 [LastUpdateDateTime] datetime null,
 [Timestamp] rowversion null
)

create table [Production].[Product]
(
 [ProductID] int not null identity(1, 1),
 [ProductName] varchar(100) not null,
 [ProductCategoryID] int not null,
 [UnitPrice] decimal(8, 4) not null,
 [Description] varchar(255) null,
 [Discontinued] bit not null,
 [CreationUser] varchar(25) not null,
 [CreationDateTime] datetime not null,
 [LastUpdateUser] varchar(25) null,
 [LastUpdateDateTime] datetime null,
 [Timestamp] rowversion null
)

create table [Production].[ProductInventory]
(
 [ProductInventoryID] int not null identity(1, 1),
 [ProductID] int not null,
 [Quantity] int not null,
 [Stocks] int not null,
 [CreationUser] varchar(25) not null,
 [CreationDateTime] datetime not null,
 [LastUpdateUser] varchar(25) null,
 [LastUpdateDateTime] datetime null,
 [Timestamp] rowversion null
)

create table [Sales].[Customer]
(
 [CustomerID] int not null identity(1, 1),
 [CompanyName] varchar(100) null,
 [ContactName] varchar(100) null,
 [CreationUser] varchar(25) not null,
 [CreationDateTime] datetime not null,
 [LastUpdateUser] varchar(25) null,
 [LastUpdateDateTime] datetime null,
 [Timestamp] rowversion null
)

create table [Sales].[Shipper]
(
 [ShipperID] int not null identity(1, 1),
 [CompanyName] varchar(100) null,
 [ContactName] varchar(100) null,
 [CreationUser] varchar(25) not null,
 [CreationDateTime] datetime not null,
 [LastUpdateUser] varchar(25) null,
 [LastUpdateDateTime] datetime null,
 [Timestamp] rowversion null
)

create table [Sales].[OrderStatus]
(
 [OrderStatusID] smallint not null,
 [Description] varchar(100) not null,
 [CreationUser] varchar(25) not null,
 [CreationDateTime] datetime not null,
 [LastUpdateUser] varchar(25) null,
 [LastUpdateDateTime] datetime null,
 [Timestamp] rowversion null
)

create table [Sales].[Order]
(
 [OrderID] int not null identity(1, 1),
 [OrderStatusID] smallint not null,
 [OrderDate] datetime not null,
 [CustomerID] int not null,
 [EmployeeID] int not null,
 [ShipperID] int not null,
 [Total] decimal(12, 4) not null,
 [Comments] varchar(255) null,
 [CreationUser] varchar(25) not null,
 [CreationDateTime] datetime not null,
 [LastUpdateUser] varchar(25) null,
 [LastUpdateDateTime] datetime null,
 [Timestamp] rowversion null
)

create table [Sales].[OrderDetail]
(
 [OrderDetailID] int not null identity(1, 1),
 [OrderID] int not null,
 [ProductID] int not null,
 [ProductName] varchar(255) not null,
 [UnitPrice] decimal(8, 4) not null,
 [Quantity] int not null,
 [Total] decimal(8, 4) not null,
 [CreationUser] varchar(25) not null,
 [CreationDateTime] datetime not null,
 [LastUpdateUser] varchar(25) null,
 [LastUpdateDateTime] datetime null,
 [Timestamp] rowversion null
)
go

alter table [EventLog]
 add constraint EventLog_PK primary key (EventLogID)
go

alter table [ChangeLog]
 add constraint ChangeLog_PK primary key (ChangeLogID)
go

alter table [HumanResources].[Employee]
 add constraint HumanResources_Employee_PK primary key (EmployeeID)
go

alter table [Production].[ProductCategory]
 add constraint Production_ProductCategory_PK primary key (ProductCategoryID)
go

alter table [Production].[Product]
 add constraint Production_Product_PK primary key (ProductID)
go

alter table [Production].[Product]
 add constraint Production_Product_ProductName unique (ProductName)
go

alter table [Production].[ProductInventory]
 add constraint Production_ProductInventory_PK primary key (ProductInventoryID)
go

alter table [Sales].[Customer]
 add constraint Sales_Customer_PK primary key (CustomerID)
go

alter table [Sales].[Shipper]
 add constraint Sales_Shipper_PK primary key (ShipperID)
go

alter table [Sales].[OrderStatus]
 add constraint Sales_OrderStatus_PK primary key (OrderStatusID)
go

alter table [Sales].[Order]
 add constraint Sales_Order_PK primary key (OrderID)
go

alter table [Sales].[OrderDetail]
 add constraint Sales_OrderDetail_PK primary key (OrderDetailID)
go

alter table [Sales].[OrderDetail]
 add constraint Sales_OrderDetail_U unique (OrderID, ProductID)
go

alter table [Production].[Product]
 add constraint Production_Product_ProductCategory foreign key (ProductCategoryID)
 references [Production].[ProductCategory]
go

alter table [Production].[ProductInventory]
 add constraint Production_ProductInventory_Product foreign key (ProductID)
 references [Production].[Product]
go

alter table [Sales].[Order]
 add constraint Sales_Order_OrderStatus foreign key (OrderStatusID)
 references [Sales].[OrderStatus]
go

alter table [Sales].[Order]
 add constraint Sales_Order_Customer foreign key (CustomerID)
 references [Sales].[Customer]
go

alter table [Sales].[Order]
 add constraint Sales_Order_Employee foreign key (EmployeeID)
 references [HumanResources].[Employee]
go

alter table [Sales].[Order]
 add constraint Sales_Order_Shipper foreign key (ShipperID)
 references [Sales].[Shipper]
go

alter table [Sales].[OrderDetail]
 add constraint Sales_OrderDetail_Order foreign key (OrderID)
 references [Sales].[Order]
go

alter table [Sales].[OrderDetail]
 add constraint Sales_OrderDetail_Product foreign key (ProductID)
 references [Production].[Product]
go

create view OrderSummary
as
 select
 OrderHeader.OrderID,
 OrderHeader.OrderDate,
 Customer.CompanyName as CustomerName,
 Employee.FirstName + ' ' + isnull(Employee.MiddleName, '') + ' ' + Employee.LastName as EmployeeName,
 Shipper.CompanyName as ShipperName
 from
 Sales.[Order] OrderHeader
 inner join Sales.Customer Customer
 on OrderHeader.CustomerID = Customer.CustomerID
 inner join HumanResources.Employee Employee
 on OrderHeader.EmployeeID = Employee.EmployeeID
 inner join Sales.Shipper Shipper
 on OrderHeader.ShipperID = Shipper.ShipperID
go

declare @userName varchar(25)
select @userName = 'seed'

insert into [HumanResources].[Employee] values ('John', null, 'Doe', getdate(), @userName, getdate(), null, null, null)

insert [Production].[ProductCategory] values ('PS4 Games', @userName, getdate(), null, null, null)

insert into [Production].[Product] values ('King of Fighters XIV', 1, 59.99, 'KOF XIV', 0, @userName, getdate(), null, null, null)
insert into [Production].[Product] values ('Street Fighter V', 1, 49.99, 'SF V', 0, @userName, getdate(), null, null, null)
insert into [Production].[Product] values ('Guilty Gear', 1, 39.99, 'GG', 0, @userName, getdate(), null, null, null)

insert into [Production].[ProductInventory] values (1, 100000, 100000, @userName, getdate(), null, null, null)
insert into [Production].[ProductInventory] values (2, 100000, 100000, @userName, getdate(), null, null, null)

insert into [Sales].[Customer] values ('Best Buy', 'Colleen Dunn', @userName, getdate(), null, null, null)
insert into [Sales].[Customer] values ('Circuit City', 'Bill McCorey', @userName, getdate(), null, null, null)
insert into [Sales].[Customer] values ('Game Stop', 'Michael Cooper', @userName, getdate(), null, null, null)

insert into [Sales].[Shipper] values ('DHL', 'Ricardo A. Bartra', @userName, getdate(), null, null, null)
insert into [Sales].[Shipper] values ('FedEx', 'Rob Carter', @userName, getdate(), null, null, null)
insert into [Sales].[Shipper] values ('UPS', 'Juan R. Perez', @userName, getdate(), null, null, null)

insert into [Sales].[OrderStatus] values (100, 'Created', @userName, getdate(), null, null, null)
go

Run script on SQL Server instance, now we can generate a database diagram like this:

Database diagram

This is a simple database, only to demonstrate concepts.

Once we have the database, we proceed to define a naming convention:

Identifier Case Example
Namespace PascalCase AdventureWorks
Class name PascalCase ProductViewModel
Interface name I prefix + PascalCase IDatabaseValidator
Method name PascalCase GetOrders
Property name PascalCase Description
Parameter name camelCase connectionString

This convention is very important because it defines the naming guidelines for our architecture.

Step 02 – Core Project

Create a project with name Store.Core for DotNet Core and add the folloging directories:

  1. EntityLayer
  2. DataLayer
  3. DataLayer\Contracts
  4. DataLayer\DataContracts
  5. DataLayer\Mapping
  6. DataLayer\Repositories
  7. BusinessLayer
  8. BusinessLayer\Responses

Inside of Entitylayer, we’ll place all entities, in this context, entity means a class that represents a table or view from database, sometimes entity is named POCO (Plain Old Common language runtime Object) than means a class with only properties not methods nor other things (events); according to wkempf feedback it’s necessary to be clear about POCOs, POCOs can have methods and events and other members but it’s not common to add those members in POCOs.

Inside of DataLayer, we’ll place DbContext and AppSettings because they’re common classes for DataLayer.

Inside of DataLayer\Contracts, we’ll place all interfaces that represent operations catalog, we’re focusing on schemas and we’ll create one interface per schema and Store contract for default schema (dbo).

Inside of DataLayer\DataContracts, we’ll place all object definitions for returned values from Contracts namespace, for now this directory would be empty.

Inside of DataLayer\Mapping, we’ll place all object definition related to mapping a class for database access.

Inside of DataLayer\Repositories, we’ll place the implementations for Contracts definitons.

Inside of EntityLayer and DataLayer\Mapping, we’ll create one directory per schema without including the default schema.

Inside of BusinessLayer, we’ll create the interfaces and implementations for business objects, in this case, the business objects will contain the methods according to use cases (or something similar) and that methods must handle exceptions and other validations related to business.

EntityLayer structure:

Entity layer structure

DataLayer structure:

Data layer structure

One repository includes operations related to that schema, so we have 4 repositories: HumanResources, Production, Sales and Store.

There is only one class for mapping, so there is a class with name StoreEntityMapper because there aren’t Focused DbContexts.

BusinessLayer structure:

Business layer structure

We’ll inspect the code for understanding the concepts but the inspection would be with one object per level because the remaining code is similar.

Architecture: Big Picture

STORAGE (DATABASE) SQL Server  
ENTITY LAYER POCOs BACK-END
DATA LAYER DbContext, Mappings, Contracts, Data Contracts
BUSINESS LAYER Contracts, DataContracts, Exceptions and Loggers
SERVICES LAYER ASMX, WCF, RESTful
COMMON Loggers, Mappers, Extensions
PRESENTATION LAYER UI Frameworks (AngularJS | ReactJS) FRONT-END
USER    

Entity Layer

Order class code:

using System;
using System.Collections.ObjectModel;

namespace Store.Core.EntityLayer.Sales
{
 public class Order : IAuditEntity
 {
 public Order()
 {
 }

 public Order(Int32 orderID)
 {
 OrderID = orderID;
 }

 public Int32? OrderID { get; set; }

 public Int16? OrderStatusID { get; set; }

 public DateTime? OrderDate { get; set; }

 public Int32? CustomerID { get; set; }

 public Int32? EmployeeID { get; set; }

 public Int32? ShipperID { get; set; }

 public Decimal? Total { get; set; }

 public String Comments { get; set; }

 public String CreationUser { get; set; }

 public DateTime? CreationDateTime { get; set; }

 public String LastUpdateUser { get; set; }

 public DateTime? LastUpdateDateTime { get; set; }

 public Byte[] Timestamp { get; set; }

 public virtual OrderStatus OrderStatusFk { get; set; }

 public virtual Collection<OrderDetail> OrderDetails { get; set; }
 }
}

Please take a look at POCOs, we’re using nullable types instead of native types because nullable are easy to evaluate if property has value or not, that’s more similar to database model.

In EntityLayer there are two interfaces: IEntity and IAuditEntity, IEntity represents all entities in our application and IAuditEntity represents all entities that allows to save audit information: create and last update; as special point if we have mapping for views, those classes do not implement IAuditEntity because a view doesn’t allow insert, update and elete operations.

Data Layer

StoreDbContext class code:

using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Store.Core.DataLayer.Mapping;

namespace Store.Core.DataLayer
{
 public class StoreDbContext : Microsoft.EntityFrameworkCore.DbContext
 {
 public StoreDbContext(IOptions<AppSettings> appSettings, IEntityMapper entityMapper)
 {
 ConnectionString = appSettings.Value.ConnectionString;
 EntityMapper = entityMapper;
 }

 public String ConnectionString { get; }

 public IEntityMapper EntityMapper { get; }

 protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
 {
 optionsBuilder.UseSqlServer(ConnectionString);
 
 base.OnConfiguring(optionsBuilder);
 }

 protected override void OnModelCreating(ModelBuilder modelBuilder)
 {
 EntityMapper.MapEntities(modelBuilder);
 
 base.OnModelCreating(modelBuilder);
 }
 }
}

OrderMap class code:

using Microsoft.EntityFrameworkCore;
using Store.Core.EntityLayer.Sales;

namespace Store.Core.DataLayer.Mapping.Sales
{
 public class OrderMap : IEntityMap
 {
 public void Map(ModelBuilder modelBuilder)
 {
 var entity = modelBuilder.Entity<Order>();

 entity.ToTable("Order", "Sales");

 entity.HasKey(p => p.OrderID);

 entity.Property(p => p.OrderID).UseSqlServerIdentityColumn();

 entity.Property(p => p.Timestamp).ValueGeneratedOnAddOrUpdate().IsConcurrencyToken();
 }
 }
}

Repository class code:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using Store.Core.EntityLayer;

namespace Store.Core.DataLayer.Repositories
{
 public abstract class Repository
 {
 protected IUserInfo UserInfo;
 protected StoreDbContext DbContext;

 public Repository(IUserInfo userInfo, StoreDbContext dbContext)
 {
 UserInfo = userInfo;
 DbContext = dbContext;
 }

 protected IQueryable<TEntity> Paging<TEntity>(Int32 pageSize = 0, Int32 pageNumber = 0) where TEntity : class, IEntity
 {
 var query = DbContext.Set<TEntity>().AsQueryable();

 return pageSize > 0 && pageNumber > 0 ? query.Skip((pageNumber - 1) * pageSize).Take(pageSize) : query;
 }

 protected IQueryable<T> Paging<T>(IQueryable<T> query, Int32 pageSize = 0, Int32 pageNumber = 0) where T : class
 {
 return pageSize > 0 && pageNumber > 0 ? query.Skip((pageNumber - 1) * pageSize).Take(pageSize) : query;
 }

 protected virtual void Add<TEntity>(TEntity entity) where TEntity : class, IEntity
 {
 var cast = entity as IAuditEntity;

 if (cast != null)
 {
 cast.CreationUser = UserInfo.Name;

 if (!cast.CreationDateTime.HasValue)
 {
 cast.CreationDateTime = DateTime.Now;
 }
 }

 DbContext.Set<TEntity>().Add(entity);
 }

 protected virtual void Update<TEntity>(TEntity entity) where TEntity : class, IEntity
 {
 var cast = entity as IAuditEntity;

 if (cast != null)
 {
 cast.LastUpdateUser = UserInfo.Name;

 if (!cast.LastUpdateDateTime.HasValue)
 {
 cast.LastUpdateDateTime = DateTime.Now;
 }
 }
 }

 protected virtual void Remove <TEntity>(TEntity entity) where TEntity : class, IEntity
 {
 DbContext.Set<TEntity>().Remove(entity);
 }

 protected virtual IEnumerable<ChangeLog> GetChanges()
 {
 foreach (var entry in DbContext.ChangeTracker.Entries())
 {
 if (entry.State == EntityState.Modified)
 {
 var entityType = entry.Entity.GetType();

 foreach (var property in entityType.GetTypeInfo().DeclaredProperties)
 {
 var originalValue = entry.Property(property.Name).OriginalValue;
 var currentValue = entry.Property(property.Name).CurrentValue;

 if (String.Concat(originalValue) != String.Concat(currentValue))
 {
 
 var key = entry.Entity.GetType().GetProperties()[0].GetValue(entry.Entity, null).ToString();

 yield return new ChangeLog
 {
 ClassName = entityType.Name,
 PropertyName = property.Name,
 Key = key,
 OriginalValue = originalValue == null ? String.Empty : originalValue.ToString(),
 CurrentValue = currentValue == null ? String.Empty : currentValue.ToString(),
 UserName = UserInfo.Name,
 ChangeDate = DateTime.Now
 };
 }
 }
 }
 }
 }

 public Int32 CommitChanges()
 {
 var dbSet = DbContext.Set<ChangeLog>();

 foreach (var change in GetChanges().ToList())
 {
 dbSet.Add(change);
 }

 return DbContext.SaveChanges();
 }

 public Task<Int32> CommitChangesAsync()
 {
 var dbSet = DbContext.Set<ChangeLog>();

 foreach (var change in GetChanges().ToList())
 {
 dbSet.Add(change);
 }

 return DbContext.SaveChangesAsync();
 }
 }
}

How about Unit of Work? in EF 6.x was usually create a repository class and unit of work class: repository provided operations for database access and unit of work provided operations to save changes in database; but in EF Core it’s a common practice to have only repositories and no unit of work; anyway for this code we have added two methods in Repository class: CommitChanges and CommitChangesAsync, so just to make sure that inside of all data writing mehotds in repositories call CommitChanges or CommitChangesAsync and with that design we have two definitions working on our architecture.

On DbContext for this version, we’re using DbSet on the fly instead of declaring DbSet properties in DbContext. I think that it’s more about architect preferences I prefer to use on the fly DbSet because I don’t worry about adding all DbSets to DbContext but this style would be changed if you considered it’s more accurate to use declarated DbSet properties in DbContext.

How about async operations? In previous versions of this post I said we’ll implement async operations in the last level: REST API, but I was wrong about that because .NET Core it’s more about async programming, so the best decision is handle all database operations in async way using the Async methods that EF Core provides.

We can take a look on Repository class, there are two methods: Add and Update, for this example Order class has audit properties: CreationUser, CreationDateTime, LastUpdateUser and LastUpdateDateTime also Order class implements IAuditEntity interface, that interface is used to set values for audit properties

For the current version of this article, we going to omit the services layer but in some cases, there is a layer that includes the connection for external services (ASMX, WCF and RESTful).

Data Layer: Stored Procedures versus LINQ Queries

In data layer, there is a very interesting point: How we can use stored procedures? For the current version of EF Core, there isn’t support for stored procedures, so we can’t use them in a native way, inside of DbSet, there is a method to execute a query but that works for stored procedures not return a result set (columns), we can add some extension methods and add packages to use classic ADO.NET, so in that case we need to handle the dynamic creation of objects to represent the stored procedure result; that makes sense? if we consume a procedure with name GetOrdersByMonth and that procedure returns a select with 7 columns, to handle all results in the same way, we’ll need to define objects to represent those results, that objects must define inside of DataLayer\DataContracts namespace according to our naming convention.

Inside of enterprise environment, a common discussion is about LINQ queries or stored procedures. According to my experience, I think the best way to solve that question is: review design conventions with architect and database administrator; nowadays, it’s more common to use LINQ queries in async mode instead of stored procedures but sometimes some companies have restrict conventions and do not allow to use LINQ queries, so it’s required to use stored procedure and we need to make our architecture flexible because we don’t say to developer manager “the business logic will be rewrite because EF Core doesn’t allow to invoke stored procedures”

As we can see until now, assuming we have the extension methods for EF Core to invoke stored procedures and data contracts to represent results from stored procedures invocations, Where do we place those methods? It’s preferable to use the same convention so we’ll add those methods inside of contracts and repositories; just to be clear if we have procedures named Sales.GetCustomerOrdersHistory and HumanResources.DisableEmployee; we must to place methods inside of Sales and HumanResources repositories.

The previous concept applies in the same way for views in database. In addition, we only need to check that repositories do not allow add, update and delete operations for views.

Change Tracking: inside of Repository class there is a method with name GetChanges, that method get all changes from DbContext through ChangeTracker and returns all changes, so those values are saved in ChangeLog table in CommitChanges method. You can update one existing entity with business object, later you can check your ChangeLog table:

ChangeLogID ClassName PropertyName Key OriginalValue CurrentValue UserName ChangeDate
----------- ------------ -------------- ---- ---------------------- ---------------------- ---------- -----------------------
1 Employee FirstName 1 John John III admin 2017-02-19 21:49:51.347
2 Employee MiddleName 1 Smith III admin 2017-02-19 21:49:51.347
3 Employee LastName 1 Doe Doe III admin 2017-02-19 21:49:51.347
4 Employee BirthDate 1 2/19/2017 8:01:45 PM 1/6/2017 12:00:00 AM admin 2017-02-19 21:49:51.350

(4 row(s) affected)

As we can see all changes made in entities will be saved on this table, as a future improvement we’ll need to add exclusions for this change log. In this guide we’re working with SQL Server, as I know there is a way to enable change tracking from database side but in this post I’m showing to you how you can implement this feature from back-end; if this feature is on back-end or database side will be a decision from your leader. In the timeline we can check on this table all changes in entities, some entities have audit properties but those properties only reflect the user and date for creation and last update but do not provide full details about how data change.

Business Layer

BusinessObject class code:

using System;
using Store.Core.BusinessLayer.Contracts;
using Store.Core.Common;
using Store.Core.DataLayer;
using Store.Core.DataLayer.Contracts;
using Store.Core.DataLayer.Repositories;

namespace Store.Core.BusinessLayer
{
 public abstract class BusinessObject : IBusinessObject
 {
 protected ILog Logger;
 protected IUserInfo UserInfo;
 protected Boolean Disposed;
 protected StoreDbContext DbContext;
 protected IHumanResourcesRepository m_humanResourcesRepository;
 protected IProductionRepository m_productionRepository;
 protected ISalesRepository m_salesRepository;

 public BusinessObject(IUserInfo userInfo, StoreDbContext dbContext)
 {
 Logger = new Log();
 UserInfo = userInfo;
 DbContext = dbContext;
 }

 public void Dispose()
 {
 if (!Disposed)
 {
 if (DbContext != null)
 {
 DbContext.Dispose();

 Disposed = true;
 }
 }
 }

 protected IHumanResourcesRepository HumanResourcesRepository
 {
 get
 {
 return m_humanResourcesRepository ?? (m_humanResourcesRepository = new HumanResourcesRepository(UserInfo, DbContext));
 }
 }

 protected IProductionRepository ProductionRepository
 {
 get
 {
 return m_productionRepository ?? (m_productionRepository = new ProductionRepository(UserInfo, DbContext));
 }
 }

 protected ISalesRepository SalesRepository
 {
 get
 {
 return m_salesRepository ?? (m_salesRepository = new SalesRepository(UserInfo, DbContext));
 }
 }
 }
}

SalesBusinessObject class code:

using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Store.Core.BusinessLayer.Contracts;
using Store.Core.BusinessLayer.Responses;
using Store.Core.DataLayer;
using Store.Core.DataLayer.DataContracts;
using Store.Core.EntityLayer.Production;
using Store.Core.EntityLayer.Sales;

namespace Store.Core.BusinessLayer
{
 public class SalesBusinessObject : BusinessObject, ISalesBusinessObject
 {
 public SalesBusinessObject(ILogger logger, IUserInfo userInfo, StoreDbContext dbContext)
 : base(logger, userInfo, dbContext)
 {
 }

 public async Task<IListModelResponse<Customer>> GetCustomersAsync(Int32 pageSize, Int32 pageNumber)
 {
 Logger?.LogInformation("{0} has been invoked", nameof(GetCustomersAsync));

 var response = new ListModelResponse<Customer>() as IListModelResponse<Customer>;

 try
 {
 response.Model = await SalesRepository.GetCustomers(pageSize, pageNumber).ToListAsync();
 }
 catch (Exception ex)
 {
 response.SetError(ex, Logger);
 }

 return response;
 }

 public async Task<IListModelResponse<Shipper>> GetShippersAsync(Int32 pageSize, Int32 pageNumber)
 {
 Logger?.LogInformation("{0} has been invoked", nameof(GetShippersAsync));

 var response = new ListModelResponse<Shipper>() as IListModelResponse<Shipper>;

 try
 {
 response.Model = await SalesRepository.GetShippers(pageSize, pageNumber).ToListAsync();
 }
 catch (Exception ex)
 {
 response.SetError(ex, Logger);
 }

 return response;
 }

 public async Task<IListModelResponse<OrderInfo>> GetOrdersAsync(Int32 pageSize, Int32 pageNumber, Int32? customerID = null, Int32? employeeID = null, Int32? shipperID = null)
 {
 Logger?.LogInformation("{0} has been invoked", nameof(GetOrdersAsync));

 var response = new ListModelResponse<OrderInfo>() as IListModelResponse<OrderInfo>;

 try
 {
 response.PageSize = pageSize;
 response.PageNumber = pageNumber;

 response.Model = await SalesRepository.GetOrders(pageSize, pageNumber, customerID, employeeID, shipperID).ToListAsync();
 }
 catch (Exception ex)
 {
 response.SetError(ex, Logger);
 }

 return response;
 }

 public async Task<ISingleModelResponse<Order>> GetOrderAsync(Int32 id)
 {
 Logger?.LogInformation("{0} has been invoked", nameof(GetOrderAsync));

 var response = new SingleModelResponse<Order>() as ISingleModelResponse<Order>;

 try
 {
 response.Model = await SalesRepository.GetOrderAsync(new Order(id));
 }
 catch (Exception ex)
 {
 response.SetError(ex, Logger);
 }

 return response;
 }

 public async Task<ISingleModelResponse<Order>> CreateOrderAsync(Order header, OrderDetail[] details)
 {
 Logger?.LogInformation("{0} has been invoked", nameof(CreateOrderAsync));

 var response = new SingleModelResponse<Order>() as ISingleModelResponse<Order>;

 try
 {
 using (var transaction = await DbContext.Database.BeginTransactionAsync())
 {
 var warehouses = await ProductionRepository.GetWarehouses().ToListAsync();

 try
 {
 foreach (var detail in details)
 {
 var product = await ProductionRepository.GetProductAsync(new Product { ProductID = detail.ProductID });

 if (product == null)
 {
 throw new NonExistingProductException(
 String.Format("Sent order has a non existing product with ID: '{0}', order has been cancelled.", detail.ProductID)
 );
 }
 else
 {
 detail.ProductName = product.ProductName;
 }

 if (product.Discontinued == true)
 {
 throw new AddOrderWithDiscontinuedProductException(
 String.Format("Product with ID: '{0}' is discontinued, order has been cancelled.", product.ProductID)
 );
 }

 detail.UnitPrice = product.UnitPrice;
 detail.Total = product.UnitPrice * detail.Quantity;
 }

 header.Total = details.Sum(item => item.Total);

 await SalesRepository.AddOrderAsync(header);

 foreach (var detail in details)
 {
 detail.OrderID = header.OrderID;

 await SalesRepository.AddOrderDetailAsync(detail);

 var lastInventory = ProductionRepository
 .GetProductInventories()
 .Where(item => item.ProductID == detail.ProductID)
 .OrderByDescending(item => item.CreationDateTime)
 .FirstOrDefault();

 var stocks = lastInventory == null ? 0 : lastInventory.Stocks - detail.Quantity;

 var productInventory = new ProductInventory
 {
 ProductID = detail.ProductID,
 WarehouseID = warehouses.First().WarehouseID,
 CreationDateTime = DateTime.Now,
 Quantity = detail.Quantity * -1,
 Stocks = stocks
 };

 await ProductionRepository.AddProductInventoryAsync(productInventory);
 }

 response.Model = header;

 transaction.Commit();
 }
 catch (Exception ex)
 {
 transaction.Rollback();

 throw ex;
 }
 }
 }
 catch (Exception ex)
 {
 response.SetError(ex, Logger);
 }

 return response;
 }

 public async Task<ISingleModelResponse<Order>> CloneOrderAsync(Int32 id)
 {
 Logger?.LogInformation("{0} has been invoked", nameof(CloneOrderAsync));

 var response = new SingleModelResponse<Order>() as ISingleModelResponse<Order>;

 try
 {
 var entity = await SalesRepository.GetOrderAsync(new Order(id));

 if (entity != null)
 {
 response.Model = new Order();

 response.Model.OrderID = entity.OrderID;
 response.Model.OrderDate = entity.OrderDate;
 response.Model.CustomerID = entity.CustomerID;
 response.Model.EmployeeID = entity.EmployeeID;
 response.Model.ShipperID = entity.ShipperID;
 response.Model.Total = entity.Total;
 response.Model.Comments = entity.Comments;

 if (entity.OrderDetails != null && entity.OrderDetails.Count > 0)
 {
 foreach (var detail in entity.OrderDetails)
 {
 response.Model.OrderDetails.Add(new OrderDetail
 {
 ProductID = detail.ProductID,
 ProductName = detail.ProductName,
 UnitPrice = detail.UnitPrice,
 Quantity = detail.Quantity,
 Total = detail.Total
 });
 }
 }
 }
 }
 catch (Exception ex)
 {
 response.SetError(ex, Logger);
 }

 return response;
 }

 public async Task<ISingleModelResponse<Order>> RemoveOrderAsync(Int32 id)
 {
 Logger?.LogInformation("{0} has been invoked", nameof(RemoveOrderAsync));

 var response = new SingleModelResponse<Order>() as ISingleModelResponse<Order>;

 try
 {
 response.Model = await SalesRepository.GetOrderAsync(new Order(id));

 if (response.Model?.OrderDetails.Count > 0)
 {
 throw new ForeignKeyDependencyException(
 String.Format("Order with ID: {0} cannot be deleted, because has dependencies. Please contact to technical support for more details", id)
 );
 }
 }
 catch (Exception ex)
 {
 response.SetError(ex, Logger);
 }

 return response;
 }
 }
}

Business Layer: Handle Related Aspects To Business

  1. Logging: we need to have a logger object, that means an object that logs on text file, database, email, etc. all events in our architecture; we can create our own logger implementation or choose an existing log. We have added logging with package Microsoft.Extensions.Logging, in this way we’re using the default log system in .NET Core, we can use another log mechanism but at this moment we’ll use this logger, inside of every method in controllers and business objects, there is a code line like this: Logger?.LogInformation("{0} has been invoked", nameof(GetOrders));, in this way we make sure invoke logger if is a valid instance and ths using of nameof operator to retrieve the name of member without use magic strings, after we’ll add code to save all logs into database.
  2. Business exceptions: The best way to handle messaging to user is with custom exceptions, inside of business layer, we’ll add definitions for exceptions to represent all handle errors in architecture.
  3. Transactions: as we can see inside of Sales business object, we have implemented transaction to handle multiple changes in our database; inside of CreateOrder method, we invoke methods from repositories, inside of repositories we don’t have any transactions because the business object is the responsibility for transactional process, also we added logic to handle exceptions related to business with custom messages because we need to provide a friendly message to the end-user.
  4. There is a CloneOrder method, this method provides a copy from existing order, this is a common requirement on ERP because it’s more easy create a new order but adding some modifications instead of create the whole order there are cases where the sales agent create a new order but removing 1 or 2 lines from details or adding 1 or 2 details, anyway never let to front-end developer to add this logic in UI, the API must to provide this feature.

In BusinessLayer it’s better to have custom exceptions for represent errors instead of send simple string messages to client, obviously the custom exception must have a message but in logger there will be a reference about custom exception. For this architecture these are the custom exceptions:

Business Exceptions
Name Description
AddOrderWithDiscontinuedProductException Represents an exception in add order with a discontinued product
DuplicatedProductNameException Represents an exception in add product name with existing name
NonExistingProductException Represents an exception in add order with non existing product
ForeignKeyDependencyException Represents an exception in delete order

Step 03 – Putting All Code Together

We create a StoreDbContext instance, that instance uses the connection string from AppSettings and inside of OnModelCreating method, there is a call of MapEntities method for EntityMapper instance, this is code in that way because it’s more a stylish way to mapping entities instead of adding a lot of lines inside of OnModelCreating.

Later, for example, we create an instance of SalesBusinessObject passing a valid instance of StoreDbContext and then we can access business object’s operations.

For this architecture implementation, we are using the DotNet naming conventions: PascalCase for classes, interfaces and methods; camelCase for parameters.

This is an example of how we can retrieve a list of orders list:

var logger = LoggerMocker.GetLogger<ISalesBusinessObject>();

var userInfo = new UserInfo { Name = "admin" } as IUserInfo;

var appSettings = new AppSettings
{
 ConnectionString = "server=(local);database=Store;integrated security=yes; "
};

var entityMapper = new StoreEntityMapper() as IEntityMapper;

using (var businessObject = new SalesBusinessObject(logger, userInfo, new StoreDbContext(appSettings, entityMapper)) as ISalesBusinessObject)
{
 var pageSize = 10;
 var pageNumber = 1;

 var response = businessObject.GetOrders(pageSize, pageNumber);

 
 var valid = !response.DidError;
}

As we can see, the CreateOrder method in SalesBusinessObject handles all changes inside of a transaction, if there is an error, the transaction is rollback, otherwise is commit.

This code is the minimum requirements for an enterprise architect, for incoming versions of this tutorial, I’ll include BusinessLayer, integration with Web API and unit tests.

Step 04 – Add Unit Tests

Open a terminal window in your working directory and follow these steps to create unit tests for current project:

  1. Create a directory in Store.Core with name test.
  2. Change to test directory.
  3. Create a directory with name Store.Core.Tests.
  4. Change to Store.Core.Tests directory
  5. Run this command: dotnet new -t xunittest
  6. Run this command: dotnet restore
  7. Later, add tests project to current solution, creating a new solution item with name test and inside of that solution item, add an existing project.
  8. Add reference to Store.Core project and save changes to rebuild.

Now, add a file with name SalesBusinessObjectTests and add this code to new file:

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Store.Core.EntityLayer.Sales;
using Xunit;

namespace Store.Core.Tests
{
 public class SalesBusinessObjectTests
 {
 [Fact]
 public async Task TestGetCustomers()
 {
 
 using (var businessObject = BusinessObjectMocker.GetSalesBusinessObject())
 {
 var pageSize = 10;
 var pageNumber = 1;

 
 var response = await businessObject.GetCustomersAsync(pageSize, pageNumber);

 
 Assert.False(response.DidError);
 }
 }

 [Fact]
 public async Task TestGetShippers()
 {
 
 using (var businessObject = BusinessObjectMocker.GetSalesBusinessObject())
 {
 var pageSize = 10;
 var pageNumber = 1;

 
 var response = await businessObject.GetShippersAsync(pageSize, pageNumber);

 
 Assert.False(response.DidError);
 }
 }

 [Fact]
 public async Task TestGetOrders()
 {
 
 using (var businessObject = BusinessObjectMocker.GetSalesBusinessObject())
 {
 var pageSize = 10;
 var pageNumber = 1;

 
 var response = await businessObject.GetOrdersAsync(pageSize, pageNumber);

 
 Assert.False(response.DidError);
 }
 }

 [Fact]
 public async Task TestCreateOrder()
 {
 
 using (var businessObject = BusinessObjectMocker.GetSalesBusinessObject())
 {
 var header = new Order();

 header.OrderDate = DateTime.Now;
 header.OrderStatusID = 100;
 header.CustomerID = 1;
 header.EmployeeID = 1;
 header.ShipperID = 1;

 var details = new List<OrderDetail>();

 details.Add(new OrderDetail { ProductID = 1, Quantity = 1 });

 
 var response = await businessObject.CreateOrderAsync(header, details.ToArray());

 
 Assert.False(response.DidError);
 }
 }

 [Fact]
 public async Task TestUpdateOrder()
 {
 
 using (var businessObject = BusinessObjectMocker.GetSalesBusinessObject())
 {
 var id = 1;

 
 var response = await businessObject.GetOrderAsync(id);

 
 Assert.False(response.DidError);
 }
 }

 [Fact]
 public async Task TestRemoveOrder()
 {
 
 using (var businessObject = BusinessObjectMocker.GetSalesBusinessObject())
 {
 var id = 600;

 
 var response = await businessObject.RemoveOrderAsync(id);

 
 Assert.True(response.DidError);
 Assert.True(response.ErrorMessage == String.Format("Order with ID: {0} cannot be deleted, because has dependencies. Please contact to technical support for more details", id));
 }
 }
 }
}

Now in the same window terminal, we need to run the following command: dotnet test and if everything works fine, we have done a good work at this point 🙂

Step 05 – Add Mocks

Open a terminal window in your working directory and follow these steps to create unit tests for current project:

  1. Go to test directory in Store.Core.
  2. Create a directory with name Store.Core.Mocks.
  3. Change to Store.Core.Mocks directory
  4. Run this command: dotnet new -t xunittest
  5. Run this command: dotnet restore
  6. Later, add tests project to current solution, creating a new solution item with name test and inside of that solution item, add an existing project.
  7. Add reference to Store.Core project and save changes to rebuild.

Now, add a file with name OrderMockingTests and add this code to new file:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Store.Core.EntityLayer.Sales;
using Xunit;

namespace Store.Core.Mocks
{
 public class OrderMockingTests
 {
 private async Task CreateData(DateTime startDate, DateTime endDate, Int32 ordersLimitPerDay)
 {
 var date = new DateTime(startDate.Year, startDate.Month, startDate.Day);

 while (date <= endDate)
 {
 if (date.DayOfWeek != DayOfWeek.Sunday)
 {
 var random = new Random();

 var salesBusinessObject = BusinessObjectMocker.GetSalesBusinessObject();
 var humanResourcesBusinessObject = BusinessObjectMocker.GetHumanResourcesBusinessObject();
 var productionBusinessObject = BusinessObjectMocker.GetProductionBusinessObject();

 var pageSize = 10;
 var pageNumber = 1;

 var customerResponse = await salesBusinessObject.GetCustomersAsync(pageSize, pageNumber);
 var employeesResponse = await humanResourcesBusinessObject.GetEmployeesAsync(pageSize, pageNumber);
 var shippersResponse = await salesBusinessObject.GetShippersAsync(pageSize, pageNumber);
 var productsResponse = await productionBusinessObject.GetProductsAsync(pageSize, pageNumber);

 var customers = customerResponse.Model.ToList();
 var employees = employeesResponse.Model.ToList();
 var shippers = shippersResponse.Model.ToList();
 var products = productsResponse.Model.ToList();

 for (var i = 0; i < ordersLimitPerDay; i++)
 {
 var header = new Order();

 var selectedCustomer = random.Next(0, customers.Count - 1);
 var selectedEmployee = random.Next(0, employees.Count - 1);
 var selectedShipper = random.Next(0, shippers.Count - 1);

 header.OrderDate = date;
 header.OrderStatusID = 100;
 header.CustomerID = customers[selectedCustomer].CustomerID;
 header.EmployeeID = employees[selectedEmployee].EmployeeID;
 header.ShipperID = shippers[selectedShipper].ShipperID;
 header.CreationDateTime = date;

 var details = new List<OrderDetail>();

 var detailsCount = random.Next(1, 3);

 for (var j = 0; j < detailsCount; j++)
 {
 var detail = new OrderDetail
 {
 ProductID = products[random.Next(0, products.Count - 1)].ProductID,
 Quantity = (Int16)random.Next(1, 3)
 };

 if (details.Count > 0 && details.Where(item => item.ProductID == detail.ProductID).Count() == 1)
 {
 continue;
 }

 details.Add(detail);
 }

 await salesBusinessObject.CreateOrderAsync(header, details.ToArray());
 }

 salesBusinessObject.Dispose();
 humanResourcesBusinessObject.Dispose();
 productionBusinessObject.Dispose();
 }

 date = date.AddDays(1);
 }
 }

 [Fact]
 public async Task CreateOrders()
 {
 await CreateData(
 startDate: new DateTime(DateTime.Now.Year, 1, 1),
 endDate: new DateTime(DateTime.Now.Year, DateTime.Now.Month, DateTime.DaysInMonth(DateTime.Now.Year, DateTime.Now.Month)),
 ordersLimitPerDay: 10
 );
 }
 }
}

Now in the same window terminal, we need to run the following command: dotnet test and if everything works fine, we can check in our database the data for Order, OrderDetail and ProductInventory tables.

How data mocker works? set a range for dates and a limit of orders per day, then iterate all days in range date except on sunday beacuse we’re assuming create order is not allowed on sundays; then create the instance of DbContext and Business Object, arrange the data using a random to get data from customers, shippers, employees and products lists; then invoke the CreateOrder method with parameters.

You can adjust the range for dates and orders per day to generate more data according to your requirements, once the mocker it’s already finish you can check the data on your database with Management Studio

Step 06 – Add Web API

Now in solution explorer add a Web API project with name Store.API and add references to Store.Core project, add a controller with name Sales and add this code:

using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Store.API.Extensions;
using Store.API.ViewModels;
using Store.Core.BusinessLayer.Contracts;
using Store.Core.BusinessLayer.Responses;

namespace Store.API.Controllers
{
 [Route("api/[controller]")]
 public class SalesController : Controller
 {
 protected ILogger Logger;
 protected IHumanResourcesBusinessObject HumanResourcesBusinessObject;
 protected IProductionBusinessObject ProductionBusinessObject;
 protected ISalesBusinessObject SalesBusinessObject;

 public SalesController(ILogger<SalesController> logger, IHumanResourcesBusinessObject humanResourcesBusinessObject, IProductionBusinessObject productionBusinessObject, ISalesBusinessObject salesBusinessObject)
 {
 Logger = logger;
 HumanResourcesBusinessObject = humanResourcesBusinessObject;
 ProductionBusinessObject = productionBusinessObject;
 SalesBusinessObject = salesBusinessObject;
 }

 protected override void Dispose(Boolean disposing)
 {
 SalesBusinessObject?.Dispose();

 base.Dispose(disposing);
 }

 [HttpGet]
 [Route("Order")]
 public async Task<IActionResult> GetOrders(Int32? pageSize = 10, Int32? pageNumber = 1)
 {
 Logger?.LogInformation("{0} has been invoked", nameof(GetOrders));

 var response = await SalesBusinessObject.GetOrdersAsync((Int32)pageSize, (Int32)pageNumber);

 return response.ToHttpResponse();
 }

 [HttpGet]
 [Route("Order/{id}")]
 public async Task<IActionResult> GetOrder(Int32 id)
 {
 Logger?.LogInformation("{0} has been invoked", nameof(GetOrder));

 var response = await SalesBusinessObject.GetOrderAsync(id);

 return response.ToHttpResponse();
 }

 [HttpGet]
 [Route("CreateOrderViewModel")]
 public async Task<IActionResult> GetCreateOrderViewModel()
 {
 Logger?.LogInformation("{0} has been invoked", nameof(GetCreateOrderViewModel));

 var response = new SingleModelResponse<CreateOrderViewModel>() as ISingleModelResponse<CreateOrderViewModel>;

 var customersResponse = await SalesBusinessObject.GetCustomersAsync(0, 0);

 response.Model.Customers = customersResponse.Model.Select(item => new CustomerViewModel(item));

 var employeesResponse = await HumanResourcesBusinessObject.GetEmployeesAsync(0, 0);

 response.Model.Employees = employeesResponse.Model.Select(item => new EmployeeViewModel(item));

 var shippersResponse = await SalesBusinessObject.GetShippersAsync(0, 0);

 response.Model.Shippers = shippersResponse.Model.Select(item => new ShipperViewModel(item));

 var productsResponse = await ProductionBusinessObject.GetProductsAsync(0, 0);

 response.Model.Products = productsResponse.Model.Select(item => new ProductViewModel(item));

 return response.ToHttpResponse();
 }

 [HttpPost]
 [Route("Order")]
 public async Task<IActionResult> CreateOrder([FromBody] OrderViewModel value)
 {
 Logger?.LogInformation("{0} has been invoked", nameof(CreateOrder));

 var response = await SalesBusinessObject.CreateOrderAsync(value.GetOrder(), value.GetOrderDetails().ToArray());

 return response.ToHttpResponse();
 }

 [HttpGet]
 [Route("CloneOrder/{id}")]
 public async Task<IActionResult> CloneOrder(Int32 id)
 {
 Logger?.LogInformation("{0} has been invoked", nameof(CloneOrder));

 var response = await SalesBusinessObject.CloneOrderAsync(id);

 return response.ToHttpResponse();
 }

 [HttpDelete]
 [Route("Order/{id}")]
 public async Task<IActionResult> RemoveOrder(Int32 id)
 {
 Logger?.LogInformation("{0} has been invoked", nameof(RemoveOrder));

 var response = await SalesBusinessObject.RemoveOrderAsync(id);

 return response.ToHttpResponse();
 }
 }
}

Don’t forget to set up all dependencies in Startup.cs file:

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Serialization;
using Store.Core;
using Store.Core.BusinessLayer;
using Store.Core.BusinessLayer.Contracts;
using Store.Core.DataLayer;
using Store.Core.DataLayer.Mapping;

namespace Store.API
{
 public class Startup
 {
 public Startup(IHostingEnvironment env)
 {
 var builder = new ConfigurationBuilder()
 .SetBasePath(env.ContentRootPath)
 .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
 .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true)
 .AddEnvironmentVariables();
 Configuration = builder.Build();
 }

 public IConfigurationRoot Configuration { get; }

 
 public void ConfigureServices(IServiceCollection services)
 {
 
 services
 .AddMvc()
 .AddJsonOptions(a => a.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver());

 services.AddEntityFrameworkSqlServer().AddDbContext<StoreDbContext>();

 services.AddScoped<IEntityMapper, StoreEntityMapper>();

 services.AddScoped<IUserInfo, UserInfo>();

 services.AddScoped<ILogger, Logger<SalesBusinessObject>>();
 services.AddScoped<ILogger, Logger<ProductionBusinessObject>>();
 services.AddScoped<ILogger, Logger<HumanResourcesBusinessObject>>();

 services.AddScoped<IHumanResourcesBusinessObject, HumanResourcesBusinessObject>();
 services.AddScoped<IProductionBusinessObject, ProductionBusinessObject>();
 services.AddScoped<ISalesBusinessObject, SalesBusinessObject>();

 services.AddOptions();

 services.Configure<AppSettings>(Configuration.GetSection("AppSettings"));

 services.AddSingleton<IConfiguration>(Configuration);
 }

 
 public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
 {
 loggerFactory.AddConsole(Configuration.GetSection("Logging"));
 loggerFactory.AddDebug();

 app.UseMvc();
 }
 }
}

Now we can build our project and test the urls in browser.

Verb Url Description
GET api/Sales/Order Get orders
GET api/Sales/Order/1 Get order by id
GET api/Sales/Order/0 Get non existing order
GET api/Sales/CreateOrderViewModel Get view model to create order
GET api/Sales/CloneOrder/3 Clone an existing order
POST api/Sales/Order Create a new order
DELETE api/Sales/Order Delete an existing order

Step 07 – Add Unit Tests for Web API

Now we proceed to add unit tests for API project, these tests are mock tests, later we’ll add integration tests; what is the difference? in mock tests we simulate all dependency objects for API project and in the integration tests we run a process that simulates API execution. I mean a simulation of API (accepts Http requests), obviously there is more information about mock and integration but at this point with this basic idea is enough.

What is TDD? Testing is required in these days, because with unit tests it’s easy to test a feature before publishing, Test Driven Development (TDD) is the way to define unit tests and validate the behavior in our code. Another concept in TDD is AAA: Arrange, Act and Assert; arrange is the block for creation of objects, act is the block to place all invocations for methods and assert is the block to validate the results from methods invocation.

Now, open a terminal window in your working directory and follow these steps to create unit tests for API project:

  1. Go to test directory in Store.
  2. Create a directory with name Store.API.Tests.
  3. Change to Store.API.Tests directory
  4. Run this command: dotnet new -t xunittest
  5. Run this command: dotnet restore
  6. Later, add tests project to current solution, creating a new solution item with name test and inside of that solution item, add an existing project.
  7. Add reference to Store.API project and save changes to rebuild.

Now, add a file with name SalesControllerTests and add this code to new file:

using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Store.API.Controllers;
using Store.API.ViewModels;
using Store.Core.BusinessLayer.Responses;
using Store.Core.EntityLayer.Sales;
using Xunit;

namespace Store.API.Tests
{
 public class SalesControllerTests
 {
 [Fact]
 public async Task GetOrdersTestAsync()
 {
 
 var humanResourcesBusinessObject = BusinessObjectMocker.GetHumanResourcesBusinessObject();
 var productionBusinessObject = BusinessObjectMocker.GetProductionBusinessObject();
 var salesBusinessObject = BusinessObjectMocker.GetSalesBusinessObject();

 using (var controller = new SalesController(humanResourcesBusinessObject, productionBusinessObject, salesBusinessObject))
 {
 
 var response = await controller.GetOrders() as ObjectResult;

 
 var value = response.Value as IListModelResponse<Order>;

 Assert.False(value.DidError);
 }
 }

 [Fact]
 public async Task GetOrderTestAsync()
 {
 
 var humanResourcesBusinessObject = BusinessObjectMocker.GetHumanResourcesBusinessObject();
 var productionBusinessObject = BusinessObjectMocker.GetProductionBusinessObject();
 var salesBusinessObject = BusinessObjectMocker.GetSalesBusinessObject();
 var id = 1;

 using (var controller = new SalesController(humanResourcesBusinessObject, productionBusinessObject, salesBusinessObject))
 {
 
 var response = await controller.GetOrder(id) as ObjectResult;

 
 var value = response.Value as ISingleModelResponse<Order>;

 Assert.False(value.DidError);
 }
 }

 [Fact]
 public async Task GetNonExistingOrderTestAsync()
 {
 
 var humanResourcesBusinessObject = BusinessObjectMocker.GetHumanResourcesBusinessObject();
 var productionBusinessObject = BusinessObjectMocker.GetProductionBusinessObject();
 var salesBusinessObject = BusinessObjectMocker.GetSalesBusinessObject();
 var id = 0;

 using (var controller = new SalesController(humanResourcesBusinessObject, productionBusinessObject, salesBusinessObject))
 {
 
 var response = await controller.GetOrder(id) as ObjectResult;

 
 var value = response.Value as ISingleModelResponse<Order>;

 Assert.False(value.DidError);
 }
 }

 [Fact]
 public async Task GetCreateOrderViewModelTestAsync()
 {
 
 var humanResourcesBusinessObject = BusinessObjectMocker.GetHumanResourcesBusinessObject();
 var productionBusinessObject = BusinessObjectMocker.GetProductionBusinessObject();
 var salesBusinessObject = BusinessObjectMocker.GetSalesBusinessObject();

 using (var controller = new SalesController(humanResourcesBusinessObject, productionBusinessObject, salesBusinessObject))
 {
 
 var response = await controller.GetCreateOrderViewModel() as ObjectResult;

 
 var value = response.Value as ISingleModelResponse<CreateOrderViewModel>;

 Assert.False(value.DidError);
 }
 }

 [Fact]
 public async Task GetCloneOrderTestAsync()
 {
 
 var humanResourcesBusinessObject = BusinessObjectMocker.GetHumanResourcesBusinessObject();
 var productionBusinessObject = BusinessObjectMocker.GetProductionBusinessObject();
 var salesBusinessObject = BusinessObjectMocker.GetSalesBusinessObject();
 var id = 1;

 using (var controller = new SalesController(humanResourcesBusinessObject, productionBusinessObject, salesBusinessObject))
 {
 
 var response = await controller.CloneOrder(id) as ObjectResult;

 
 var value = response.Value as ISingleModelResponse<Order>;

 Assert.False(value.DidError);
 }
 }
 }
}

As we can see these methods are the tests for Urls in API project, please take care bout the tests are async methods.

Save all changes and run the tests from command line or Visual Studio.

Code Improvements

  1. Add code for store procedures invocation
  2. Add integration tests
  3. Add authentication API
  4. Dynamic loading for entity mappings
  5. Save logs to database

Points of Interest

  1. In this article, we’re working with EF Core but these concepts can apply to another ORM or without ORM.
  2. We can adjust all repositories to expose required operations, I mean in some cases we don’t want to get all, add, update or delete but those operations will depend on product owner requirements.

History

  • 12th December, 2016: Initial version
  • 13th December, 2016: Addition of Business Layer
  • 15th December, 2016: Addition of Mocks
  • 31th December, 2016: Addition of API
  • 5th January, 2017: Addition of Unit Tests for API
  • 22th January, 2017: Addition of Change Log
  • 4th February, 2017: Addition of Async Operations
  • 15th May, 2017: Addition of Logs

LEAVE A REPLY