Files
baya-monorepo/server/CONVENTIONS.md
T
2026-07-06 02:48:56 +03:30

25 KiB

Server Coding Conventions

Rules enforced for all code in server/. These represent the standards expected from a senior .NET engineer. Read alongside CLAUDE.md.

When in doubt, ask: would a senior engineer approve this diff without comment?


1. Routing

Rule: all URL segments must be snake_case

SnakeCaseParameterTransformer (Baya.WebFramework/Routing/) is registered globally via RouteTokenTransformerConvention. It converts [controller] and [action] tokens automatically.

// ✅ transformer converts MyFeature → my_feature, GetBySlug → get_by_slug
[Route("api/v{version:apiVersion}/[controller]")]
public class MyFeatureController : BaseController
{
    [HttpGet("[action]")]
    public Task<IActionResult> GetBySlug(...) { }
}

// ❌ bypasses transformer — hardcoded segment escapes snake_case enforcement
[Route("api/v{version:apiVersion}/MyFeature")]
[HttpGet("GetBySlug")]

If a method name doesn't read cleanly as a URL, rename the method — don't hardcode the route string.


2. C# code quality

Use the right type for the job

Scenario Use
Request/response/DTO record (immutable, value semantics)
Domain entity class (mutable state, encapsulated)
Shared small value readonly record struct
Handler, service sealed class

Language features — use them

// ✅ primary constructor (C# 12)
public sealed class OrderHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<...> { }

// ✅ switch expression over if/else chains
var label = status switch
{
    OrderStatus.Pending  => "Pending",
    OrderStatus.Shipped  => "Shipped",
    OrderStatus.Cancelled => "Cancelled",
    _ => throw new ArgumentOutOfRangeException(nameof(status))
};

// ✅ pattern matching
if (result is { IsSuccess: false, IsNotFound: true }) return NotFound();

// ✅ collection expressions (C# 12)
List<string> tags = ["new", "sale"];

Immutability & safety

  • Mark fields readonly unless mutation is genuinely needed.
  • Prefer IReadOnlyList<T> / IReadOnlyCollection<T> over List<T> in signatures unless the caller needs to mutate.
  • Never expose public setters on entities — use methods or constructors.
  • Avoid static mutable state.

Null handling

  • Enable <Nullable>enable</Nullable> in any new project you create.
  • Use guard clauses at the entry point; don't scatter null checks throughout.
  • Prefer returning OperationResult.NotFoundResult(...) over returning null from handlers.
  • Never use null! (null-forgiving) unless you can prove the value cannot be null and the compiler cannot.

Naming

Kind Convention Example
Class, record, interface PascalCase OrderHandler, IOrderRepository
Method PascalCase GetUserOrdersAsync
Parameter, local variable camelCase orderId, userEmail
Private field _camelCase _unitOfWork
Constant PascalCase MaxRetryCount
Generic type param T or descriptive TEntity
Command {Verb}{Noun}Command CreateOrderCommand
Query {Verb}{Noun}Query GetUserOrdersQuery
Handler {RequestName}Handler CreateOrderCommandHandler
Result DTO {RequestName}Result CreateOrderCommandResult

No abbreviations unless universally understood (dto, id, url). No Hungarian notation (strName, intCount).

No unused code

Leave nothing dead behind. Remove unused using directives, local variables, parameters, private fields, and private members rather than letting them accumulate.

  • These already surface as compiler/analyzer signals — CS0168 (variable declared, never used), CS0219 (variable assigned, value never used), CS0169 (private field never used), IDE0005 (unnecessary using). The quality gate is zero new warnings, so treat unused code as a gate failure.
  • Delete it — don't silence it. Do not add #pragma warning disable, throwaway discards, or _ = assignments just to quiet the analyzer.
  • The one exception: a parameter that must exist to satisfy an interface or delegate signature but is genuinely unused. Keep it, name it conventionally, and add a one-line // why only if the reason isn't obvious.

Comments — explain why, never what

Code that needs a comment to be understood usually needs a better name instead. Prefer self-documenting names over prose.

  • Do not write comments that restate what the code already says — no // constructor, // loop over users, or XML-doc that merely echoes the method name.
  • Do add a comment only where a non-obvious decision, constraint, business rule, workaround, or trade-off is not evident from the code — explain the reasoning, not the mechanics.
  • Keep any necessary comment tight, and delete comments that no longer match the code.
// ❌ restates the obvious
// increment the retry counter
retryCount++;

// ✅ captures a non-obvious constraint the code can't express on its own
// Payment gateway rejects amounts above 50M IRR per call; split larger settlements upstream.
if (amount > MaxPerCallRial) ...

