Introduction
Design an enterpise architecture for applications it's a big challenge, there is a common question in this point: What is the the best way to solve this issue following the best practices according to selected technology in our company.
This guide uses .Net Core
, so We'll work with Entity Framework Core
, but these concepts apply for another technologies like Dapper
or another ORM.
In fact, we'll take a look at the common requirements to design of enterprise architect in this article.
The sample database provided in this guide represents an online store.
Because we're working with Entity Framework Core
and ASP.NET Core
, unit tests using in memory database provider; integration tests using a Test Web Server.
All tests (units and integration) are writen with xUnit
framework.
.Net Core
, so We'll work with Entity Framework Core
, but these concepts apply for another technologies like Dapper
or another ORM.Entity Framework Core
and ASP.NET Core
, unit tests using in memory database provider; integration tests using a Test Web Server.xUnit
framework.Background
According to my experience, enterprise architecture for applications should have the following levels:
- Entity Layer: Contains entities (POCOs)
- Data Layer: Contains objects related to database access
- Business Layer: Contains definitions and validations related to business
- External Services Layer (optional): Contains invocations for external services (ASMX, WCF, RESTful)
- Common: Contains common objects for layers (e.g. Loggers, Mappers, Extensions)
- Tests (QA): Contains tests for back-end (units and integration)
- Presentation Layer: This is the UI
- UI Tests (QA): Contains automated tests for front-end
Architecture: Big Picture
DATABASE | SQL Server | DATABASE |
ENTITY LAYER | POCOs | BACK-END |
DATA LAYER | DbContext, Configurations, Contracts, Data Contracts and Repositories | |
BUSINESS LAYER | Services, Contracts, DataContracts, Exceptions and Loggers | |
EXTERNAL SERVICES LAYER | ASMX, WCF, RESTful | |
COMMON | Loggers, Mappers, Extensions | |
PRESENTATION LAYER | UI Frameworks (AngularJS | ReactJS | Vue.js | Others) | FRONT-END |
USER |
Prerequisites
Skills
Before to continuing, keep in mind we need to have the folllowing skills in order to understand this guide:
- OOP (Object Oriented Programming)
- AOP (Aspect Oriented Programming)
- ORM (Object Relational Mapping)
- Design Patterns: Domain Driven Design, Repository & Unit of Work and IoC
Software
- .Net Core
- Visual Studio 2017
- SQL Server instance (local or remote)
- SQL Server Management Studio
Table of Contents
Using the Code
Chapter 01 - Database
Take a look for sample database to understand each component in architecture. In this database there are 4 schemas: Dbo, HumanResources, Warehouse and Sales.
Each schema represents a division on store company, keep this in mind because all code is designed following this aspect; at this moment this code only implements features for Production
and Sales
schemas.
All tables have a primary key with one column and have columns for creation, last update and concurrency token.
Production
and Sales
schemas.Tables
Schema | Name |
---|---|
dbo | ChangeLog |
dbo | ChangeLogExclusion |
dbo | Country |
dbo | CountryCurrency |
dbo | Currency |
dbo | EventLog |
HumanResources | Employee |
HumanResources | EmployeeAddress |
HumanResources | EmployeeEmail |
Sales | Customer |
Sales | OrderDetail |
Sales | OrderHeader |
Sales | OrderStatus |
Sales | PaymentMethod |
Sales | Shipper |
Warehouse | Location |
Warehouse | Product |
Warehouse | ProductCategory |
Warehouse | ProductInventory |
You can found the scripts for database in this link: OnLine Store Database Scripts on GitHub.
Please remember: This is a sample database, only for demonstration of concepts.
Chapter 02 - Core Project
Core project represents the core for solution, in this guide Core project includes entity, data and business layers.
We're working with .NET Core
, the naming convention is .NET
naming convention, so it's very useful to define a naming convention table to show how to set names in code, something like this:
.NET Core
, the naming convention is .NET
naming convention, so it's very useful to define a naming convention table to show how to set names in code, something like this:Identifier | Case | Example |
---|---|---|
Namespace | PascalCase | Store |
Class | PascalCase | Product |
Interface | I prefix + PascalCase | ISalesRepository |
Method | Verb in PascalCase + Noun in PascalCase | GetProducts |
Async Method | Verb in PascalCase + Noun in PascalCase + Async sufix | GetOrdersAsync |
Property | PascalCase | Description |
Parameter | camelCase | connectionString |
This convention is important because it defines the naming guidelines for architecture.
This is the structure for
Store.Core
project:EntityLayer
DataLayer
DataLayer
\Configurations
BusinessLayer
BusinessLayer
\Contracts
BusinessLayer
\Requests
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
because it's a common class for DataLayer
.
For 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).
For
DataLayer
\DataContracts
, we'll place all object definitions for returned values from Contracts
namespace, for now this directory contains OrderInfo
class definition.
For
DataLayer
\Mapping
, we'll place all object definitions related to mapping classes for database.
For
DataLayer
\Repositories
, we'll place the implementations for Contracts
definitons.
One repository includes operations related to one schema, so we have 4 repositories:
DboRepository
, HumanResourcesRepository
, ProductionRepository
and SalesRepository
.
Inside of
EntityLayer
and DataLayer
\Mapping
, we'll create one directory per schema.
Inside of
BusinessLayer
, we'll create the interfaces and implementations for services, in this case, the services will contain the methods according to use cases (or something similar) and those methods must perform validations and handle exceptions related to busines.
For
BusinessLayer\Responses
, we'll create the responses: single, list and paged to represent the result from services.
We'll inspect the code to understand these concepts but the review would be with one object per level because the remaining code is similar.
Entity Layer
OrderHeader
class:
Hide Shrink Copy Code
using System;
using System.Collections.ObjectModel;
using OnLineStore.Core.EntityLayer.Dbo;
using OnLineStore.Core.EntityLayer.HumanResources;
namespace OnLineStore.Core.EntityLayer.Sales
{
public class OrderHeader : IAuditableEntity
{
public OrderHeader()
{
}
public OrderHeader(long? orderHeaderID)
{
OrderHeaderID = orderHeaderID;
}
public long? OrderHeaderID { get; set; }
public short? OrderStatusID { get; set; }
public DateTime? OrderDate { get; set; }
public int? CustomerID { get; set; }
public int? EmployeeID { get; set; }
public int? ShipperID { get; set; }
public decimal? Total { get; set; }
public short? CurrencyID { get; set; }
public Guid? PaymentMethodID { get; set; }
public int? DetailsCount { get; set; }
public long? ReferenceOrderID { 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 Customer CustomerFk { get; set; }
public virtual Employee EmployeeFk { get; set; }
public virtual Shipper ShipperFk { get; set; }
public virtual Currency CurrencyFk { get; set; }
public virtual PaymentMethod PaymentMethodFk { get; set; }
public virtual Collection<OrderDetail> OrderDetails { get; set; } = new Collection<OrderDetail>();
}
}
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.
OrderHeader
class: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
For this source code, the implementation for repositories are by feature instead of generic repositories; the generic repositories require to create derived repositories in case we need to implement specific operations. I prefer repositories by features because do not require to create derived objects (interfaces and classes) but a repository by feature will contains a lot of operations because is a placeholder for all operations in feature.
The sample database for this article contains 4 schemas in database, so we'll have 4 repositories, this implementation provides a separation of concepts.
We are working with EF Core in this guide, so we need to have a DbContext and objects that allow mapping classes and database objects (tables and views).
Repository versus DbHelper versus Data Access Object
This issue is related to naming objects, some years ago I used DataAccessObject
as suffix to class that contain database operatios (select, insert, update, delete, etc). Other developers used DbHelper
as suffix to represent this kind of objects, at my beggining in EF I learned about repository design pattern, so from my point of view I prefer to use Repository
suffix to name the object that contains database operations.
OnLineStoreDbContext
class:
Hide Shrink Copy Code
using Microsoft.EntityFrameworkCore;
using OnLineStore.Core.DataLayer.Configurations;
using OnLineStore.Core.DataLayer.Configurations.Dbo;
using OnLineStore.Core.DataLayer.Configurations.HumanResources;
using OnLineStore.Core.DataLayer.Configurations.Warehouse;
using OnLineStore.Core.DataLayer.Configurations.Sales;
using OnLineStore.Core.EntityLayer.Dbo;
using OnLineStore.Core.EntityLayer.HumanResources;
using OnLineStore.Core.EntityLayer.Warehouse;
using OnLineStore.Core.EntityLayer.Sales;
namespace OnLineStore.Core.DataLayer
{
public class OnLineStoreDbContext : DbContext
{
public OnLineStoreDbContext(DbContextOptions<OnLineStoreDbContext> options)
: base(options)
{
}
public DbSet<ChangeLog> ChangeLogs { get; set; }
public DbSet<ChangeLogExclusion> ChangeLogExclusions { get; set; }
public DbSet<CountryCurrency> CountryCurrencies { get; set; }
public DbSet<Country> Countries { get; set; }
public DbSet<Currency> Currencies { get; set; }
public DbSet<EventLog> EventLogs { get; set; }
public DbSet<Employee> Employees { get; set; }
public DbSet<EmployeeAddress> EmployeeAddresses { get; set; }
public DbSet<EmployeeEmail> EmployeeEmails { get; set; }
public DbSet<ProductCategory> ProductCategories { get; set; }
public DbSet<ProductInventory> ProductInventories { get; set; }
public DbSet<Product> Products { get; set; }
public DbSet<EntityLayer.Warehouse.Location> Warehouses { get; set; }
public DbSet<Customer> Customers { get; set; }
public DbSet<OrderDetail> OrderDetails { get; set; }
public DbSet<OrderHeader> Orders { get; set; }
public DbSet<OrderStatus> OrderStatuses { get; set; }
public DbSet<OrderSummary> OrderSummaries { get; set; }
public DbSet<PaymentMethod> PaymentMethods { get; set; }
public DbSet<Shipper> Shippers { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Apply all configurations
modelBuilder
.ApplyConfiguration(new ChangeLogConfiguration())
.ApplyConfiguration(new ChangeLogExclusionConfiguration())
.ApplyConfiguration(new CountryCurrencyConfiguration())
.ApplyConfiguration(new CountryConfiguration())
.ApplyConfiguration(new CurrencyConfiguration())
.ApplyConfiguration(new EventLogConfiguration())
;
modelBuilder
.ApplyConfiguration(new EmployeeConfiguration())
.ApplyConfiguration(new EmployeeAddressConfiguration())
.ApplyConfiguration(new EmployeeEmailConfiguration())
;
modelBuilder
.ApplyConfiguration(new ProductCategoryConfiguration())
.ApplyConfiguration(new ProductInventoryConfiguration())
.ApplyConfiguration(new ProductConfiguration())
.ApplyConfiguration(new LocationConfiguration())
;
modelBuilder
.ApplyConfiguration(new CustomerConfiguration())
.ApplyConfiguration(new OrderDetailConfiguration())
.ApplyConfiguration(new OrderHeaderConfiguration())
.ApplyConfiguration(new OrderStatusConfiguration())
.ApplyConfiguration(new OrderSummaryConfiguration())
.ApplyConfiguration(new PaymentMethodConfiguration())
.ApplyConfiguration(new ShipperConfiguration())
;
base.OnModelCreating(modelBuilder);
}
}
}
OrderHeaderConfiguration
class:
Hide Shrink Copy Code
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using OnLineStore.Core.EntityLayer.Sales;
namespace OnLineStore.Core.DataLayer.Configurations.Sales
{
public class OrderHeaderConfiguration : IEntityTypeConfiguration<OrderHeader>
{
public void Configure(EntityTypeBuilder<OrderHeader> builder)
{
// Mapping for table
builder.ToTable("OrderHeader", "Sales");
// Set key for entity
builder.HasKey(p => p.OrderHeaderID);
// Set identity for entity (auto increment)
builder.Property(p => p.OrderHeaderID).UseSqlServerIdentityColumn();
// Set mapping for columns
builder.Property(p => p.OrderStatusID).HasColumnType("smallint").IsRequired();
builder.Property(p => p.OrderDate).HasColumnType("datetime").IsRequired();
builder.Property(p => p.CustomerID).HasColumnType("int").IsRequired();
builder.Property(p => p.EmployeeID).HasColumnType("int");
builder.Property(p => p.ShipperID).HasColumnType("int");
builder.Property(p => p.Total).HasColumnType("decimal(12, 4)").IsRequired();
builder.Property(p => p.CurrencyID).HasColumnType("smallint");
builder.Property(p => p.PaymentMethodID).HasColumnType("uniqueidentifier");
builder.Property(p => p.DetailsCount).HasColumnType("int").IsRequired();
builder.Property(p => p.ReferenceOrderID).HasColumnType("bigint");
builder.Property(p => p.Comments).HasColumnType("varchar(max)");
builder.Property(p => p.CreationUser).HasColumnType("varchar(25)").IsRequired();
builder.Property(p => p.CreationDateTime).HasColumnType("datetime").IsRequired();
builder.Property(p => p.LastUpdateUser).HasColumnType("varchar(25)");
builder.Property(p => p.LastUpdateDateTime).HasColumnType("datetime");
// Set concurrency token for entity
builder.Property(p => p.Timestamp).ValueGeneratedOnAddOrUpdate().IsConcurrencyToken();
// Add configuration for foreign keys
builder
.HasOne(p => p.OrderStatusFk)
.WithMany(b => b.Orders)
.HasForeignKey(p => p.OrderStatusID);
builder
.HasOne(p => p.CustomerFk)
.WithMany(b => b.Orders)
.HasForeignKey(p => p.CustomerID);
builder
.HasOne(p => p.ShipperFk)
.WithMany(b => b.Orders)
.HasForeignKey(p => p.ShipperID);
}
}
}
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 DbSet
s 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).
DataAccessObject
as suffix to class that contain database operatios (select, insert, update, delete, etc). Other developers used DbHelper
as suffix to represent this kind of objects, at my beggining in EF I learned about repository design pattern, so from my point of view I prefer to use Repository
suffix to name the object that contains database operations.OnLineStoreDbContext
class:OrderHeaderConfiguration
class: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.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 DbSet
s to DbContext
but this style would be changed if you considered it's more accurate to use declarated DbSet
properties in DbContext
.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 propertiesStored 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 Entity Framework 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.
Just to be clear: STAY AWAY FROM STORED PROCEDURES!
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:
Hide Copy Code
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
(3 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.
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.Entity Framework Core
doesn't allow to invoke stored procedures"Sales.GetCustomerOrdersHistory
and HumanResources.DisableEmployee
; we must to place methods inside of Sales
and HumanResources
repositories.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:Business Layer
Controller versus Service versus Business Object
There is a common issue in this point, How we must to name the object that represents business operations: for first versions of this article I named this object as BusinessObject
, that can be confusing for some developers, some developers do not name this as business object because the controller in Web API represents business logic, but Service
is another name used by developers, so from my point of view is more clear to use Service
as sufix for this object. If we have a Web API that implements business logic in controller we can ommit to have services, but if there is business layer it is more useful to have services, these classes must to implement logic business and controllers must invoke service's methods.
BusinessObject
, that can be confusing for some developers, some developers do not name this as business object because the controller in Web API represents business logic, but Service
is another name used by developers, so from my point of view is more clear to use Service
as sufix for this object. If we have a Web API that implements business logic in controller we can ommit to have services, but if there is business layer it is more useful to have services, these classes must to implement logic business and controllers must invoke service's methods.Business Layer: Handle Related Aspects To Business
- 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(GetOrdersAsync));
, 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.
- 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.
- Transactions: as we can see inside of
Sales
business object, we have implemented transaction to handle multiple changes in our database; inside of CreateOrderAsync
method, we invoke methods from repositories, inside of repositories we don't have any transactions because the service is the responsible 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.
- There is a
CloneOrderAsync
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.
GetCreateOrderRequestAsync
method in SalesRepository
provides the required information to create an order, information from foreign keys: products and anothers. With this method we are providing a model that contains the list for foreign keys and in that way we reduce the work from front-end to know how to create create order operation.
Service
class:
Hide Shrink Copy Code
using Microsoft.Extensions.Logging;
using OnLineStore.Core.BusinessLayer.Contracts;
using OnLineStore.Core.DataLayer;
namespace OnLineStore.Core.BusinessLayer
{
public abstract class Service : IService
{
protected bool Disposed;
protected ILogger Logger;
protected IUserInfo UserInfo;
public Service(ILogger logger, IUserInfo userInfo, OnLineStoreDbContext dbContext)
{
Logger = logger;
UserInfo = userInfo;
DbContext = dbContext;
}
public void Dispose()
{
if (!Disposed)
{
DbContext?.Dispose();
Disposed = true;
}
}
public OnLineStoreDbContext DbContext { get; }
}
}
SalesService
class:
Hide Shrink Copy Code
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using OnLineStore.Core.BusinessLayer.Contracts;
using OnLineStore.Core.BusinessLayer.Requests;
using OnLineStore.Core.BusinessLayer.Responses;
using OnLineStore.Core.DataLayer;
using OnLineStore.Core.DataLayer.Repositories;
using OnLineStore.Core.DataLayer.Sales;
using OnLineStore.Core.DataLayer.Warehouse;
using OnLineStore.Core.EntityLayer.Dbo;
using OnLineStore.Core.EntityLayer.Sales;
using OnLineStore.Core.EntityLayer.Warehouse;
namespace OnLineStore.Core.BusinessLayer
{
public class SalesService : Service, ISalesService
{
public SalesService(ILogger<SalesService> logger, IUserInfo userInfo, OnLineStoreDbContext dbContext)
: base(logger, userInfo, dbContext)
{
}
public async Task<IPagedResponse<Customer>> GetCustomersAsync(int pageSize = 10, int pageNumber = 1)
{
Logger?.LogDebug("{0} has been invoked", nameof(GetCustomersAsync));
var response = new PagedResponse<Customer>();
try
{
// Get query
var query = DbContext.Customers;
// Set information for paging
response.PageSize = pageSize;
response.PageNumber = pageNumber;
response.ItemsCount = await query.CountAsync();
// Retrieve items, set model for response
response.Model = await query
.Paging(pageSize, pageNumber)
.ToListAsync();
}
catch (Exception ex)
{
response.SetError(Logger, nameof(GetCustomersAsync), ex);
}
return response;
}
public async Task<IPagedResponse<Shipper>> GetShippersAsync(int pageSize = 10, int pageNumber = 1)
{
Logger?.LogDebug("{0} has been invoked", nameof(GetShippersAsync));
var response = new PagedResponse<Shipper>();
try
{
// Get query
var query = DbContext.Shippers;
// Set information for paging
response.PageSize = pageSize;
response.PageNumber = pageNumber;
response.ItemsCount = await query.CountAsync();
// Retrieve items, set model for response
response.Model = await query
.Paging(pageSize, pageNumber)
.ToListAsync();
}
catch (Exception ex)
{
response.SetError(Logger, nameof(GetShippersAsync), ex);
}
return response;
}
public async Task<IPagedResponse<Currency>> GetCurrenciesAsync(int pageSize = 10, int pageNumber = 1)
{
Logger?.LogDebug("{0} has been invoked", nameof(GetCurrenciesAsync));
var response = new PagedResponse<Currency>();
try
{
// Get query
var query = DbContext.Currencies;
// Set information for paging
response.PageSize = pageSize;
response.PageNumber = pageNumber;
response.ItemsCount = await query.CountAsync();
// Retrieve items, set model for response
response.Model = await query
.Paging(pageSize, pageNumber)
.ToListAsync();
}
catch (Exception ex)
{
response.SetError(Logger, nameof(GetCurrenciesAsync), ex);
}
return response;
}
public async Task<IPagedResponse<PaymentMethod>> GetPaymentMethodsAsync(int pageSize = 10, int pageNumber = 1)
{
Logger?.LogDebug("{0} has been invoked", nameof(GetPaymentMethodsAsync));
var response = new PagedResponse<PaymentMethod>();
try
{
// Get query
var query = DbContext.PaymentMethods;
// Set information for paging
response.PageSize = pageSize;
response.PageNumber = pageNumber;
response.ItemsCount = await query.CountAsync();
// Retrieve items, set model for response
response.Model = await query
.Paging(pageSize, pageNumber)
.ToListAsync();
}
catch (Exception ex)
{
response.SetError(Logger, nameof(GetPaymentMethodsAsync), ex);
}
return response;
}
public async Task<IPagedResponse<OrderInfo>> GetOrdersAsync(int pageSize = 10, int pageNumber = 1, short? orderStatusID = null, int? customerID = null, int? employeeID = null, int? shipperID = null, short? currencyID = null, Guid? paymentMethodID = null)
{
Logger?.LogDebug("{0} has been invoked", nameof(GetOrdersAsync));
var response = new PagedResponse<OrderInfo>();
try
{
// Get query
var query = DbContext.GetOrders(orderStatusID, customerID, employeeID, shipperID, currencyID, paymentMethodID);
// Set information for paging
response.PageSize = pageSize;
response.PageNumber = pageNumber;
response.ItemsCount = await query.CountAsync();
// Retrieve items, set model for response
response.Model = await query
.Paging(pageSize, pageNumber)
.ToListAsync();
response.Message = string.Format("Page {0} of {1}, Total of rows: {2}", response.PageNumber, response.PageCount, response.ItemsCount);
Logger?.LogInformation(response.Message);
}
catch (Exception ex)
{
response.SetError(Logger, nameof(GetOrdersAsync), ex);
}
return response;
}
public async Task<ISingleResponse<OrderHeader>> GetOrderAsync(long id)
{
Logger?.LogDebug("{0} has been invoked", nameof(GetOrderAsync));
var response = new SingleResponse<OrderHeader>();
try
{
// Retrieve order by id
response.Model = await DbContext.GetOrderAsync(new OrderHeader(id));
}
catch (Exception ex)
{
response.SetError(Logger, nameof(GetOrderAsync), ex);
}
return response;
}
public async Task<ISingleResponse<CreateOrderRequest>> GetCreateOrderRequestAsync()
{
Logger?.LogDebug("{0} has been invoked", nameof(GetCreateOrderRequestAsync));
var response = new SingleResponse<CreateOrderRequest>();
try
{
// Retrieve products list
response.Model.Products = await DbContext.GetProducts().ToListAsync();
// Retrieve customers list
response.Model.Customers = await DbContext.Customers.ToListAsync();
}
catch (Exception ex)
{
response.SetError(Logger, nameof(GetCreateOrderRequestAsync), ex);
}
return response;
}
public async Task<ISingleResponse<OrderHeader>> CreateOrderAsync(OrderHeader header, OrderDetail[] details)
{
Logger?.LogDebug("{0} has been invoked", nameof(CreateOrderAsync));
var response = new SingleResponse<OrderHeader>();
// Begin transaction
using (var transaction = await DbContext.Database.BeginTransactionAsync())
{
try
{
// todo: Retrieve available warehouse to dispatch products
var warehouses = await DbContext.Warehouses.ToListAsync();
foreach (var detail in details)
{
// Retrieve product by id
var product = await DbContext.GetProductAsync(new Product(detail.ProductID));
// Throw exception if product no exists
if (product == null)
throw new NonExistingProductException(string.Format(SalesDisplays.NonExistingProductExceptionMessage, detail.ProductID));
// Throw exception if product is discontinued
if (product.Discontinued == true)
throw new AddOrderWithDiscontinuedProductException(string.Format(SalesDisplays.AddOrderWithDiscontinuedProductExceptionMessage, product.ProductID));
// Throw exception if quantity for product is invalid
if (detail.Quantity <= 0)
throw new InvalidQuantityException(string.Format(SalesDisplays.InvalidQuantityExceptionMessage, product.ProductID));
// Set values for detail
detail.ProductName = product.ProductName;
detail.UnitPrice = product.UnitPrice;
detail.Total = product.UnitPrice * detail.Quantity;
}
// Set default values for order header
if (!header.OrderDate.HasValue)
header.OrderDate = DateTime.Now;
header.OrderStatusID = 100;
// Calculate total for order header from order's details
header.Total = details.Sum(item => item.Total);
header.DetailsCount = details.Count();
// Save order header
DbContext.Add(header, UserInfo);
await DbContext.SaveChangesAsync();
foreach (var detail in details)
{
// Set order id for order detail
detail.OrderHeaderID = header.OrderHeaderID;
// Add order detail
DbContext.Add(detail, UserInfo);
await DbContext.SaveChangesAsync();
// Create product inventory instance
var productInventory = new ProductInventory
{
ProductID = detail.ProductID,
LocationID = warehouses.First().LocationID,
OrderDetailID = detail.OrderDetailID,
Quantity = detail.Quantity * -1,
CreationDateTime = DateTime.Now,
CreationUser = header.CreationUser
};
// Save product inventory
DbContext.Add(productInventory);
}
await DbContext.SaveChangesAsync();
response.Model = header;
// Commit transaction
transaction.Commit();
Logger.LogInformation(SalesDisplays.CreateOrderMessage);
}
catch (Exception ex)
{
response.SetError(Logger, nameof(CreateOrderAsync), ex);
}
}
return response;
}
public async Task<ISingleResponse<OrderHeader>> CloneOrderAsync(long id)
{
Logger?.LogDebug("{0} has been invoked", nameof(CloneOrderAsync));
var response = new SingleResponse<OrderHeader>();
try
{
// Retrieve order by id
var entity = await DbContext.GetOrderAsync(new OrderHeader(id));
if (entity != null)
{
// Create a new instance for order and set values from existing order
response.Model = new OrderHeader
{
OrderHeaderID = entity.OrderHeaderID,
OrderDate = entity.OrderDate,
CustomerID = entity.CustomerID,
EmployeeID = entity.EmployeeID,
ShipperID = entity.ShipperID,
Total = entity.Total,
Comments = entity.Comments
};
if (entity.OrderDetails != null && entity.OrderDetails.Count > 0)
{
foreach (var detail in entity.OrderDetails)
{
// Add order detail clone to collection
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(Logger, nameof(CloneOrderAsync), ex);
}
return response;
}
public async Task<IResponse> RemoveOrderAsync(long id)
{
Logger?.LogDebug("{0} has been invoked", nameof(RemoveOrderAsync));
var response = new Response();
try
{
// Retrieve order by id
var entity = await DbContext.GetOrderAsync(new OrderHeader(id));
if (entity != null)
{
// Restrict remove operation for orders with details
if (entity.OrderDetails.Count > 0)
throw new ForeignKeyDependencyException(string.Format(SalesDisplays.RemoveOrderExceptionMessage, id));
// Delete order
DbContext.Remove(entity);
await DbContext.SaveChangesAsync();
Logger?.LogInformation(SalesDisplays.DeleteOrderMessage);
}
}
catch (Exception ex)
{
response.SetError(Logger, nameof(RemoveOrderAsync), ex);
}
return response;
}
}
}
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:
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(GetOrdersAsync));
, 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.Sales
business object, we have implemented transaction to handle multiple changes in our database; inside of CreateOrderAsync
method, we invoke methods from repositories, inside of repositories we don't have any transactions because the service is the responsible 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.CloneOrderAsync
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.GetCreateOrderRequestAsync
method in SalesRepository
provides the required information to create an order, information from foreign keys: products and anothers. With this method we are providing a model that contains the list for foreign keys and in that way we reduce the work from front-end to know how to create create order operation.Service
class:SalesService
class: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:Name | Description |
---|---|
AddOrderWithDiscontinuedProductException | Represents an exception adding order with a discontinued product |
ForeignKeyDependencyException | Represents an exception deleting an order with detail rows |
DuplicatedProductNameException | Represents an exception adding product with existing name |
NonExistingProductException | Represents an exception adding order with non existing product |
Chapter 03 - Putting All Code Together
We need to create a OnLineStoreDbContext
instance, that instance works with SQL Server, in OnModelCreating
method, all configurations are applied to ModelBuilder
instance.
Later, there is an instance of SalesService
created with a valid instance of OnLineStoreDbContext
to get access for service's operations.
OnLineStoreDbContext
instance, that instance works with SQL Server, in OnModelCreating
method, all configurations are applied to ModelBuilder
instance.SalesService
created with a valid instance of OnLineStoreDbContext
to get access for service's operations.Get All
This is an example of how we can retrieve a list of orders list:
Hide Copy Code
// Create logger instance
var logger = LoggerMocker.GetLogger<ISalesService>();
// Create application user
var userInfo = new UserInfo("admin");
// Create options for DbContext
var options = new DbContextOptionsBuilder<OnLineStoreDbContext>()
.UseSqlServer("YourConnectionStringHere")
.Options;
// Create instance of business object
// Set logger, application user and context for database
using (var service = new SalesService(logger, userInfo, new OnLineStoreDbContext(options)))
{
// Declare parameters and set values for paging
var pageSize = 10;
var pageNumber = 1;
// Get response from business object
var response = await service.GetOrdersAsync(pageSize, pageNumber);
// Validate if there was an error
var valid = !response.DidError;
}
As we can see, GetOrdersAsync
method in SalesService
retrieves rows from Sales.Order
table as a generic list.
GetOrdersAsync
method in SalesService
retrieves rows from Sales.Order
table as a generic list.Get by Key
This is an example of how we can retrieve an entity by key:
Hide Copy Code
// Create logger instance
var logger = LoggerMocker.GetLogger<ISalesService>();
// Create application user
var userInfo = new UserInfo("admin");
// Create options for DbContext
var options = new DbContextOptionsBuilder<OnLineStoreDbContext>()
.UseSqlServer("YourConnectionStringHere")
.Options;
// Create instance of business object
// Set logger, application user and context for database
using (var service = new SalesService(logger, userInfo, new OnLineStoreDbContext(options)))
{
// Declare parameters and set values for paging
var id = 1;
// Get response from business object
var response = await service.GetOrderAsync(id);
// Validate if there was an error
var valid = !response.DidError;
// Get entity
var entity = response.Model;
}
For incoming versions of this article, there will be samples for another operations.
Chapter 04 - Mocker
Mocker
it's a project that allows to create rows in Sales.OrderHeader
, Sales.OrderDetail
and Warehouse.ProductInventory
tables for a range of dates, by default Mocker
creates rows for one year.
Program
class:
Hide Shrink Copy Code
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using OnLineStore.Common;
using OnLineStore.Core.EntityLayer.Sales;
namespace OnLineStore.Mocker
{
public class Program
{
private static readonly ILogger Logger;
static Program()
{
Logger = LoggingHelper.GetLogger<Program>();
}
public static void Main(string[] args)
{
MainAsync(args).GetAwaiter().GetResult();
}
static async Task MainAsync(string[] args)
{
var year = DateTime.Now.AddYears(-1).Year;
var ordersLimitPerDay = 3;
foreach (var arg in args)
{
if (arg.StartsWith("/year:"))
year = Convert.ToInt32(arg.Replace("/year:", string.Empty));
else if (arg.StartsWith("/ordersLimitPerDay:"))
ordersLimitPerDay = Convert.ToInt32(arg.Replace("/ordersLimitPerDay:", string.Empty));
}
var start = new DateTime(year, 1, 1);
var end = new DateTime(year, 12, DateTime.DaysInMonth(year, 12));
if (start.DayOfWeek == DayOfWeek.Sunday)
start = start.AddDays(1);
do
{
if (start.DayOfWeek != DayOfWeek.Sunday)
{
await CreateDataAsync(start, ordersLimitPerDay);
Thread.Sleep(1000);
}
start = start.AddDays(1);
}
while (start <= end);
}
static async Task CreateDataAsync(DateTime date, int ordersLimitPerDay)
{
var random = new Random();
var warehouseService = ServiceMocker.GetWarehouseService();
var salesService = ServiceMocker.GetSalesService();
var customers = (await salesService.GetCustomersAsync()).Model.ToList();
var currencies = (await salesService.GetCurrenciesAsync()).Model.ToList();
var paymentMethods = (await salesService.GetPaymentMethodsAsync()).Model.ToList();
var products = (await warehouseService.GetProductsAsync()).Model.ToList();
Logger.LogInformation("Creating orders for {0}", date);
for (var i = 0; i < ordersLimitPerDay; i++)
{
var header = new OrderHeader
{
OrderDate = date,
CreationDateTime = date
};
var selectedCustomer = random.Next(0, customers.Count - 1);
var selectedCurrency = random.Next(0, currencies.Count - 1);
var selectedPaymentMethod = random.Next(0, paymentMethods.Count - 1);
header.CustomerID = customers[selectedCustomer].CustomerID;
header.CurrencyID = currencies[selectedCurrency].CurrencyID;
header.PaymentMethodID = paymentMethods[selectedPaymentMethod].PaymentMethodID;
var details = new List<OrderDetail>();
var detailsCount = random.Next(1, 5);
for (var j = 0; j < detailsCount; j++)
{
var detail = new OrderDetail
{
ProductID = products[random.Next(0, products.Count - 1)].ProductID,
Quantity = (short)random.Next(1, 5)
};
if (details.Count > 0 && details.Count(item => item.ProductID == detail.ProductID) == 1)
continue;
details.Add(detail);
}
await salesService.CreateOrderAsync(header, details.ToArray());
Logger.LogInformation("Date: {0}", date);
}
warehouseService.Dispose();
salesService.Dispose();
}
}
}
Now in the same window terminal, we need to run the following command: dotnet run
and if everything works fine, we can check in our database the data for OrderHeader
, OrderDetail
and ProductInventory
tables.
How Mocker
works? set a range for dates and a limit of orders per day, then iterates all days in date range except sundays beacuse we're assuming create order process is not allowed on sundays; then create the instance of DbContext
and Services
, arranges data using a random index to get elements from products, customers, currencies and payment methods; then invokes the CreateOrderAsync
method.
You can adjust the range for dates and orders per day to mock data according to your requirements, once the Mocker
has finished you can check the data on your database.
Mocker
it's a project that allows to create rows in Sales.OrderHeader
, Sales.OrderDetail
and Warehouse.ProductInventory
tables for a range of dates, by default Mocker
creates rows for one year.Program
class:dotnet run
and if everything works fine, we can check in our database the data for OrderHeader
, OrderDetail
and ProductInventory
tables.Mocker
works? set a range for dates and a limit of orders per day, then iterates all days in date range except sundays beacuse we're assuming create order process is not allowed on sundays; then create the instance of DbContext
and Services
, arranges data using a random index to get elements from products, customers, currencies and payment methods; then invokes the CreateOrderAsync
method.Mocker
has finished you can check the data on your database.Chapter 05 - Web API
There is a project with name OnlineStore.WebAPI
, this represents Web API for this solution, this project has references to OnLineStore.Core
project.
We'll take a look on SalesController
class:
Hide Shrink Copy Code
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using OnLineStore.Core.BusinessLayer.Contracts;
using OnLineStore.WebAPI.Requests;
using OnLineStore.WebAPI.Responses;
namespace OnLineStore.WebAPI.Controllers
{
#pragma warning disable CS1591
[ApiController]
[Route("api/v1/[controller]")]
public class SalesController : ControllerBase
{
protected ILogger Logger;
protected ISalesService SalesService;
public SalesController(ILogger<SalesController> logger, ISalesService salesService)
{
Logger = logger;
SalesService = salesService;
}
#pragma warning restore CS1591
/// <summary>
/// Retrieves the orders generated by customers
/// </summary>
/// <param name="pageSize">Page size</param>
/// <param name="pageNumber">Page number</param>
/// <param name="orderStatusID">Order status</param>
/// <param name="customerID">Customer</param>
/// <param name="employeeID">Employee</param>
/// <param name="shipperID">Shipper</param>
/// <param name="currencyID">Currency</param>
/// <param name="paymentMethodID">Payment method</param>
/// <returns>A sequence of orders</returns>
[HttpGet("Order")]
[ProducesResponseType(200)]
[ProducesResponseType(500)]
public async Task<IActionResult> GetOrdersAsync(int? pageSize = 50, int? pageNumber = 1, short? orderStatusID = null, int? customerID = null, int? employeeID = null, int? shipperID = null, short? currencyID = null, Guid? paymentMethodID = null)
{
Logger?.LogDebug("{0} has been invoked", nameof(GetOrdersAsync));
// Get response from business logic
var response = await SalesService.GetOrdersAsync((int)pageSize, (int)pageNumber, orderStatusID, customerID, employeeID, shipperID, currencyID, paymentMethodID);
// Return as http response
return response.ToHttpResponse();
}
/// <summary>
/// Retrieves an existing order by id
/// </summary>
/// <param name="id">Order ID</param>
/// <returns>An existing order</returns>
[HttpGet("Order/{id}")]
[ProducesResponseType(200)]
[ProducesResponseType(404)]
[ProducesResponseType(500)]
public async Task<IActionResult> GetOrderAsync(long id)
{
Logger?.LogDebug("{0} has been invoked", nameof(GetOrderAsync));
// Get response from business logic
var response = await SalesService.GetOrderAsync(id);
// Return as http response
return response.ToHttpResponse();
}
/// <summary>
/// Retrieves the request model to create a new order
/// </summary>
/// <returns>A model that represents the request to create a new order</returns>
[HttpGet("CreateOrderRequest")]
public async Task<IActionResult> GetCreateOrderRequestAsync()
{
Logger?.LogDebug("{0} has been invoked", nameof(GetCreateOrderRequestAsync));
// Get response from business logic
var response = await SalesService.GetCreateOrderRequestAsync();
// Return as http response
return response.ToHttpResponse();
}
/// <summary>
/// Creates a new order
/// </summary>
/// <param name="request">Model request</param>
/// <returns>A result that contains the order ID generated by application</returns>
[HttpPost]
[Route("Order")]
[ProducesResponseType(200)]
[ProducesResponseType(400)]
[ProducesResponseType(500)]
public async Task<IActionResult> CreateOrderAsync([FromBody] OrderHeaderRequest request)
{
Logger?.LogDebug("{0} has been invoked", nameof(CreateOrderAsync));
// Get response from business logic
var response = await SalesService.CreateOrderAsync(request.GetOrder(), request.GetOrderDetails().ToArray());
// Return as http response
return response.ToHttpResponse();
}
/// <summary>
/// Creates a new order from existing order
/// </summary>
/// <param name="id">Order ID</param>
/// <returns>A model for a new order</returns>
[HttpGet("CloneOrder/{id}")]
[ProducesResponseType(200)]
[ProducesResponseType(500)]
public async Task<IActionResult> CloneOrderAsync(int id)
{
Logger?.LogDebug("{0} has been invoked", nameof(CloneOrderAsync));
// Get response from business logic
var response = await SalesService.CloneOrderAsync(id);
// Return as http response
return response.ToHttpResponse();
}
/// <summary>
/// Deletes an existing order
/// </summary>
/// <param name="id">ID for order</param>
/// <returns>A success response if order is deleted</returns>
[HttpDelete("Order/{id}")]
[ProducesResponseType(200)]
[ProducesResponseType(500)]
public async Task<IActionResult> DeleteOrderAsync(int id)
{
Logger?.LogDebug("{0} has been invoked", nameof(DeleteOrderAsync));
// Get response from business logic
var response = await SalesService.RemoveOrderAsync(id);
// Return as http response
return response.ToHttpResponse();
}
}
}
ViewModel versus Request
ViewModel
is an object that contains behavior, request is the action related to invoke a Web API method, this is the misunderstood: ViewModel
is an object linked to a view, contains behavior to handle changes and sync up with view; usually the parameter for Web API method is an object with properties, so this definition is named Request
; MVC is not MVVM, the life's cycle for model is different in those patterns, this definition doesn't keep state between UI and API, also the process to set properties values in request from query string is handled by a model binder.
Now take a look on Startup.cs
class:
Hide Shrink Copy Code
using System;
using System.IO;
using System.Reflection;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using OnLineStore.Core;
using OnLineStore.Core.BusinessLayer;
using OnLineStore.Core.BusinessLayer.Contracts;
using OnLineStore.Core.DataLayer;
using Swashbuckle.AspNetCore.Swagger;
namespace OnLineStore.WebAPI
{
#pragma warning disable CS1591
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
services.AddMvc().AddJsonOptions(options =>
{
options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
});
// Setting dependency injection
// For DbContext
services.AddDbContext<OnLineStoreDbContext>(options => options.UseSqlServer(Configuration["AppSettings:ConnectionString"]));
// User info
services.AddScoped<IUserInfo, UserInfo>();
// Logger for services
services.AddScoped<ILogger, Logger<Service>>();
// Services
services.AddScoped<IHumanResourcesService, HumanResourcesService>();
services.AddScoped<IWarehouseService, WarehouseService>();
services.AddScoped<ISalesService, SalesService>();
// Configuration for Help page
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new Info { Title = "OnLine Store API", Version = "v1" });
// Get xml comments path
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
// Set xml path
options.IncludeXmlComments(xmlPath);
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
app.UseDeveloperExceptionPage();
// todo: Set port number for client app
app.UseCors(policy =>
{
// Add client origin in CORS policy
policy.WithOrigins("http://localhost:4200");
policy.AllowAnyHeader();
policy.AllowAnyMethod();
});
// Configuration for Swagger
app.UseSwagger();
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint("/swagger/v1/swagger.json", "OnLine Store API");
});
app.UseMvc();
}
}
#pragma warning restore CS1591
}
This class it's the configuration point for Web API project, in this class there is the configuration for dependency injection, API's configuration and another settings.
For Web API project, these are the routes for controllers:
OnlineStore.WebAPI
, this represents Web API for this solution, this project has references to OnLineStore.Core
project.SalesController
class:ViewModel
is an object that contains behavior, request is the action related to invoke a Web API method, this is the misunderstood: ViewModel
is an object linked to a view, contains behavior to handle changes and sync up with view; usually the parameter for Web API method is an object with properties, so this definition is named Request
; MVC is not MVVM, the life's cycle for model is different in those patterns, this definition doesn't keep state between UI and API, also the process to set properties values in request from query string is handled by a model binder.Startup.cs
class:Verb | Route | Description |
---|---|---|
GET | api/v1/Sales/Order | Get orders |
GET | api/v1/Sales/Order/1 | Get order by id |
GET | api/v1/Sales/CreateOrderRequest | Get model to create order |
GET | api/v1/Sales/CloneOrder/3 | Clone an existing order |
POST | api/v1/Sales/Order | Create a new order |
DELETE | api/v1/Sales/Order | Delete an existing order |
As we can see there is a
v1
in each route, this is because the version for Web API is 1 and that value is defined in Route
attribute for controllers in Web API project.Chapter 06 - Help Page for Web API
Web API uses Swagger to show a help page.
The following package is required to show a help page with Swagger:
Swashbuckle.AspNetCore
The configuration for Swagger is located in Startup
class, addition for Swagger is in ConfigureServices
method:
Hide Copy Code
// Configuration for Help page
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new Info { Title = "OnLine Store API", Version = "v1" });
// Get xml comments path
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
// Set xml path
options.IncludeXmlComments(xmlPath);
});
The configuration for endpoint is in Configure
method:
Hide Copy Code
// Configuration for Swagger
app.UseSwagger();
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint("/swagger/v1/swagger.json", "OnLine Store API");
});
Swagger allows to show description for actions in controllers, these descriptions are taken from xml comments.
Help Page:
Models Section in Help Page:
Help Page for Web API it's a good practice, because provides information about API for clients.
Swashbuckle.AspNetCore
Startup
class, addition for Swagger is in ConfigureServices
method:Configure
method:Chapter 07 - Unit Tests for Web API
Now we proceed to add unit tests for Web API project, these tests work with in memory database, what is the difference between unit tests and integration tests? for unit tests we simulate all dependencies for Web API project and for integration tests we run a process that simulates Web API execution. I mean a simulation of Web API (accept Http requests), obviously there is more information about unit tests and integration tests but at this point this basic idea is enough.
What is TDD? Testing is important in these days, because with unit tests it's easy to performing tests for features before to publish, Test Driven Development (TDD) is the way to define unit tests and validate the behavior in 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, take a look for SalesControllerTests
class:
Hide Shrink Copy Code
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using OnLineStore.Common;
using OnLineStore.Core.BusinessLayer.Requests;
using OnLineStore.Core.BusinessLayer.Responses;
using OnLineStore.Core.DataLayer.Sales;
using OnLineStore.Core.EntityLayer.Sales;
using OnLineStore.WebAPI.Controllers;
using OnLineStore.WebAPI.Requests;
using OnLineStore.WebAPI.UnitTests.Mocks;
using Xunit;
namespace OnLineStore.WebAPI.UnitTests
{
public class SalesControllerTests
{
[Fact]
public async Task TestGetOrdersAsync()
{
// Arrange
var logger = LoggingHelper.GetLogger<SalesController>();
var service = ServiceMocker.GetSalesService(nameof(TestGetOrdersAsync));
var controller = new SalesController(logger, service);
// Act
var response = await controller.GetOrdersAsync() as ObjectResult;
var value = response.Value as IPagedResponse<OrderInfo>;
service.Dispose();
// Assert
Assert.False(value.DidError);
}
[Fact]
public async Task TestGetOrdersByCurrencyAsync()
{
// Arrange
var logger = LoggingHelper.GetLogger<SalesController>();
var service = ServiceMocker.GetSalesService(nameof(TestGetOrdersByCurrencyAsync));
var controller = new SalesController(logger, service);
var currencyID = (short?)1000;
// Act
var response = await controller.GetOrdersAsync(currencyID: currencyID) as ObjectResult;
var value = response.Value as IPagedResponse<OrderInfo>;
service.Dispose();
// Assert
Assert.False(value.DidError);
Assert.True(value.Model.Count() > 0);
Assert.True(value.Model.Count(item => item.CurrencyID == currencyID) == value.Model.Count());
}
[Fact]
public async Task TestGetOrdersByCustomerAsync()
{
// Arrange
var logger = LoggingHelper.GetLogger<SalesController>();
var service = ServiceMocker.GetSalesService(nameof(TestGetOrdersByCustomerAsync));
var controller = new SalesController(logger, service);
var customerID = 1;
// Act
var response = await controller.GetOrdersAsync(customerID: customerID) as ObjectResult;
var value = response.Value as IPagedResponse<OrderInfo>;
service.Dispose();
// Assert
Assert.False(value.DidError);
Assert.True(value.Model.Count(item => item.CustomerID == customerID) == value.Model.Count());
}
[Fact]
public async Task TestGetOrdersByEmployeeAsync()
{
// Arrange
var logger = LoggingHelper.GetLogger<SalesController>();
var service = ServiceMocker.GetSalesService(nameof(TestGetOrdersByEmployeeAsync));
var controller = new SalesController(logger, service);
var employeeID = 1;
// Act
var response = await controller.GetOrdersAsync(employeeID: employeeID) as ObjectResult;
var value = response.Value as IPagedResponse<OrderInfo>;
service.Dispose();
// Assert
Assert.False(value.DidError);
Assert.True(value.Model.Count(item => item.EmployeeID == employeeID) == value.Model.Count());
}
[Fact]
public async Task TestGetOrderAsync()
{
// Arrange
var logger = LoggingHelper.GetLogger<SalesController>();
var service = ServiceMocker.GetSalesService(nameof(TestGetOrderAsync));
var controller = new SalesController(logger, service);
var id = 1;
// Act
var response = await controller.GetOrderAsync(id) as ObjectResult;
var value = response.Value as ISingleResponse<OrderHeader>;
service.Dispose();
// Assert
Assert.False(value.DidError);
}
[Fact]
public async Task TestGetNonExistingOrderAsync()
{
// Arrange
var logger = LoggingHelper.GetLogger<SalesController>();
var service = ServiceMocker.GetSalesService(nameof(TestGetNonExistingOrderAsync));
var controller = new SalesController(logger, service);
var id = 0;
// Act
var response = await controller.GetOrderAsync(id) as ObjectResult;
var value = response.Value as ISingleResponse<OrderHeader>;
service.Dispose();
// Assert
Assert.False(value.DidError);
}
[Fact]
public async Task TestGetCreateOrderRequestAsync()
{
// Arrange
var logger = LoggingHelper.GetLogger<SalesController>();
var service = ServiceMocker.GetSalesService(nameof(TestGetCreateOrderRequestAsync));
var controller = new SalesController(logger, service);
// Act
var response = await controller.GetCreateOrderRequestAsync() as ObjectResult;
var value = response.Value as ISingleResponse<CreateOrderRequest>;
service.Dispose();
// Assert
Assert.False(value.DidError);
Assert.True(value.Model.Products.Count() > 0);
Assert.True(value.Model.Customers.Count() > 0);
}
[Fact]
public async Task TestCreateOrderAsync()
{
// Arrange
var logger = LoggingHelper.GetLogger<SalesController>();
var service = ServiceMocker.GetSalesService(nameof(TestCreateOrderAsync));
var controller = new SalesController(logger, service);
var model = new OrderHeaderRequest
{
CustomerID = 1,
PaymentMethodID = new Guid("7671A4F7-A735-4CB7-AAB4-CF47AE20171D"),
Comments = "Order from unit tests",
CreationUser = "unitests",
CreationDateTime = DateTime.Now,
Details = new List<OrderDetailRequest>
{
new OrderDetailRequest
{
ProductID = 1,
ProductName = "The King of Fighters XIV",
Quantity = 1,
}
}
};
// Act
var response = await controller.CreateOrderAsync(model) as ObjectResult;
var value = response.Value as ISingleResponse<OrderHeader>;
service.Dispose();
// Assert
Assert.False(value.DidError);
Assert.True(value.Model.OrderHeaderID.HasValue);
}
[Fact]
public async Task TestCloneOrderAsync()
{
// Arrange
var logger = LoggingHelper.GetLogger<SalesController>();
var service = ServiceMocker.GetSalesService(nameof(TestCloneOrderAsync));
var controller = new SalesController(logger, service);
var id = 1;
// Act
var response = await controller.CloneOrderAsync(id) as ObjectResult;
var value = response.Value as ISingleResponse<OrderHeader>;
service.Dispose();
// Assert
Assert.False(value.DidError);
}
}
}
As we can see those methods perform tests for Urls in Web API project, please take care about the tests are async methods.
SalesControllerTests
class:Chapter 08 - Integration Tests for Web API
In order to work with integration tests, we need to create a class to provide a Web Host to performing Http behavior, this class it will be TestFixture
and to represent Http requests for Web API, there is a class with name SalesTests
, this class will contains all requests for defined actions in SalesController
class, but using a mocked Http client.
Code for TestFixture
class:
Hide Shrink Copy Code
using System;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.ViewComponents;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Store.API.IntegrationTests
{
public class TestFixture<TStartup> : IDisposable
{
public static string GetProjectPath(string projectRelativePath, Assembly startupAssembly)
{
var projectName = startupAssembly.GetName().Name;
var applicationBasePath = AppContext.BaseDirectory;
var directoryInfo = new DirectoryInfo(applicationBasePath);
do
{
directoryInfo = directoryInfo.Parent;
var projectDirectoryInfo = new DirectoryInfo(Path.Combine(directoryInfo.FullName, projectRelativePath));
if (projectDirectoryInfo.Exists)
if (new FileInfo(Path.Combine(projectDirectoryInfo.FullName, projectName, $"{projectName}.csproj")).Exists)
return Path.Combine(projectDirectoryInfo.FullName, projectName);
}
while (directoryInfo.Parent != null);
throw new Exception($"Project root could not be located using the application root {applicationBasePath}.");
}
private TestServer Server;
public TestFixture()
: this(Path.Combine(""))
{
}
protected TestFixture(string relativeTargetProjectParentDir)
{
var startupAssembly = typeof(TStartup).GetTypeInfo().Assembly;
var contentRoot = GetProjectPath(relativeTargetProjectParentDir, startupAssembly);
var configurationBuilder = new ConfigurationBuilder()
.SetBasePath(contentRoot)
.AddJsonFile("appsettings.json");
var webHostBuilder = new WebHostBuilder()
.UseContentRoot(contentRoot)
.ConfigureServices(InitializeServices)
.UseConfiguration(configurationBuilder.Build())
.UseEnvironment("Development")
.UseStartup(typeof(TStartup));
Server = new TestServer(webHostBuilder);
Client = Server.CreateClient();
Client.BaseAddress = new Uri("http://localhost:1234");
Client.DefaultRequestHeaders.Accept.Clear();
Client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}
public void Dispose()
{
Client.Dispose();
Server.Dispose();
}
public HttpClient Client { get; }
protected virtual void InitializeServices(IServiceCollection services)
{
var startupAssembly = typeof(TStartup).GetTypeInfo().Assembly;
var manager = new ApplicationPartManager();
manager.ApplicationParts.Add(new AssemblyPart(startupAssembly));
manager.FeatureProviders.Add(new ControllerFeatureProvider());
manager.FeatureProviders.Add(new ViewComponentFeatureProvider());
services.AddSingleton(manager);
}
}
}
Now, this is the code for SalesTests
class:
Hide Shrink Copy Code
using System;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using OnLineStore.WebAPI.IntegrationTests.Helpers;
using Xunit;
namespace OnLineStore.WebAPI.IntegrationTests
{
public class SalesTests : IClassFixture<TestFixture<Startup>>
{
private HttpClient Client;
public SalesTests(TestFixture<Startup> fixture)
{
Client = fixture.Client;
}
[Fact]
public async Task TestGetOrdersAsync()
{
// Arrange
var request = "/api/v1/Sales/Order";
// Act
var response = await Client.GetAsync(request);
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task TestGetOrdersByCurrencyAsync()
{
// Arrange
var currencyID = (short)1;
var request = string.Format("/api/v1/Sales/Order?currencyID={0}", currencyID);
// Act
var response = await Client.GetAsync(request);
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task TestGetOrdersByCustomerAsync()
{
// Arrange
var customerID = 1;
var request = string.Format("/api/v1/Sales/Order?customerID={0}", customerID);
// Act
var response = await Client.GetAsync(request);
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task TestGetOrdersByEmployeeAsync()
{
// Arrange
var employeeID = 1;
var request = string.Format("/api/v1/Sales/Order?employeeID={0}", employeeID);
// Act
var response = await Client.GetAsync(request);
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task TestGetOrderByIdAsync()
{
// Arrange
var id = 1;
var request = string.Format("/api/v1/Sales/Order/{0}", id);
// Act
var response = await Client.GetAsync(request);
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task TestGetOrderByNonExistingIdAsync()
{
// Arrange
var id = 0;
var request = string.Format("/api/v1/Sales/Order/{0}", id);
// Act
var response = await Client.GetAsync(request);
// Assert
Assert.True(response.StatusCode == HttpStatusCode.NotFound);
}
[Fact]
public async Task TestGetCreateOrderRequestAsync()
{
// Arrange
var request = "/api/v1/Sales/CreateOrderRequest";
// Act
var response = await Client.GetAsync(request);
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task TestCreateOrderAsync()
{
// Arrange
var request = "/api/v1/Sales/Order";
var model = new
{
CustomerID = 1,
PaymentMethodID = new Guid("7671A4F7-A735-4CB7-AAB4-CF47AE20171D"),
Comments = "Order from integration tests",
CreationUser = "integrationtests",
Details = new[]
{
new
{
ProductID = 1,
Quantity = 1
}
}
};
// Act
var response = await Client.PostAsync(request, ContentHelper.GetStringContent(model));
// Assert
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task TestCloneOrderAsync()
{
// Arrange
var id = 1;
var request = string.Format("/api/v1/Sales/CloneOrder/{0}", id);
// Act
var response = await Client.GetAsync(request);
// Assert
response.EnsureSuccessStatusCode();
}
}
}
Don't forget we can have more tests, we have class with name ProductionTests
to perform requests for ProductionController
.
TestFixture
and to represent Http requests for Web API, there is a class with name SalesTests
, this class will contains all requests for defined actions in SalesController
class, but using a mocked Http client.TestFixture
class:SalesTests
class:ProductionTests
to perform requests for ProductionController
.Code Improvements
- Add Security (e.g.
IdentityServer4
)
- Save logs to text file or database
- Implement Money Pattern to represent money in application
- Add section to explain why this code doesn't use Generic Repository and Unit of Work
IdentityServer4
)
No comments:
Post a Comment