Overview
In this article, we implement the API side for "Manage Staff" feature and create default Staff using CQRS pattern.
For the API Side
As described in "[TinyERP: SPA for Enterprise Application]Overview", we already created the project for API. Let's open it in Visual Studio and add new "Class Library" project named "TinyERP.HRM
":
Remember to delete unnecessary cs file in new project and add "TinyERP.HRM
" into "TinyERP.Api
" as reference.
Add new StaffHandler.cs into TinyERP.HRM\Api:
namespace TinyERP.HRM.Api
{
using Common.DI;
using Query;
using Search.Share;
using Share.Staff;
using System.Web.Http;
using TinyERP.Common.MVC;
using TinyERP.Common.MVC.Attributes;
[RoutePrefix("api/hrm/staffs")]
public class StaffHandler: BaseApiController
{
[Route("")]
[HttpGet()]
[ResponseWrapper()]
public ISearchResult<StaffListItem> GetStaffs() {
IStaffQuery query = IoC.Container.Resolve<IStaffQuery>();
return query.Search<StaffListItem>();
}
}
}
This is the normal ApiController
in WebAPI
, there are some points:
RoutePrefix
is "api/hrm/staffs". Staff
is the resource in out system, so all requests related to Staff
will call to this uri
. See "RESTful Web Services" for more information. StaffHandler
was inherited from BaseApiController
which defined TinyERP.Common
. We need to install this package from nuget. For more information, see https://www.nuget.org/packages/TinyERP.Common - We use
ResponseWrapper
attribute for most API methods. ISearchResult
was used in the case we want to search data with some conditions. For example, search staff using first name, email. This Interface was defined in TinyERP.Search.Share
. Please add this package from nuget. See TinyERP.Search.Share IStaffQuery
was used to get data from read database only as we use CQRS pattern for this feature. - IoC was defined in
TinyERP.Common
also. There is no need to initialize this container.
Let's continue adding IStaffQuery.cs:
namespace TinyERP.HRM.Query
{
using TinyERP.Common.Data;
using TinyERP.Search.Share;
internal interface IStaffQuery : IBaseQueryRepository<TinyERP.HRM.Query.Entities.StaffSummary>
{
ISearchResult<TResult> Search<TResult>();
}
}
It was simple, just inherit from IBaseQueryRepository
. We will get data from StaffSummary
collection in MongoDB
, that is why we need to specify this class in generic declaration.
And implementation for IStaffQuery
:
namespace TinyERP.HRM.Query
{
using Common.Data;
using TinyERP.HRM.Query.Entities;
using Search.Share;
using System.Linq;
using System.Collections.Generic;
using Common.Extensions;
internal class StaffQuery : BaseQueryRepository<StaffSummary>, IStaffQuery
{
public StaffQuery() : base() { }
public StaffQuery(IUnitOfWork uow) : base(uow.Context) { }
public ISearchResult<TResult> Search<TResult>()
{
IList<TResult> items = this.DbSet.AsQueryable().ToList().Cast<StaffSummary, TResult>();
ISearchResult<TResult> result = new SearchResult<TResult>(items, items.Count);
return result;
}
}
}
StaffQuery
was also inherited from BaseQueryRepository
. - There are 2 required constructors, the first was used for reading and the second for updating data on read site of CQRS pattern.
- There is available
DbSet
as property of BaseQueryRepository
, we can use to read, update or delete appropriate data. In this case, we can use DbSet
for working on StaffSummary
only. Just need to convert this to IQueryable
and using LINQ to get data. - Cast: This is extension to convert from
A
class to B
class. In this sample, It will convert collection from StaffSummary
to StaffListItem
type.
We also need to map IStaffQuery
to StaffQuery
using IBootstrapper<ITaskArgument> interface
:
namespace TinyERP.HRM.Query
{
using TinyERP.Common.DI;
using TinyERP.Common.Tasks;
public class Bootstrap:BaseTask<ITaskArgument>, IBootstrapper<ITaskArgument>
{
public Bootstrap():base(Common.ApplicationType.All){}
public override void Execute(ITaskArgument context)
{
if (!this.IsValid(context.Type)) { return; }
IBaseContainer container = context.Context as IBaseContainer;
container.RegisterTransient<IStaffQuery, StaffQuery>();
}
}
}
I suggest that we should use transient for most interfaces. This will reduce the amount of memory at runtime.
Let's define StaffSummary
class, this is a collection in mongodb server:
namespace TinyERP.HRM.Query.Entities
{
using Common.MVC.Attributes;
using Context;
using System;
using TinyERP.Common;
[DbContext(Use = typeof(IHRMQueryContext))]
internal class StaffSummary: AggregateSummaryEntity
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string Department { get; set; }
public StaffSummary(Guid aggregateId):base(aggregateId){}
}
}
There are some interesting points:
DbContext
attribute: Describe which database context we want to use. This is useful for the case, we have a lot entities (tables) on single database, so we can break this into multiple smaller databases. We will mention this again in "Scale your repository" article. StaffSummary
was considered as aggregate root for "staff domain". That is why it needs to inherit from AggregateSummaryEntity
(from TinyERP.Common package). - The aggregate root must have the constructor with the
GUID
value. This is the ID of appropriated object on write database.
And IHRMQueryContext.cs:
namespace TinyERP.HRM.Context
{
using TinyERP.Common.Data;
public interface IHRMQueryContext:IDbContext
{
}
}
In this interface, we only need to inherit from IDbContext
.
OK, for now, we can get the list of staff from read database and return back to client side.
Finally, we need to config the connection string for IHRMQueryContext
in TinyERP.Api/config/configuration.debug.config. In aggregates section, add:
<add name="TinyERP.HRM.Context.IHRMQueryContext"
repoType="MongoDb" connectionStringName="DefaultMongoDb"></add>
and add this into databases section:
<add
name="DefaultMongoDb"
database="TinyERP"
server="localhost"
port="27017"
userName=""
password=""
ssl="false"
dbType="MongoDb"
default="true"
></add>
With the above configuration, we tell with the system that, IHRMQueryContext
will connect to MongoDB and using DefaultMongoDb
connection string
as described below.
Let's run TinyERP.Api
and call the GetStaffs
method, the result is as below:
Let me explain a little:
status
: This determines if the request was success or fail. We usually use 200 (OK), 400 (Bad Request), 500 (InternalServerError). Errors
: This will contain the list of validation errors, for example: "invalid user name or password" in login request. Data
: This is the response from server if the status is 200.
For now, we receive empty in data
property as there is no data in mongodb server.
I think, for now, we should create some staffs as initialized data.
Add New Staff
Now, we will create CreateDefaultStaff
in "TinyERP.HRM\Share\Tasks" folder:
namespace TinyERP.HRM.Share.Task
{
using Command.Staff;
using Common.Command;
using TinyERP.Common.Tasks;
public class CreateDefaultStaff: BaseTask<ITaskArgument>,
TinyERP.Common.Tasks.IApplicationReadyTask<ITaskArgument>
{
public CreateDefaultStaff():base(Common.ApplicationType.All){}
public override void Execute(ITaskArgument context)
{
if (!this.IsValid(context.Type)) { return; }
CreateStaffRequest request =
new CreateStaffRequest("Tu", "Tran", "contact@tranthanhtu.vn");
ICommandHandlerStrategy commandHandler =
CommandHandlerStrategyFactory.Create<TinyERP.HRM.Aggregate.Staff>();
CreateStaffResponse response =
commandHandler.Execute<CreateStaffRequest, CreateStaffResponse>(request);
this.Logger.Info("New staff (id: {0}) was created", response.Id);
}
}
}
- It was simple to create
CreateStaffRequest
and call Execute
method. System
will redirect this request to the necessary location. - There are many phases in the life-cyle of the application which we can inject custom task. When all necessary configurations complete, this task will be called.
Content of CreateStaffRequest
and CreateStaffResponse
as below:
namespace TinyERP.HRM.Command.Staff
{
using TinyERP.Common.Command;
public class CreateStaffRequest: IBaseCommand
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public CreateStaffRequest(string firstName, string lastName, string email)
{
this.FirstName = firstName;
this.LastName = lastName;
this.Email = email;
}
}
}
namespace TinyERP.HRM.Command.Staff
{
using System;
class CreateStaffResponse
{
public Guid Id { get; set; }
}
}
We can see that it was simple. Create request with 3 fields: first name, last name and email and receive back the Id of newly created staff.
We need to register the handler for this request, Create command folder and add this class:
using TinyERP.Common.Command;
using TinyERP.Common.DI;
using TinyERP.Common.Tasks;
using TinyERP.HRM.Command.Staff;
namespace TinyERP.HRM.Command
{
public class Bootstrap: BaseTask<ITaskArgument>, IBootstrapper<ITaskArgument>
{
public Bootstrap():base(Common.ApplicationType.All){}
public override void Execute(ITaskArgument arg)
{
if (!this.IsValid(arg.Type)) { return; }
IBaseContainer container = arg.Context as IBaseContainer;
container.RegisterTransient<IBaseCommandHandler
<CreateStaffRequest, CreateStaffResponse>, StaffCommandHandler>();
}
}
}
This means that CreateStaffRequest
request will be redirected to StaffCommandHandler
class:
namespace TinyERP.HRM.Command
{
using System;
using TinyERP.Common.Command;
using Staff;
using Common.Helpers;
using Common.Validation;
using Common.Data;
using Repository;
using Common.DI;
internal class StaffCommandHandler : BaseCommandHandler, IStaffCommandHandler
{
public CreateStaffResponse Handle(CreateStaffRequest command)
{
this.Validate(command);
using (IUnitOfWork uow = this.CreateUnitOfWork<TinyERP.HRM.Aggregate.Staff>()) {
TinyERP.HRM.Aggregate.Staff staff = new Aggregate.Staff();
staff.UpdateBasicInfo(command);
IStaffRepository repository = IoC.Container.Resolve<IStaffRepository>(uow);
repository.Add(staff);
uow.Commit();
staff.PublishEvents();
return ObjectHelper.Cast<CreateStaffResponse>(staff);
}
}
private void Validate(CreateStaffRequest command)
{
IValidationException validator = ValidationHelper.Validate(command);
validator.ThrowIfError();
}
}
}
I think the code was straight-forward, just define Handle
method which was declared by IStaffCommandHandler
:
namespace TinyERP.HRM.Command
{
using TinyERP.Common.Command;
using TinyERP.HRM.Command.Staff;
internal interface IStaffCommandHandler:
IBaseCommandHandler<CreateStaffRequest, CreateStaffResponse>
{
}
}
We should all appropriate methods of aggregate
object to perform necessary action (such as: update first name in this case).
For each change in aggregate
, the new event will be raised. Then the read side of CQRS pattern will subscribe those events and update data appropriately.
So, for Staff.cs:
namespace TinyERP.HRM.Aggregate
{
using TinyERP.Common.Aggregate;
using Command.Staff;
using Event;
using Common.MVC.Attributes;
using Context;
[DbContext(Use = typeof(IHRMContext))]
internal class Staff: BaseAggregateRoot
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public Staff()
{
this.AddEvent(new OnStaffCreated(this.Id));
}
internal void UpdateBasicInfo(CreateStaffRequest command)
{
this.FirstName = command.FirstName;
this.LastName = command.LastName;
this.Email = command.Email;
this.AddEvent(new OnStaffBasicInforChanged
(this.Id, this.FirstName, this.LastName, this.Email));
}
}
}
and StaffEventhandler.cs:
namespace TinyERP.HRM.Event
{
using System;
using Common.Data;
using Common.DI;
using Query;
using Query.Entities;
using TinyERP.Common.Event;
internal class StaffEventHandler : BaseEventHandler, IStaffEventHandler
{
public void Execute(OnStaffBasicInforChanged ev)
{
using (IUnitOfWork uow = this.CreateUnitOfWork<StaffSummary>())
{
IStaffQuery query = IoC.Container.Resolve<IStaffQuery>(uow);
StaffSummary staff = query.GetByAggregateId(ev.StaffId.ToString());
staff.FirstName = ev.FirstName;
staff.LastName = ev.LastName;
staff.Email = ev.Email;
query.Update(staff);
uow.Commit();
}
}
public void Execute(OnStaffCreated ev)
{
using (IUnitOfWork uow = this.CreateUnitOfWork<StaffSummary>())
{
StaffSummary summary = new StaffSummary(ev.StaffId);
IStaffQuery query = IoC.Container.Resolve<IStaffQuery>(uow);
query.Add(summary);
uow.Commit();
}
}
}
}
In StaffEventHandler
, we receive changes (raised by StaffCommandHandler
) and update into read database (it was mongodb in this case).
We also need to config for IHRMContext
similar to IHRMQueryContext
.
In aggregates section (in config/configuration.debug.config), add:
<add name="TinyERP.HRM.Context.IHRMContext" repoType="MSSQL"
connectionStringName="DefaultMSSQL"></add>
and add this into databases section:
<add
name="DefaultMSSQL"
database="TinyERP"
server=".\SqlExpress"
port="0"
userName="sa"
password="123456"
dbType="MSSQL"
></add>
Let try to compile and run the app again, you can see there is new record added into both MSSQL and Mongodb:
Ok, So, call "api/hrm/staffs" again:
And the structure for current HRM project is:
Let's go to the last step to finish this article.
Open staffService.ts on client and update uri
to api
:
export class StaffService extends BaseService implements IStaffService{
public getStaffs():Promise{
let uri="http://localhost:56622/api/hrm/staffs";
let iconnector: IConnector = window.ioc.resolve(IoCNames.IConnector);
return iconnector.get(uri);
}
}
Compile and run the client again. We can see the list of staffs
were displayed on UI.
An overview about the flow for "Manage Staff" is as below:
Until now, we can:
- Create new staff using
IApplicationReadyTask interface
. - Create and send
CreateStaffRequest
. - Register command handler/ event handler using
IBootstrapper interface
. - Define
StaffCommandHandler
and IStaffCommandHandler
to handle CreateStaffRequest
. - Define
StaffEventHandler
and IStaffEventHandler
to handle appropriated event for "staff domain". - Get list of
Staffs
using IStaffQuery
.
There are many questions we continue to clarify on "Revise Manage Staff" article later.
For the reference source code in this part, please have a look at https://github.com/tranthanhtu0vn/TinyERP (branch: feature/manage_staff).
Other articles in series:
Thank you for reading.