3. Async / await

// ✅ always async all the way — no .Result or .Wait()
public async ValueTask<OperationResult<T>> Handle(MyQuery request, CancellationToken ct)
{
    var entity = await _repository.GetAsync(request.Id, ct);
    return OperationResult<T>.SuccessResult(_mapper.Map(entity));
}

// ❌ blocks the thread, risks deadlock
var result = _repository.GetAsync(id).Result;

// ✅ pass CancellationToken through every async call
await _db.SaveChangesAsync(cancellationToken);

// ❌ fire and forget with no error handling
_ = DoSomethingAsync();
  • Every public async method must accept CancellationToken and pass it downstream.
  • Use ValueTask<T> for hot paths (handlers, repositories). Use Task<T> for rarely-called or always-async methods.
  • Never use async void — it swallows exceptions. Use async Task even for event-like callbacks.
  • Do not add .ConfigureAwait(false) in this ASP.NET Core app — it's unnecessary and adds noise.

4. Controllers

Every controller must follow this skeleton:

[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Display(Description = "One-line description shown in Swagger")]
[Authorize(ConstantPolicies.DynamicPermission)]   // or [Authorize], or omit for public
public sealed class MyFeatureController(ISender sender) : BaseController
{
    [HttpGet("[action]")]
    [ProducesOkApiResponseType<MyQueryResult>]
    public async Task<IActionResult> GetSomething(CancellationToken ct)
        => OperationResult(await sender.Send(new MyQuery(), ct));

    [HttpPost("[action]")]
    [ProducesOkApiResponseType<MyCommandResult>]
    public async Task<IActionResult> CreateSomething(MyCommand command, CancellationToken ct)
        => OperationResult(await sender.Send(command, ct));
}

Rules:

  • sealed — controllers are not designed for inheritance beyond BaseController.
  • Inject ISender via primary constructor — not IMediator.
  • Never call Ok(), BadRequest(), NotFound() directly — always base.OperationResult(result).
  • Keep controller methods thin: one Send, one OperationResult. No business logic in controllers.
  • Use [Display(Description = "...")] so NSwag generates meaningful Swagger tags.
  • Pass CancellationToken from the action into sender.Send(...).

Authorization levels — use the narrowest that fits

Attribute When
(none) Truly public (health check, metrics)
[Authorize] Any authenticated user
[Authorize(ConstantPolicies.DynamicPermission)] Role/claim-gated admin action
[RequireTokenWithoutAuthorization] Token must be present but may be expired (e.g. refresh)

Apply at the controller level for uniform policy; override at the action level only for exceptions.


5. CQRS — feature structure

Features/<Area>/
├── Commands/<VerbNoun>Command/
│   ├── <Name>Command.cs           record Command(…) : IRequest<OperationResult<T>>
│   ├── <Name>Command.Handler.cs   internal sealed class Handler : IRequestHandler<…>
│   └── <Name>Command.Validator.cs AbstractValidator<Command> (omit if no validation needed)
└── Queries/<VerbNoun>Query/
    ├── <Name>Query.cs             record Query(…) : IRequest<OperationResult<T>>
    ├── <Name>Query.Handler.cs     internal sealed class Handler : IRequestHandler<…>
    └── <Name>Query.Result.cs      record Result(…)   ← the DTO returned
  • Request types are record — immutable.
  • Handlers are internal sealed — they are never used outside the Application layer.
  • Handlers must not throw for expected failures. Use OperationResult factory methods:
    • OperationResult<T>.SuccessResult(value) — happy path
    • OperationResult<T>.FailureResult(errors) — validation / business rule failure
    • OperationResult<T>.NotFoundResult(message) — entity not found
  • Only one handler per request type — no conditional dispatch.
  • Contracts the handler depends on go in Application/Contracts/ as interfaces; implementations live in Infrastructure.

6. Persistence — EF Core rules

// ✅ project to DTO in the query — never load full entity for read operations
var dto = await _db.Orders
    .AsNoTracking()
    .Where(o => o.UserId == userId)
    .Select(o => new OrderResult(o.Id, o.Status, o.CreatedAt))
    .ToListAsync(ct);

// ❌ loads entire entity graph then maps in memory — N+1 risk
var orders = await _db.Orders.Include(o => o.Lines).ToListAsync();
var dtos = _mapper.Map<List<OrderResult>>(orders);

