Files
baya-monorepo/docs/rules/server/conventions.md
T
2026-07-30 02:26:52 +03:30

10 KiB

Server C# conventions

Style, types, naming, async, error handling and tests. The successor to server/CONVENTIONS.md.

Last verified: 2026-07-30 against commit d3ec723.

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


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

Immutability and safety

  • Mark fields readonly unless mutation is genuinely needed.
  • Prefer IReadOnlyList<T> / IReadOnlyCollection<T> in signatures unless the caller must mutate.
  • Never expose a public setter on an entity. Use methods or the constructor. A lifecycle status gets a private setter and cohesive transition methods — see persistence.md §5.
  • Avoid static mutable state.

Null handling

  • <Nullable>enable</Nullable> in any new project.
  • Guard clauses at the entry point; don't scatter null checks through a method.
  • Prefer OperationResult.NotFoundResult(...) over returning null from a handler.
  • Never null! unless you can prove the value cannot be null and the compiler cannot.

Use the language

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

// switch expression over an if/else chain
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"];

2. Naming

Kind Convention Example
Class, record, interface PascalCase OrderHandler, IOrderRepository
Method PascalCase GetUserOrdersAsync
Parameter, local camelCase orderId, userEmail
Private field _camelCase _unitOfWork
Constant PascalCase MaxRetryCount
Generic type parameter 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).

The Baya.* prefix is project naming, not the brand — see shared/naming.md.


3. Routing

All URL segments are snake_case. SnakeCaseParameterTransformer (Baya.WebFramework/Routing/) is registered globally via RouteTokenTransformerConvention and converts [controller] and [action] tokens automatically.

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

// ❌ hardcoded segments bypass the transformer and escape 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 — it also breaks the dynamic-permission key, which is derived from the same route values.

The controller skeleton and authorization table are in cqrs.md §4.


4. Async / await

// ✅ async all the way — no .Result, no .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;

// ❌ fire and forget with no error handling
_ = DoSomethingAsync();
  • Every public async method accepts a CancellationToken and passes it downstream — including into SaveChangesAsync(ct) and sender.Send(command, ct).
  • Use ValueTask<T> for hot paths (handlers, repositories); Task<T> for rarely-called or always-async methods.
  • Never async void — it swallows exceptions. Use async Task even for an event-like callback.
  • Do not add .ConfigureAwait(false) in this ASP.NET Core app. It is unnecessary here and adds noise.

5. Error handling and logging

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

// ❌ swallowing an exception into a generic failure
try {  } catch { return OperationResult<T>.FailureResult(); }

The global ExceptionHandler middleware catches unhandled exceptions and logs them. Do not add a try/catch for unknown exceptions in a handler — let them propagate. Catch only what you can actually handle.

Logging rules are in identity.md §9: structured templates, no PII or secrets, correct level.


6. Validation

  • Every command that accepts user input needs a FluentValidation validator. ValidateCommandBehavior runs it automatically before the handler, and RegisterValidatorsAsServices() registers them.
  • Validate at the boundary — the command or query — not deep in the domain or a repository.
  • Never validate a route-supplied id in the body command. See cqrs.md §3.

7. Mapping — Mapster

  • Use the injected IMapper for entity↔DTO mapping in handlers.
  • Register type-adapter configs in Program.cs via TypeAdapterConfig.GlobalSettings.Scan(...); add new assemblies containing mapping configs there.
  • Never write manual mapping code where Mapster can infer it. Only write a custom TypeAdapterConfig when shapes genuinely diverge.
  • Mapping happens in the handler after the DB query, never in the repository — the repository projects.

8. 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.
  • NSubstitute for mocking: Substitute.For<IUnitOfWork>().
  • Persistence tests use the in-memory SQLite context from Baya.Tests.Setup rather than mocking the DB.
  • Name tests {MethodUnderTest}_{Scenario}_{ExpectedOutcome}.
  • One assertion concept per test. Multiple .Should() calls are fine if they verify the same outcome.
  • Don't test EF internals (tracking, migrations) — test behaviour through the handler.

Integration tests — the HTTP pipeline

Handler tests leave the whole HTTP stack untested: routing, the auth pipeline, middleware, and the OperationResult → IActionResult translation. Each feature area needs at least one WebApplicationFactory<Program> test in Baya.Test.Api (environment Testing, in-memory SQLite) covering:

  1. Happy path — an authenticated request returns 200 with the right body shape.
  2. Unauthenticated — 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);
    }
}

The recurring-job scheduler is dormant under Testing, so a background tick can't make an integration test flaky.


9. Service registration

  • Every new infrastructure service gets an extension method in that project's ServiceConfiguration/ folder, called from Program.cs. No inline DI registration in Program.cs.
  • Lifetimes: Singleton for stateless, thread-safe services (IHttpContextAccessor, IFieldEncryptor — which must be a singleton, see identity.md §3); Scoped for per-request services (repositories, DbContext, handlers); Transient for lightweight stateless ones (validators, transformers).
  • All NuGet versions live only in Directory.Packages.props. Never add Version= to a <PackageReference> in a .csproj.

10. Code organisation

  • One type per file, file name matching the type name exactly.
  • Handlers and validators live in the same feature folder — not in a root Handlers/ or Validators/.
  • A file over ~150 lines usually means mixed concerns. Consider splitting it.
  • Partial classes are only for generated code (source generators, EF scaffolding) — and the one deliberate exception, DemoLifecycleSeeder's .Money.cs/.Social.cs partials, which split a Development-only seeder by domain.
  • Program.cs stays an orchestrator — extension-method calls only, no logic.

11. No unused code, and comment the why

Both are shared rules with real teeth on this side: the gate is zero new warnings, and CS0168 / CS0219 / CS0169 / IDE0005 all surface dead code. Delete it — don't #pragma warning disable it.

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.

Full rules, with examples of a comment that earns its place: shared/code-quality.md.

Known pre-existing warnings that must not be fixed unless a task says so: shared/git-and-gates.md §5.