Appearance
Backend Coding Conventions
This document captures the mandatory coding standards for all .NET 8 backend services in Microtec ERP. These rules apply to Platforms/, MicrotecHR/, and any other .NET microservice in the platform.
Source documents: Platforms/Solution Items/CodeStyle.md and Platforms/Solution Items/NamingConventions.md.
1. File-Scoped Namespaces (Mandatory)
Use file-scoped namespaces to reduce indentation and keep files concise.
csharp
// WRONG — block namespace
namespace Microtec.AppsPortal.Application.Invoices.Commands
{
public class AddInvoiceCommand { }
}
// CORRECT — file-scoped namespace
namespace Microtec.AppsPortal.Application.Invoices.Commands;
public class AddInvoiceCommand { }2. Primary Constructors for Dependency Injection
Use C# 12 primary constructors for services and controllers. Avoid field injection boilerplate.
csharp
// WRONG — verbose constructor with field assignment
public class InvoiceService : IScopedService
{
private readonly IUnitOfWork<AppDbContext> _unitOfWork;
private readonly IMapper _mapper;
public InvoiceService(IUnitOfWork<AppDbContext> unitOfWork, IMapper mapper)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
}
}
// CORRECT — primary constructor
public class InvoiceService(
IUnitOfWork<AppDbContext> unitOfWork,
IMapper mapper) : IScopedService
{
// unitOfWork and mapper are available directly
}3. FluentValidation for All Input DTOs
Every command or query DTO that accepts external input must have a FluentValidation validator. The validator class suffix is Validator.
csharp
// AddInvoiceDto.cs
public record AddInvoiceDto(
int CustomerId,
DateOnly InvoiceDate,
List<InvoiceLineDto> Lines);
// AddInvoiceDtoValidator.cs
public class AddInvoiceDtoValidator : AbstractValidator<AddInvoiceDto>
{
public AddInvoiceDtoValidator()
{
RuleFor(x => x.CustomerId).GreaterThan(0);
RuleFor(x => x.InvoiceDate).NotEmpty();
RuleFor(x => x.Lines).NotEmpty().WithMessage("Invoice must have at least one line.");
}
}INFO
Validators are automatically registered and executed via the ValidationBehaviour MediatR pipeline behaviour. You do not need to call .Validate() manually in handlers.
4. ProducesResponseType on All Controller Actions
Every controller action must declare its possible HTTP response types. This drives Swagger documentation and client SDK generation.
csharp
[HttpPost]
[ProducesResponseType(typeof(ApiResponse<int>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Add([FromBody] AddInvoiceDto dto)
{
var result = await _mediator.Send(new AddInvoiceCommand(dto));
return Ok(result);
}5. Rich Domain Entities with Private Setters
Domain entities must encapsulate business logic. All property setters are private; mutation happens only through named methods.
csharp
public class Invoice : BaseEntity
{
public int CustomerId { get; private set; }
public InvoiceStatus Status { get; private set; }
public DateOnly InvoiceDate { get; private set; }
private Invoice() { } // EF Core constructor
public static Invoice Create(int customerId, DateOnly invoiceDate)
{
return new Invoice
{
CustomerId = customerId,
InvoiceDate = invoiceDate,
Status = InvoiceStatus.Draft,
};
}
public void Post()
{
if (Status != InvoiceStatus.Draft)
throw new BusinessException(ExceptionCodes.PostedStatusRequired);
Status = InvoiceStatus.Posted;
}
}6. Early Return Pattern
Avoid deep nesting by returning early from guard clauses. Functions should not exceed 20 lines; extract helpers when needed.
csharp
// WRONG — nested conditions
public async Task Handle(DeleteInvoiceCommand request)
{
var invoice = await _repo.GetByIdAsync(request.Id);
if (invoice != null)
{
if (invoice.Status == InvoiceStatus.Draft)
{
_repo.Remove(invoice);
await _unitOfWork.SaveChangesAsync();
}
else
{
throw new BusinessException(ExceptionCodes.CantEditOrDeletePostedRecord);
}
}
else
{
throw new NotFoundException(request.Id);
}
}
// CORRECT — early returns
public async Task Handle(DeleteInvoiceCommand request)
{
var invoice = await _repo.GetByIdAsync(request.Id)
?? throw new NotFoundException(request.Id);
if (invoice.Status != InvoiceStatus.Draft)
throw new BusinessException(ExceptionCodes.CantEditOrDeletePostedRecord);
_repo.Remove(invoice);
await _unitOfWork.SaveChangesAsync();
}7. Decompose Functions Over 20 Lines
Any function exceeding 20 lines should be refactored into smaller private methods with descriptive names.
csharp
// WRONG — one large Handle method
public async Task<int> Handle(AddInvoiceCommand cmd)
{
// 50 lines of validation, mapping, saving, event publishing...
}
// CORRECT — delegated responsibilities
public async Task<int> Handle(AddInvoiceCommand cmd)
{
var invoice = CreateInvoice(cmd.Dto);
await SaveInvoiceAsync(invoice);
await PublishInvoiceCreatedEventAsync(invoice.Id);
return invoice.Id;
}8. nameof() for Route Strings
Never use magic strings for route constants. Use nameof() to prevent typos and enable safe refactoring.
csharp
// WRONG
[HttpGet("GetById")]
public IActionResult GetById(int id) { }
// CORRECT
[HttpGet(nameof(GetById))]
[Route("{id:int}")]
public IActionResult GetById(int id) { }9. AsNoTracking() for Read-Only Queries
All queries that do not modify data must use AsNoTracking(). This is a hard rule — violating it wastes memory and CPU with unnecessary EF Core change tracking.
csharp
// WRONG — tracking enabled for a read query
var invoices = await _context.Invoices
.Where(x => x.TenantId == tenantId)
.ToListAsync();
// CORRECT
var invoices = await _context.Invoices
.AsNoTracking()
.Where(x => x.TenantId == tenantId)
.ToListAsync();10. IClockService Instead of DateTime.Now
Never use DateTime.Now or DateTime.UtcNow directly. Inject IClockService instead to make time-dependent logic testable.
csharp
// WRONG
var now = DateTime.Now;
// CORRECT
public class SomeService(IClockService clock)
{
public void DoSomething()
{
var now = clock.GetNow();
var utcNow = clock.GetUtcNow();
}
}11. Service Marker Interfaces
Services are auto-registered by scanning for marker interfaces. Do not manually register services in Program.cs — use the correct marker:
| Marker Interface | Lifetime | When to Use |
|---|---|---|
IScopedService | Per HTTP request | Most services — default choice |
ITransientService | New instance per injection | Lightweight, stateless utilities |
ISingltonService | Application-wide | Caches, configuration readers |
Typo in Singleton marker
The interface is named ISingltonService (missing 'e' in Singleton). This is a known typo in the codebase — do not correct it or registration will break.
csharp
// Scoped service (most common)
public class InvoiceService : IScopedService { }
// Transient utility
public class PdfGenerator : ITransientService { }
// Singleton cache reader
public class FeatureFlagService : ISingltonService { }12. Separate DTOs per Command / Query
Never reuse DTOs across different commands or queries. Each operation gets its own dedicated DTO:
AddInvoiceDto.cs ← only used by AddInvoiceCommand
EditInvoiceDto.cs ← only used by EditInvoiceCommand
GetByIdInvoiceDto.cs ← response DTO for GetByIdInvoice query
GetAllInvoicesDto.cs ← response DTO for GetAllInvoices query13. DTO Mapping Profiles
AutoMapper profiles live inside the same DTO file they map — do not create a separate MapperProfile.cs file per entity.
csharp
// GetByIdInvoiceDto.cs
public record GetByIdInvoiceDto(int Id, string CustomerName, InvoiceStatus Status);
public class GetByIdInvoiceDtoProfile : Profile
{
public GetByIdInvoiceDtoProfile()
{
CreateMap<Invoice, GetByIdInvoiceDto>();
}
}14. Build Complex Queries Incrementally
For queries with conditional filters, build the IQueryable step by step rather than writing one large LINQ expression:
csharp
var query = _context.Invoices.AsNoTracking()
.Where(x => x.TenantId == tenantId);
if (request.CustomerId.HasValue)
query = query.Where(x => x.CustomerId == request.CustomerId.Value);
if (request.Status.HasValue)
query = query.Where(x => x.Status == request.Status.Value);
if (!string.IsNullOrEmpty(request.Search))
query = query.Where(x => x.Number.Contains(request.Search));
var total = await query.CountAsync();
var items = await query
.OrderByDescending(x => x.InvoiceDate)
.Skip(request.Skip)
.Take(request.Take)
.ProjectTo<GetAllInvoicesDto>(_mapper.ConfigurationProvider)
.ToListAsync();Naming Quick Reference
| Element | Convention | Example |
|---|---|---|
| Classes / interfaces | PascalCase | InvoiceService, IInvoiceRepository |
| Methods | PascalCase | GetById, AddInvoice |
| Local variables | camelCase | invoiceList, isActive |
| Constants | UPPER_CASE | MAX_RETRY_COUNT |
| Properties | PascalCase | InvoiceDate, TotalAmount |
| Private fields | _camelCase | _unitOfWork, _logger |
| Enums | PascalCase (name + values) | InvoiceStatus.Draft |
| Primary ctor params | camelCase | unitOfWork, mapper |