Rules:

  • Always use AsNoTracking() on read-only queries.
  • Always project with Select() in queries — never hydrate full entities just to map them.
  • Never load more than you need. Pagination is mandatory for any unbounded list: Skip / Take.
  • Use Include only in command handlers where you need to mutate the aggregate and need navigation properties loaded.
  • Access the DB through IUnitOfWork in Application-layer handlers. ApplicationDbContext is only referenced directly inside Infrastructure.
  • Commit once per command at the end: await _unitOfWork.CommitAsync(ct).
  • One IEntityTypeConfiguration<T> per entity, in Persistence/Configuration/<Area>Config/.
  • Migrations command: dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api

Soft delete

Every entity that supports soft delete must declare a global EF query filter in its IEntityTypeConfiguration<T>:

public void Configure(EntityTypeBuilder<Order> builder)
{
    builder.HasQueryFilter(o => !o.IsDeleted);
}

Without this filter, soft-deleted records appear in every query that doesn't explicitly filter them — a silent data leak. Never add Where(x => !x.IsDeleted) in individual queries; the filter makes it automatic and auditable.

Entity audit fields

When designing or extending an entity, include audit fields alongside timestamps:

Field Type Set by
CreatedAt DateTimeOffset SaveChangesAsync override (on Add)
ModifiedAt DateTimeOffset SaveChangesAsync override (on Update)
CreatedById int? SaveChangesAsync override via ICurrentUser
ModifiedById int? SaveChangesAsync override via ICurrentUser

Wire ICurrentUser (HTTP context accessor wrapped in an interface, registered Scoped) into ApplicationDbContext so the context can stamp who made the change without handlers needing to pass it explicitly. Audit fields cannot be backfilled retroactively — design them in from the start.

As built (backend-phase-0): the audit base type is BaseEntity/IAuditableEntity in Baya.Domain/Common/BaseEntity.cs (CreatedAt/ModifiedAt as DateTimeOffset, CreatedById/ ModifiedById as int?). Stamping is done by AuditFieldInterceptor (Baya.Infrastructure.Persistence/Interceptors/), a SaveChangesInterceptor that reads time from IDateTimeProvider and the user from ICurrentUser — not in the DbContext itself.

