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 create the project for api already. Let open it in Visual Studio and add new "Class Library" project and 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 request related to Staff will call to this uri. See "RESTful Web Services" for more information.
- StaffHandler was inherit 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 atribute 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 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 inherit from BaseQueryRepository also.
- There are 2 required constructors. the first was used for reading and 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 appropriated data. In this case, we can use DbSet fro 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 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 need 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.
The last, 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 above configuration, we tell with the system that, IHRMQueryContext will connect to MongoDB and using DefaultMongoDb connection string as describe below.
Let run TinyERP.Api and call to GetStaffs method, the result 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 necessary location.
- There are many phase in 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);
// and other business validations here, such as: unit first + last name, unit email, ....
validator.ThrowIfError();
}
}
}
I think the code was straight-forward, just define Handle method which was declare by IStaffCommandHandler:
namespace TinyERP.HRM.Command
{
using TinyERP.Common.Command;
using TinyERP.HRM.Command.Staff;
internal interface IStaffCommandHandler:
IBaseCommandHandler<CreateStaffRequest, CreateStaffResponse>
{
}
}
We should all appropriated 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 as 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 to "api/hrm/staffs" again:
And the structure for current HRM project is:
Let 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 the list of staffs were displayed on UI
an overview about the flow for "Manage Staff" 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 question we continue 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,CodeProject