Appearance
Backend Developer Onboarding
A practical guide to becoming productive on the Microtec ERP .NET 8 backend.
Prerequisites: Complete Day 1 Checklist first
Stack: .NET 8, MediatR, EF Core 8, FluentValidation, MassTransit, Serilog
Solution Structure
Two solutions sit under Platforms/:
Platforms/
├── Microtec.Platforms.sln ← ERP + Infrastructure (~30 projects)
├── Microtec.BusinessOwner.sln ← Tenant admin portal
└── Src/
├── AppsPortal/ ← ERP modules
│ └── Accounting/ ← Most complete reference module
│ ├── AppsPortal.Apis/ # REST controllers
│ ├── AppsPortal.Application/ # CQRS commands/queries
│ ├── AppsPortal.Domain/ # Entities, value objects
│ └── AppsPortal.Infrastructure/ # EF Core, repositories
├── BusinessOwners/ ← Multi-tenant admin portal
├── InfrastructureServices/ ← Cross-cutting microservices
│ ├── Attachment/
│ ├── Notification/
│ ├── Workflow/
│ ├── Import/
│ ├── Zatca/ # Saudi e-invoicing
│ └── Reporting/
├── Shared/ ← Becomes NuGet packages
│ ├── Shared.Core/ # → Microtec.Domain
│ ├── Shared.Infrastructure/ # → Microtec.Persistence
│ └── Shared.Web/ # → Microtec.Web.Core / Microtec.Web.Hosting
├── Auth/ ← Auth middleware (packaged)
├── Gateway/ ← YARP reverse proxy
└── Website/ ← Marketing website[INFO] Start by reading the
Accountingmodule — it is the most complete and follows all conventions correctly. Use it as your reference implementation.
Key Patterns
CQRS with MediatR
Every feature is implemented as a command or query. Controllers are thin — they only dispatch to MediatR.
Command flow:
Controller → Send(AddInvoiceCommand) → AddInvoiceCommandHandler → Result
Query flow:
Controller → Send(GetAllInvoicesQuery) → GetAllInvoicesQueryHandler → PaginatedList<InvoiceDto>Naming convention (strict — must follow exactly):
| Operation | Command/Query Name |
|---|---|
| Create | Add{Entity}Command |
| Update | Edit{Entity}Command |
| Delete | Delete{Entity}Command |
| Get by ID | GetById{Entity}Query |
| Get paginated list | GetAll{Entity}Query |
| Get dropdown | GetDropdown{Entity}Query |
| Export | Export{Entity}Query |
File locations:
AppsPortal.Application/
└── Invoice/
├── Commands/
│ ├── Add/
│ │ ├── AddInvoiceCommand.cs
│ │ ├── AddInvoiceCommandHandler.cs
│ │ └── AddInvoiceCommandValidator.cs
│ └── Edit/
│ └── ...
└── Queries/
├── GetAll/
│ ├── GetAllInvoicesQuery.cs
│ └── GetAllInvoicesQueryHandler.cs
└── GetById/
└── ...Command handler example
csharp
public record AddInvoiceCommand(string Number, decimal Total) : IRequest<int>;
public class AddInvoiceCommandHandler(IUnitOfWork<AppDbContext> unitOfWork, IClockService clock)
: IRequestHandler<AddInvoiceCommand, int>
{
public async Task<int> Handle(AddInvoiceCommand request, CancellationToken cancellationToken)
{
var invoice = new Invoice
{
Number = request.Number,
Total = request.Total,
CreatedAt = clock.Now // Never use DateTime.Now directly
};
await unitOfWork.Repository<Invoice>().AddAsync(invoice, cancellationToken);
await unitOfWork.CompleteAsync(cancellationToken);
return invoice.Id;
}
}FluentValidation
csharp
public class AddInvoiceCommandValidator : AbstractValidator<AddInvoiceCommand>
{
public AddInvoiceCommandValidator()
{
RuleFor(x => x.Number)
.NotEmpty()
.MaximumLength(50);
RuleFor(x => x.Total)
.GreaterThan(0);
}
}Your First Feature
Follow this exact sequence for any new feature:
- Create domain entity in
{Module}.Domain/Entities/ - Add EF Core mapping in
{Module}.Infrastructure/Persistence/Configurations/ - Create migration via
dotnet ef migrations add {MigrationName} - Write command/query in
{Module}.Application/{Feature}/Commands/or/Queries/ - Write validator alongside the command
- Write handler — inject
IUnitOfWorkandIClockService - Write controller action — thin, just dispatch to MediatR
- Write tests — unit tests for handler, integration tests for API endpoint
Running a specific test
bash
cd Platforms
# Run a single test class
dotnet test --filter "FullyQualifiedName~AddInvoiceCommandHandlerTests"
# Run all tests in a project
dotnet test Src/AppsPortal/Accounting/AppsPortal.Tests/AppsPortal.Tests.csproj
# Run with verbose output
dotnet test --logger "console;verbosity=detailed"Key Services and Patterns
Multi-tenancy
Every API request carries a tenant context. The platform resolves the tenant from the JWT token:
csharp
// Inject ITenantContextManager to access current tenant info
public class MyHandler(ITenantContextManager tenantContext)
{
var tenantId = tenantContext.TenantId;
var dbName = tenantContext.DatabaseName;
}Database-per-tenant: Each tenant has their own SQL database. IUnitOfWork<TContext> automatically connects to the correct database for the current request.
Read-only queries — always use AsNoTracking()
csharp
// Correct — no EF change tracking overhead for reads
var invoices = await unitOfWork.Repository<Invoice>()
.GetAllAsync(query => query.AsNoTracking().Where(i => i.TenantId == tenantId));
// Wrong — EF tracks all returned entities unnecessarily
var invoices = await dbContext.Invoices.ToListAsync();Service registration — marker interfaces
csharp
// Scoped (per HTTP request) — most services
public class InvoiceService : IInvoiceService, IScopedService { }
// Transient (new instance per injection)
public class PdfBuilder : IPdfBuilder, ITransientService { }
// Singleton (one instance for app lifetime)
public class TenantCache : ITenantCache, ISingletonService { }AddSharedWeb() automatically discovers and registers all classes implementing these markers via assembly scanning.
Inter-service communication
Use typed HTTP clients from Microtec.PublicApi.* packages:
csharp
public class MyHandler(IAccountingPublicApi accountingApi)
{
var balance = await accountingApi.GetAccountBalanceAsync(accountId);
}IClockService — always use instead of DateTime
csharp
// Wrong
var now = DateTime.Now;
var utcNow = DateTime.UtcNow;
// Correct — testable and consistent
public class MyHandler(IClockService clock)
{
var now = clock.Now; // Returns DateTimeOffset in configured timezone
}NuGet Packages Reference
| Package | Purpose | Add to |
|---|---|---|
Microtec.Web.Hosting | Full bootstrap (auth + EF + MediatR + logging) | *.Apis projects |
Microtec.Web.Core | Auth middleware only | Advanced use |
Microtec.Domain | Base entity, audit interfaces | *.Domain projects |
Microtec.Persistence | Generic repository, UoW | *.Infrastructure projects |
Microtec.Contracts | CQRS interfaces only | Shared library projects |
Microtec.Reporting | PDF (QuestPDF) + Excel (ClosedXML) | Reporting features |
Microtec.PublicApi.AppsPortal | HTTP clients for AppsPortal API | Cross-service calls |
Code Style Rules
These are enforced by the team's
.editorconfigand code review.
- File-scoped namespaces:
namespace Microtec.AppsPortal.Application.Invoice.Commands.Add; - Primary constructors for DI:
public class MyHandler(IUnitOfWork uow) { ... } - Early returns: Don't nest — return early on validation failures
- Decompose large methods: Functions > 20 lines should be extracted
nameof()for route strings:[Route(nameof(GetById))]not[Route("GetById")]- ProducesResponseType on all actions:csharp
[ProducesResponseType<int>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<IActionResult> AddInvoice(...) { ... } - Rich domain entities with private setters:csharp
public class Invoice : BaseEntity { public string Number { get; private set; } = default!; public static Invoice Create(string number) => new() { Number = number }; }
Full code style reference: Platforms/Solution Items/CodeStyle.md
Full naming conventions: Platforms/Solution Items/NamingConventions.md
Testing Strategy
bash
# Run all backend tests
dotnet test Microtec.Platforms.sln
# Run with coverage
dotnet test Microtec.Platforms.sln \
--collect:"XPlat Code Coverage" \
--results-directory TestResults/
# Generate coverage report
reportgenerator -reports:"TestResults/**/*.xml" -targetdir:"coverage-report"Target: 80% code coverage minimum (enforced in CI).
Useful IDE Shortcuts (Rider)
| Action | Shortcut |
|---|---|
| Find usages | Alt+F7 |
| Navigate to implementation | Ctrl+Alt+B |
| Run tests in current file | Ctrl+Shift+T |
| Apply code cleanup | Ctrl+Alt+F |
| Find in files | Ctrl+Shift+F |
| Navigate to file | Ctrl+Shift+N |