As built (backend-phase-1) — reusable patterns you should follow:

  • Config is rows, read at compute time. Money-critical constants (commission %, VAT, deadlines, EVV tolerance, cancellation tiers) live in platform_configs, read via IPlatformConfig.GetConfig<T> (cached, parsed by the row's data_type) — never hardcode. Changing a rate must never retroactively alter an already-computed amount: later phases snapshot the rate onto the booking/invoice at compute time; do not live-re-read a rate for an already-priced row.
  • Append-only audit trail. audit_logs is immutable — there is no update/delete path in app code. Mark a compliance-sensitive entity with IAuditable (Baya.Domain/Common) and the AuditFieldInterceptor writes an old/new diff row per change in the same transaction; annotate any encrypted/PII property with [AuditRedacted] so it is redacted (never plaintext) in the diff. platform_configs is the first IAuditable entity.
  • DB-backed platform facades (IPlatformConfig/IHolidayCalendar/IAnalyticsSink/IAuditLogger/ INotificationService/ISupportAlertService) live in Persistence/Services/ and are the contracts other domains reuse — don't re-query these tables directly. IAnalyticsSink is fire-and-forget (never fail the caller); INotificationService/notification endpoints are always tenant-scoped to ICurrentUser; support_alerts are admin-only and never appear on a user-facing route.
  • Retention/scheduling seam. Background jobs run behind the hosted-service seam (NotificationRetentionHostedService); real Hangfire/Quartz is deferred. The notification retention predicate is exactly is_read = 1 AND age > 90d — unread is never auto-deleted.

Money is IRR BIGINT — integer-only, no floats

Every monetary value is IRR Rials stored as long / BIGINT. There is no float/decimal path on money — not in entities, DTOs, the API, or arithmetic. Toman is display-only and converts to/from Rials only inside a provider adapter at its boundary, never in domain or shared code. If a money value object is introduced later it must be integer-only. The three booking amounts always satisfy gross = commission + payout.

Deterministic set-hash for multi-row uniqueness

When "no two rows may share the same set of child rows" must be enforced (e.g. a nurse can't list two identical variants — same category + identical answered option-set), a plain composite unique index can't express it because the set spans multiple rows. Reduce the set to a single comparable column with Baya.Application.Common.OptionSetHash.Compute(pairs) (backend-phase-5): it sorts the (long, long) pairs and SHA-256s them to a stable 64-char hex hash that is order-independent (identical sets always collide). Persist it (NVARCHAR(64)) and back it with a filtered unique index (e.g. UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL) as the race-safe backstop, with a handler pre-check for the friendly 409. Reuse this helper for any future "same set of ids" guard; do not reuse IFieldEncryptor.Hash (that is for PII-column equality lookups).

Guarded cross-aggregate state flip (backend-phase-6)

When one write must atomically change a header row's state and a derived boolean on a different aggregate (e.g. nurse_verifications.statusnurse_profiles.is_verified), do it in one transaction: load both as tracked entities, mutate them through a single pure domain helper (VerificationAggregator.Finalize), then CommitAsync once — never flip the derived flag from a controller, a partial write, or an out-of-band update, and never leave an in-between state. Two follow-on rules this establishes:

  • Self-committing facades come after the atomic commit. ISupportAlertService.RaiseAsync, INotificationDispatcher.DispatchAsync, IAuditLogger.WriteAsync and IPlatformConfig.SetConfig each call SaveChanges on the shared scoped DbContext. Calling one mid-build flushes your partial tracked changes — invoke them only after unitOfWork.CommitAsync(). In a batch loop that commits per item, load and guard every dependency before mutating tracked state, or an early continue leaks a dirty entity that a later iteration's commit will flush.
  • Persist a status enum as its stable snake_case code, not the member name. Define the C# enum, then map it with a HasConversion(e => e.ToCode(), s => Parse(s)) value converter (see VerificationCodes) so the DB and the wire carry in_review, not InReview. Enum→code mapping in a projected read happens in memory after materialization (.ToCode() is not LINQ-translatable); DTOs expose the code string.

Forward-only status machine (backend-phase-8)

When an entity has a lifecycle status with a fixed set of allowed transitions, model the machine as a static allowed-edges table and route every write through it — never assign status ad-hoc. The b8 pattern (reused by b9 for the bookings machine):

  • Statuses are const string codes (BookingRequestStatus) persisted as the stable snake_case string — no C# enum, no value converter needed. Edges live in a static CanTransition(from, to) (BookingRequestTransitions) built from a Dictionary<string, IReadOnlyCollection<string>>; terminal states map to an empty set.
  • The entity owns the transition. status has a private setter; the only mutators are cohesive domain methods (Accept/Reject/Cancel…) that call a private Transition(target) which asserts the edge is legal (throws on an illegal edge — a programming error, since the handler pre-checks). Side-effect fields (payment_deadline_at, rejection_reason) are set in the same method.
  • The handler pre-checks and returns a clean 409. if (!entity.CanTransitionTo(target)) return OperationResult.ConflictResult(...) — never throw for the expected "already moved / terminal" case.
  • Time-sensitive commands self-guard against a passed deadline via IDateTimeProvider rather than trusting a sweep has run; the recurring expiry BackgroundService is bounded/paginated/idempotent, and its WHERE status = … predicate (re-queried each tick) is the concurrency guard — a row a racing action moved is simply not reloaded.
  • Deadline columns that are compared/sorted use DateTime (UTC datetime2), not DateTimeOffset — the SQLite test provider cannot translate DateTimeOffset comparison/ORDER BY. Order lists/sweeps by Id, not the timestamp, for the same reason.

7. Validation

  • All commands that accept user input need a FluentValidation validator. The ValidateCommandBehavior pipeline behavior runs it automatically before the handler.
  • Validators are registered automatically via RegisterValidatorsAsServices() in Program.cs.
  • Validate at the boundary (command/query), not deep in the domain or repositories.
public sealed class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        RuleFor(x => x.UserId).GreaterThan(0);
        RuleFor(x => x.Items).NotEmpty().WithMessage("Order must have at least one item.");
        RuleForEach(x => x.Items).ChildRules(item =>
        {
            item.RuleFor(i => i.ProductId).GreaterThan(0);
            item.RuleFor(i => i.Quantity).InclusiveBetween(1, 100);
        });
    }
}

8. Mapping — Mapster rules

  • Use IMapper (injected via DI) for all entity↔DTO mapping in handlers.
  • Register type adapter configs in Program.cs via TypeAdapterConfig.GlobalSettings.Scan(...). Add new assemblies that contain mapping configs there.
  • Never write manual mapping code when Mapster can infer it — only write custom TypeAdapterConfig when shapes diverge.
  • Mapping happens in the handler after the DB query, not in the repository.

9. Error handling & logging

// ✅ expected failure — use OperationResult, do not throw
if (user is null)
    return OperationResult<T>.NotFoundResult("User not found.");

// ✅ unexpected failure — let it propagate; ExceptionHandler middleware catches it
// Log at the point you catch unexpected exceptions (ExceptionHandler logs automatically)

