Rulegeneral
Backend Rule
Core rules for C# development and tooling
Backend
Guidelines for C# backend development, including code style, naming, exceptions, logging, and build/test/format workflow.
Code Style
- Be consistent—if you do something a certain way, do all similar things the same way
- Always use these C# features:
- Top-level namespaces
- Primary constructors
- Array initializers
- Pattern matching with
is nullandis not nullinstead of== nulland!= null
- Records for immutable types
- Mark all C# types as sealed
- Use
varwhen possible - Use simple collection types like
UserId[]instead ofList<UserId>whenever possible - JetBrains tooling formats code automatically, but line breaking is disabled for readability:
- Wrap lines only when new language constructs start after 120 characters (content after 120 chars is acceptable if the construct starts before)
CancellationToken cancellationTokenis not considered important and should never trigger a line break- Prefer long lines over splitting to maximize visible code—only wrap when truly necessary
- Examples:
// ✅ DO: Keep on one line even if longer than 120 chars (the 'c' in 'command' is before 120 chars) public async Task<Result<CompleteEmailConfirmationResponse>> Handle(CompleteEmailConfirmationCommand command, CancellationToken cancellationToken) // ✅ DO: Keep constructor parameters on one line when they fit (the 'e' in 'executionContext' is before 120 chars) public sealed class GetPaymentHistoryHandler(ISubscriptionRepository subscriptionRepository, IExecutionContext executionContext) : IRequestHandler<GetPaymentHistoryQuery, Result<PaymentHistoryResponse>> // ✅ DO: Wrap to 2 lines when needed, but never 3, 4, or 5 lines var updatedLocale = Connection.ExecuteScalar<string>( "SELECT Locale FROM Users WHERE Id = @id", new { id = DatabaseSeeder.Tenant1Owner.Id.ToString() } ); // ❌ DON'T: Split method parameters across multiple lines when they fit before 120 chars public async Task<Result> Handle( UpgradeSubscriptionCommand command, CancellationToken cancellationToken ) // ❌ DON'T: Split constructor parameters across multiple lines when they fit before 120 chars public sealed class GetPaymentHistoryHandler( ISubscriptionRepository subscriptionRepository, IExecutionContext executionContext ) : IRequestHandler<GetPaymentHistoryQuery, Result<PaymentHistoryResponse>>
- Avoid using exceptions for control flow:
- When throwing exceptions, use meaningful exceptions following .NET conventions
- Use
UnreachableExceptionto signal unreachable code that cannot be reached by tests - Exception messages should include a period
- Log only meaningful events at appropriate severity levels:
- Logging messages should not include a period
- Use structured logging
- Never introduce new NuGet dependencies
- Don't do defensive coding (e.g., don't add exception handling for situations we don't know will happen)
- Use
user?.IsActive == trueoveruser != null && user.IsActive == true - Avoid try-catch unless we cannot fix the root cause—global exception handling covers unknown exceptions
- Use
SharedInfrastructureConfiguration.IsRunningInAzureto determine if running in Azure - Inject
TimeProviderinto services and handlers, usetimeProvider.GetUtcNow()instead ofDateTimeOffset.UtcNow - Pass
DateTimeOffsetvalues (notTimeProvider) to domain methods and aggregates to maintain clean boundaries (e.g.,entity.HasExpired(timeProvider.GetUtcNow())) - Naming rules:
- Never use acronyms or abbreviations (e.g., use
SharedAccessSignaturenotSas,ContextnotCtx) - Prefer long variable names for readability (e.g.,
gravatarHttpClientnothttpClient) - Choose descriptive and unambiguous names
- Make meaningful distinctions
- Use pronounceable names
- Use searchable names
- Replace magic numbers with named constants
- Avoid encodings—don't append prefixes or type information
- Never use acronyms or abbreviations (e.g., use
- Comments rules:
- Don't explain what you changed (that belongs in commit messages)—code should reflect the current state only
- Always try to explain yourself in code
- Don't be redundant
- Don't add obvious noise
- Don't use closing brace comments
- Don't comment out code—just remove it
- Use comments for explanation of intent, clarification, or warning of consequences
- Source code structure:
- Separate concepts vertically
- Related code should appear vertically dense
- Declare variables close to their usage
- Dependent functions should be close
- Similar functions should be close
- Place functions in the downward direction
- Order: public functions above internal, internal above private
- Don't use horizontal alignment
- Use white space to associate related things and disassociate weakly related
- Avoid nesting—prefer early return or break/continue statements, keeping the happy path at the end
- Functions rules:
- Keep them small
- Do one thing
- Use descriptive names
- Prefer fewer arguments
- Have no side effects
- Don't use flag arguments—split into independent methods instead
- For enum comparisons:
- When comparing enums to enums, use direct comparison:
tenant.State == tenantState.Trial - When comparing string properties to enums (e.g., JWT claims), use
nameof:executionContext.UserInfo.Role == nameof(UserRole.Owner) - Avoid unnecessary
Enum.TryParsewhen the comparison context is clear
- When comparing enums to enums, use direct comparison:
Implementation
Follow these steps when implementing changes:
- Always start new changes by writing new test cases (or change existing tests)—consult API Tests for details
- Build and test your changes:
- Use the execute MCP tool with
command: "build"for backend - Use the execute MCP tool with
command: "test"to run all tests - If you change API contracts (endpoints, DTOs), also build frontend to ensure it still compiles
- Use the execute MCP tool with
- Format and inspect your code in parallel:
- When all tests pass and the feature is complete, call both MCP tools in a single message:
execute_command(command: "format")execute_command(command: "inspect")
- Format automatically fixes code style issues according to our conventions
- ALL inspect findings are blocking - CI pipeline fails on any result marked "Issues found"
- Severity level (note/warning/error) is irrelevant - fix all findings before proceeding
- When all tests pass and the feature is complete, call both MCP tools in a single message:
When you see paths like /[scs-name]/Core/Features/[Feature]/Domain in rules, replace [scs-name] with the specific self-contained system name (e.g., account-management, back-office) and [Feature] with the feature name (e.g., Users, Tenants). A feature is often 1:1 with a domain aggregate.