Skip to content

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

PropertyValue
SolutionMicrotec.Platforms.sln
CAE placementPrivate CAE
Gateway route prefix/erp-apis/
SourcePlatforms/Src/AppsPortal/
ArchitectureClean Architecture + CQRS + MediatR
Auth realmmicrotec (Keycloak)

Modules

AppsPortal is logically divided into business modules. Each module is a folder inside the Application, Domain, and Infrastructure projects.

ModuleRoute PrefixStatusKey Features
Accounting/Accounting/CompleteChart of accounts, journals, ledgers, trial balance, closing
Finance/Finance/PartialCash management, bank reconciliation, cheques
Sales/Sales/PartialQuotations, sales orders, invoices, returns
Purchase/Purchase/PartialPurchase orders, goods receipt, supplier invoices
Inventory/Inventory/PartialItems, warehouses, transfers, stock adjustments
Distribution/Distribution/PartialRoutes, van sales, delivery orders
Fixed Assets/FixedAssets/PartialAsset 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

InterfacePackagePurpose
IClockServiceMicrotec.DomainCurrent UTC time — never use DateTime.Now
ICurrentUserServiceMicrotec.Web.CoreAuthenticated user ID, name, roles from JWT
ITenantProviderMicrotec.Web.CoreTenant ID and connection string from JWT
IUnitOfWork<TContext>Microtec.PersistencePer-tenant repository and save operations

Inter-Service Communication

AppsPortal calls other microservices via typed HTTP clients defined in Microtec.PublicApi.AppsPortal:

Client InterfaceTarget ServiceUsage
IAttachmentPublicApiAttachment.ApisUpload/download documents
INotificationPublicApiNotification.ApisSend email/SMS notifications
IWorkflowPublicApiWorkflow.ApisTrigger approval workflows
IIntegrationPublicApiIntegration.ApisZATCA 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.csproj

NuGet 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.

Internal Documentation — Microtec Platform Team