Appearance
AppsPortal Service
AppsPortal is the core ERP backend service for Microtec. It hosts the main business modules — Accounting, Finance, Sales, Purchase, Inventory, Distribution, and Fixed Assets — all served from a single deployable unit (AppsPortal.Apis) running on the Private CAE.
Overview
| Property | Value |
|---|---|
| Solution | Microtec.Platforms.sln |
| CAE placement | Private CAE |
| Gateway route prefix | /erp-apis/ |
| Source | Platforms/Src/AppsPortal/ |
| Architecture | Clean Architecture + CQRS + MediatR |
| Auth realm | microtec (Keycloak) |
Modules
AppsPortal is logically divided into business modules. Each module is a folder inside the Application, Domain, and Infrastructure projects.
| Module | Route Prefix | Status | Key Features |
|---|---|---|---|
| Accounting | /Accounting/ | Complete | Chart of accounts, journals, ledgers, trial balance, closing |
| Finance | /Finance/ | Partial | Cash management, bank reconciliation, cheques |
| Sales | /Sales/ | Partial | Quotations, sales orders, invoices, returns |
| Purchase | /Purchase/ | Partial | Purchase orders, goods receipt, supplier invoices |
| Inventory | /Inventory/ | Partial | Items, warehouses, transfers, stock adjustments |
| Distribution | /Distribution/ | Partial | Routes, van sales, delivery orders |
| Fixed Assets | /FixedAssets/ | Partial | Asset registration, depreciation schedules |
Inventory is split
The Inventory module has its own dedicated API (Inventory.Apis) that runs as a separate microservice. See Inventory API for details.
Solution Structure
Platforms/Src/AppsPortal/
├── AppsPortal.Apis/ # REST controllers, startup, Dockerfile
│ ├── Controllers/
│ │ ├── Accounting/
│ │ │ ├── InvoicesController.cs
│ │ │ ├── JournalsController.cs
│ │ │ └── ...
│ │ ├── Sales/
│ │ ├── Finance/
│ │ └── ...
│ ├── Program.cs
│ ├── appsettings.json
│ └── Dockerfile
├── AppsPortal.Application/ # CQRS handlers, DTOs, validators
│ ├── Accounting/
│ │ ├── Invoices/
│ │ │ ├── Commands/
│ │ │ │ ├── AddInvoice/
│ │ │ │ ├── EditInvoice/
│ │ │ │ └── DeleteInvoice/
│ │ │ └── Queries/
│ │ │ ├── GetByIdInvoice/
│ │ │ ├── GetAllInvoices/
│ │ │ └── GetDropdownInvoice/
│ │ ├── Journals/
│ │ └── ChartOfAccounts/
│ ├── Sales/
│ ├── Finance/
│ └── ...
├── AppsPortal.Domain/ # Entities, enums, domain events, value objects
│ ├── Accounting/
│ │ ├── Invoice.cs
│ │ ├── InvoiceLine.cs
│ │ ├── Journal.cs
│ │ └── Account.cs
│ └── ...
└── AppsPortal.Infrastructure/ # EF Core contexts, migrations, repo implementations
├── Persistence/
│ ├── AppDbContext.cs
│ ├── Migrations/
│ └── Configurations/
└── ...Accounting Module — Key Domain Entities
The Accounting module is the most complete module in AppsPortal. Understanding its domain model is a good template for all other modules.
Module Registration Pattern
Each AppsPortal module registers its own dependencies via a marker interface extension method. The main Program.cs calls a single registration chain:
csharp
// Program.cs
builder.Services
.AddAccountingModule(builder.Configuration)
.AddSalesModule(builder.Configuration)
.AddFinanceModule(builder.Configuration)
.AddPurchaseModule(builder.Configuration);
// Each module's registration file — e.g., AccountingServiceCollectionExtensions.cs
public static IServiceCollection AddAccountingModule(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddMediator(typeof(AccountingAssemblyMarker).Assembly);
services.AddDbContext<AccountingDbContext>(options =>
options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));
return services;
}Adding a New Feature
Follow this checklist when adding a new entity or operation to any AppsPortal module.
Step 1 — Domain Entity
csharp
// AppsPortal.Domain/Accounting/CostCenter.cs
namespace Microtec.AppsPortal.Domain.Accounting;
public class CostCenter
{
public int Id { get; private set; }
public string Code { get; private set; } = string.Empty;
public string NameAr { get; private set; } = string.Empty;
public string NameEn { get; private set; } = string.Empty;
public bool IsActive { get; private set; } = true;
// Factory method — use instead of public constructor
public static CostCenter Create(string code, string nameAr, string nameEn)
=> new() { Code = code, NameAr = nameAr, NameEn = nameEn };
public void Edit(string nameAr, string nameEn)
{
NameAr = nameAr;
NameEn = nameEn;
}
public void Deactivate() => IsActive = false;
}Step 2 — EF Core Configuration
csharp
// AppsPortal.Infrastructure/Persistence/Configurations/CostCenterConfiguration.cs
public class CostCenterConfiguration : IEntityTypeConfiguration<CostCenter>
{
public void Configure(EntityTypeBuilder<CostCenter> builder)
{
builder.HasKey(x => x.Id);
builder.Property(x => x.Code).HasMaxLength(20).IsRequired();
builder.Property(x => x.NameAr).HasMaxLength(200).IsRequired();
builder.Property(x => x.NameEn).HasMaxLength(200).IsRequired();
builder.HasIndex(x => x.Code).IsUnique();
}
}Step 3 — CQRS Command
csharp
// Application/Accounting/CostCenters/Commands/AddCostCenter/AddCostCenterCommand.cs
public record AddCostCenterCommand(AddCostCenterDto Dto) : ICommand<int>;
// Application/Accounting/CostCenters/Commands/AddCostCenter/Dto/AddCostCenterDto.cs
public record AddCostCenterDto(string Code, string NameAr, string NameEn);
// Application/Accounting/CostCenters/Commands/AddCostCenter/AddCostCenterCommandValidator.cs
public class AddCostCenterCommandValidator : AbstractValidator<AddCostCenterCommand>
{
public AddCostCenterCommandValidator()
{
RuleFor(x => x.Dto.Code).NotEmpty().MaximumLength(20);
RuleFor(x => x.Dto.NameAr).NotEmpty().MaximumLength(200);
RuleFor(x => x.Dto.NameEn).NotEmpty().MaximumLength(200);
}
}
// Application/Accounting/CostCenters/Commands/AddCostCenter/AddCostCenterCommandHandler.cs
public class AddCostCenterCommandHandler(
IUnitOfWork<AppDbContext> unitOfWork) : IRequestHandler<AddCostCenterCommand, ApiResponse<int>>
{
public async Task<ApiResponse<int>> Handle(
AddCostCenterCommand request, CancellationToken cancellationToken)
{
var costCenter = CostCenter.Create(
request.Dto.Code, request.Dto.NameAr, request.Dto.NameEn);
await unitOfWork.Repository<CostCenter>().AddAsync(costCenter, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);
return ApiResponse.Success(costCenter.Id);
}
}Step 4 — Controller
csharp
// Apis/Controllers/Accounting/CostCentersController.cs
[ApiController]
[Route("[controller]")]
public class CostCentersController(IMediator mediator) : ControllerBase
{
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<PagedList<GetAllCostCentersDto>>), 200)]
public Task<IActionResult> GetAll([FromQuery] GetAllCostCentersQuery query)
=> mediator.Send(query).ToActionResult();
[HttpGet("{Id:int}")]
[ProducesResponseType(typeof(ApiResponse<GetByIdCostCenterDto>), 200)]
public Task<IActionResult> GetById([FromRoute] GetByIdCostCenterQuery query)
=> mediator.Send(query).ToActionResult();
[HttpPost]
[ProducesResponseType(typeof(ApiResponse<int>), 200)]
public Task<IActionResult> Add([FromBody] AddCostCenterDto dto)
=> mediator.Send(new AddCostCenterCommand(dto)).ToActionResult();
[HttpPut]
[ProducesResponseType(typeof(ApiResponse), 200)]
public Task<IActionResult> Edit([FromBody] EditCostCenterDto dto)
=> mediator.Send(new EditCostCenterCommand(dto)).ToActionResult();
[HttpDelete("{Id:int}")]
[ProducesResponseType(typeof(ApiResponse), 200)]
public Task<IActionResult> Delete([FromRoute] DeleteCostCenterCommand command)
=> mediator.Send(command).ToActionResult();
[HttpGet("DropDown")]
[ProducesResponseType(typeof(ApiResponse<List<DropdownDto>>), 200)]
public Task<IActionResult> DropDown()
=> mediator.Send(new GetDropdownCostCenterQuery()).ToActionResult();
}Multi-Tenancy
AppsPortal is fully multi-tenant. Each tenant has an isolated database. The tenant context is resolved per-request from the JWT claims via ITenantProvider:
csharp
// Resolved automatically by Microtec.Web.Core middleware
public interface ITenantProvider
{
int TenantId { get; }
string TenantCode { get; }
string ConnectionString { get; }
}The IUnitOfWork<AppDbContext> factory uses the tenant's connection string, so each handler automatically operates on the correct tenant database without any additional configuration.
Never share AppDbContext instances
Do not inject AppDbContext directly. Always use IUnitOfWork<AppDbContext> to ensure the per-tenant connection string is applied.
Key Shared Services
| Interface | Package | Purpose |
|---|---|---|
IClockService | Microtec.Domain | Current UTC time — never use DateTime.Now |
ICurrentUserService | Microtec.Web.Core | Authenticated user ID, name, roles from JWT |
ITenantProvider | Microtec.Web.Core | Tenant ID and connection string from JWT |
IUnitOfWork<TContext> | Microtec.Persistence | Per-tenant repository and save operations |
Inter-Service Communication
AppsPortal calls other microservices via typed HTTP clients defined in Microtec.PublicApi.AppsPortal:
| Client Interface | Target Service | Usage |
|---|---|---|
IAttachmentPublicApi | Attachment.Apis | Upload/download documents |
INotificationPublicApi | Notification.Apis | Send email/SMS notifications |
IWorkflowPublicApi | Workflow.Apis | Trigger approval workflows |
IIntegrationPublicApi | Integration.Apis | ZATCA e-invoice submission |
csharp
// Injecting an inter-service client
public class PostInvoiceCommandHandler(
IUnitOfWork<AppDbContext> unitOfWork,
IIntegrationPublicApi integrationApi) : IRequestHandler<PostInvoiceCommand, ApiResponse>
{
public async Task<ApiResponse> Handle(PostInvoiceCommand request, CancellationToken ct)
{
var invoice = await unitOfWork.Repository<Invoice>().GetByIdAsync(request.InvoiceId);
invoice.Post();
await unitOfWork.SaveChangesAsync(ct);
// Fire ZATCA submission asynchronously
await integrationApi.SubmitInvoiceAsync(invoice.Id, ct);
return ApiResponse.Success();
}
}Configuration
json
// appsettings.json (AppsPortal.Apis)
{
"ConnectionStrings": {
"AdminConnection": "Server=...;Database=MicrotecAdmin;..."
},
"Keycloak": {
"Authority": "https://<keycloak-host>/realms/microtec",
"Audience": "account"
},
"Redis": {
"Configuration": "<redis-host>:10000,ssl=true,password=..."
},
"Services": {
"Attachment": "https://mic-erp-be-attachment.internal.<env>.azurecontainerapps.io",
"Notification": "https://mic-erp-be-notification.internal.<env>.azurecontainerapps.io",
"Workflow": "https://mic-erp-be-workflow.internal.<env>.azurecontainerapps.io",
"Integration": "https://mic-erp-be-integration.internal.<env>.azurecontainerapps.io"
}
}Local Build & Run
bash
cd Platforms
# Build the full solution
dotnet build Microtec.Platforms.sln
# Run AppsPortal only
dotnet run --project Src/AppsPortal/AppsPortal.Apis/AppsPortal.Apis.csproj
# Run with .NET Aspire (recommended — spins up all services)
dotnet run --project Src/Gateway/Microtec.Platforms.AppHost/Microtec.Platforms.AppHost.csprojNuGet Feed
Before the first build, complete the NuGet private feed setup described in Platforms/Solution Items/NUGET-SETUP.md. Without a valid PAT, the build will fail restoring Microtec.Domain and other shared packages.
Deployment
AppsPortal is containerised via the Dockerfile in AppsPortal.Apis/. It is listed in the pipeline config as:
Devops/azure/config/container-backend/services-config.json → "appsportal"The CI/CD pipeline auto-detects changes in Platforms/Src/AppsPortal/ and rebuilds only the affected environment images.