Skip to content

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 InterfaceLifetimeWhen to Use
IScopedServicePer HTTP requestMost services — default choice
ITransientServiceNew instance per injectionLightweight, stateless utilities
ISingltonServiceApplication-wideCaches, 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 query

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

ElementConventionExample
Classes / interfacesPascalCaseInvoiceService, IInvoiceRepository
MethodsPascalCaseGetById, AddInvoice
Local variablescamelCaseinvoiceList, isActive
ConstantsUPPER_CASEMAX_RETRY_COUNT
PropertiesPascalCaseInvoiceDate, TotalAmount
Private fields_camelCase_unitOfWork, _logger
EnumsPascalCase (name + values)InvoiceStatus.Draft
Primary ctor paramscamelCaseunitOfWork, mapper

Internal Documentation — Microtec Platform Team