// ❌ swallowing exceptions
try { ... } catch { return OperationResult<T>.FailureResult(...); }

// ✅ structured logging — never interpolate sensitive data
_logger.LogInformation("Order {OrderId} created for user {UserId}", order.Id, userId);

// ❌ logs PII / secrets
_logger.LogInformation($"Token for {user.Email}: {token}");
  • Log at the correct level: Debug for trace info, Information for meaningful events, Warning for recoverable issues, Error for unexpected failures.
  • Never log passwords, tokens, secrets, or full PII (email is borderline — use userId in logs instead).
  • The global ExceptionHandler middleware catches unhandled exceptions — do not add try/catch in handlers for unknown exceptions; let them propagate.

10. Testing

Arrange — Act — Assert, always

[Fact]
public async Task CreateOrder_ValidCommand_ReturnsSuccess()
{
    // Arrange
    var command = new CreateOrderCommand(UserId: 1, Items: [new(ProductId: 5, Quantity: 2)]);
    var handler = new CreateOrderCommandHandler(_unitOfWork, _mapper);

    // Act
    var result = await handler.Handle(command, CancellationToken.None);

    // Assert
    result.IsSuccess.Should().BeTrue();
    result.Result.Should().NotBeNull();
}
  • Test the handler directly — not the controller. Controllers are thin wrappers.
  • Use NSubstitute for mocking: Substitute.For<IUnitOfWork>().
  • Integration tests use Baya.Tests.Setup which provides an in-memory SQLite context — prefer this over mocking the DB for persistence tests.
  • Name tests: {MethodUnderTest}_{Scenario}_{ExpectedOutcome}.
  • One assertion concept per test. Multiple .Should() calls are fine if they all verify the same outcome.
  • Do not test EF internals (entity tracking, migrations) — test behavior through the handler.

Integration tests — HTTP pipeline coverage

Handler tests verify business logic but leave the entire HTTP stack (routing, auth pipeline, middleware, OperationResult → IActionResult translation) untested. Each feature area must have at least one WebApplicationFactory<Program>-based test covering:

  1. Happy path — authenticated request returns 200 with correct body shape.
  2. Unauthenticated request returns 401.
  3. Validation failure returns 400 with field-level error detail.
public class MyFeatureApiTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task GetSomething_Authenticated_Returns200()
    {
        var client = factory.CreateClient();
        client.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", TestTokens.ValidAdminToken);

        var response = await client.GetAsync("/api/v1/my_feature/get_something");

        response.StatusCode.Should().Be(HttpStatusCode.OK);
    }
}

Place these tests in a dedicated Baya.Test.Api project so they can run against the full Program.cs wiring.


11. Security rules

  • Never hardcode secrets. Keys, connection strings, and tokens come from appsettings.*.json / user-secrets / environment variables, bound to typed settings classes.
  • SecretKey and Encryptkey (in IdentitySettings) must be set in environment-specific config, never in appsettings.json committed to the repo.
  • Always validate all external input with FluentValidation before processing.
  • EF Core parameterizes queries automatically — never concatenate raw SQL.
  • If you must use raw SQL, use FromSqlInterpolated (parameterized), never FromSqlRaw with user data.
  • Respect the principle of least privilege: grant [Authorize(ConstantPolicies.DynamicPermission)] to admin actions, not just [Authorize].
  • Auth and OTP endpoints must be rate-limited. Use ASP.NET Core's built-in AddRateLimiter (no extra NuGet package needed). Apply at minimum to: login, OTP request, and token refresh. A fixed window or token bucket policy per IP is the baseline. Register the limiter in a ServiceConfiguration/ extension; add app.UseRateLimiter() before app.UseAuthentication() in Program.cs.

12. Service registration

  • Every new infrastructure service gets an extension method in the project's ServiceConfiguration/ folder.
  • That extension is called from Program.cs — no inline DI registration in Program.cs.
  • Register with the correct lifetime:
    • Singleton — stateless, thread-safe services (e.g. IHttpContextAccessor)
    • Scoped — per-request services (repositories, DbContext, handlers)
    • Transient — lightweight, stateless (validators, transformers)
  • All NuGet versions live in Directory.Packages.props. Never add Version= to a <PackageReference> in a .csproj.

13. Code organisation

  • One type per file. File name matches the type name exactly.
  • Handlers and validators go in the same feature folder — not in separate Handlers/ or Validators/ root folders.
  • If a file exceeds ~150 lines, consider splitting it. Long files usually mean mixed concerns.
  • Partial classes are only for generated code (source generators, EF scaffolding).
  • Keep Program.cs as an orchestrator — extension method calls only, no logic.