Establish project-wide rules for testing (xUnit 3 / Shouldly / NSubstitute), logging (Microsoft.Extensions.Logging + Serilog + LogContext), and general C# conventions. Referenced from CLAUDE.md and phases 4-7.
7.2 KiB
7.2 KiB
.NET Coding Standards
These standards apply to all .NET code in the dotnet/ directory. All contributors and AI agents must follow these rules.
Runtime and Language
- Target framework: .NET 10
- Language: C# (latest stable version)
- Nullable reference types: Enabled project-wide (
<Nullable>enable</Nullable>) - Implicit usings: Enabled (
<ImplicitUsings>enable</ImplicitUsings>)
General Practices
- Follow the Microsoft C# coding conventions
- Use
PascalCasefor public members, types, namespaces, and methods - Use
camelCasefor local variables and parameters - Prefix private fields with
_(e.g.,_connectionCount) - Prefer
readonlyfields and immutable types where practical - Use file-scoped namespaces
- Use primary constructors where they simplify the code
- Prefer pattern matching over type-checking casts
- Use
CancellationTokenon all async method signatures - Use
ReadOnlySpan<byte>andReadOnlyMemory<byte>on hot paths to minimize allocations - Prefer
ValueTaskoverTaskfor methods that frequently complete synchronously
Forbidden Packages
Do NOT use the following packages anywhere in the solution:
| Package | Reason |
|---|---|
FluentAssertions |
Use Shouldly instead |
Moq |
Use NSubstitute instead |
Unit Testing
All unit tests live in dotnet/tests/ZB.MOM.NatsNet.Server.Tests/.
Framework and Libraries
| Purpose | Package | Version |
|---|---|---|
| Test framework | xUnit |
3.x |
| Assertions | Shouldly |
latest |
| Mocking | NSubstitute |
latest |
| Benchmarking | BenchmarkDotNet |
latest (for Benchmark* ports) |
xUnit 3 Conventions
- Use
[Fact]for single-case tests - Use
[Theory]with[InlineData]or[MemberData]for parameterized tests (replaces Go table-driven tests) - Use
[Collection]to control test parallelism when tests share resources - Test classes implement
IAsyncLifetimewhen setup/teardown is async - Do not use
[SetUp]or[TearDown]— those are NUnit/MSTest concepts
Shouldly Conventions
// Preferred assertion style
result.ShouldBe(expected);
result.ShouldNotBeNull();
result.ShouldBeGreaterThan(0);
collection.ShouldContain(item);
collection.ShouldBeEmpty();
Should.Throw<InvalidOperationException>(() => subject.DoSomething());
await Should.ThrowAsync<TimeoutException>(async () => await subject.DoSomethingAsync());
NSubstitute Conventions
// Create substitutes
var logger = Substitute.For<ILogger<MyService>>();
var repository = Substitute.For<IRepository>();
// Configure returns
repository.GetByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new Entity { Id = 1 });
// Verify calls
logger.Received(1).Log(
Arg.Is(LogLevel.Warning),
Arg.Any<EventId>(),
Arg.Any<object>(),
Arg.Any<Exception?>(),
Arg.Any<Func<object, Exception?, string>>());
Test Naming
[Method]_[Scenario]_[Expected]
Examples:
TryParse_ValidInput_ReturnsTrueMatch_WildcardSubject_ReturnsSubscribersConnect_InvalidCredentials_ThrowsAuthException
Test Class Naming
[ClassName]Tests
Examples: NatsParserTests, SubListTests, JetStreamControllerTests
Logging
Use Microsoft.Extensions.Logging with Serilog as the provider.
Setup (in ZB.MOM.NatsNet.Server.Host)
builder.Host.UseSerilog((context, services, configuration) =>
configuration.ReadFrom.Configuration(context.Configuration));
Usage in Services
Inject ILogger<T> via constructor injection:
public class ConnectionHandler
{
private readonly ILogger<ConnectionHandler> _logger;
public ConnectionHandler(ILogger<ConnectionHandler> logger)
{
_logger = logger;
}
public void HandleConnection(string clientId)
{
using (LogContext.PushProperty("ClientId", clientId))
{
_logger.LogInformation("Client connected");
}
}
}
Structured Logging Rules
- Always use message templates with named placeholders — never string interpolation:
// Correct _logger.LogInformation("Client {ClientId} subscribed to {Subject}", clientId, subject); // Wrong — loses structured data _logger.LogInformation($"Client {clientId} subscribed to {subject}"); - Use
LogContext.PushPropertyto add contextual properties that apply to a scope of operations (e.g., client ID, connection ID, stream name). This enriches all log entries within theusingblock without repeating parameters:using (LogContext.PushProperty("ConnectionId", connection.Id)) using (LogContext.PushProperty("RemoteAddress", connection.RemoteEndPoint)) { // All log entries here automatically include ConnectionId and RemoteAddress _logger.LogDebug("Processing command"); _logger.LogInformation("Subscription created for {Subject}", subject); } - Use appropriate log levels:
Level Use for TraceWire protocol bytes, parser state transitions DebugInternal state changes, subscription matching details InformationClient connects/disconnects, server start/stop, config loaded WarningSlow consumers, approaching limits, recoverable errors ErrorFailed operations, unhandled protocol errors CriticalServer cannot continue, data corruption detected
Serilog Configuration
Configure via appsettings.json:
{
"Serilog": {
"Using": ["Serilog.Sinks.Console"],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
},
"Enrich": ["FromLogContext"],
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
}
}
]
}
}
The "Enrich": ["FromLogContext"] entry is required for LogContext.PushProperty to work.
Dependency Injection
- Register services in
ZB.MOM.NatsNet.Server.HostusingMicrosoft.Extensions.DependencyInjection - Prefer constructor injection
- Use
IOptions<T>/IOptionsMonitor<T>for configuration binding - Register scoped services for per-connection lifetime, singletons for server-wide services
Async Patterns
- All I/O operations must be async (
async/await) - Use
CancellationTokenpropagation consistently - Use
Channel<T>for producer-consumer patterns (replaces Go channels) - Use
Task.WhenAll/Task.WhenAnyfor concurrent operations (replaces Goselect) - Avoid
Task.Runfor CPU-bound work in hot paths — prefer dedicated processing pipelines
Performance
- Use
System.IO.Pipelines(PipeReader/PipeWriter) for network I/O - Prefer
Span<T>/Memory<T>over arrays for buffer operations - Use
ArrayPool<T>.Sharedfor temporary buffers - Use
ObjectPool<T>for frequently allocated objects - Profile before optimizing — do not prematurely optimize