Skip to content

Coding Standards

This guide outlines the coding standards and conventions for the LBS Foundry project. Following these standards ensures consistency, maintainability, and quality across the codebase.

C# Coding Standards

Member Access

  • Always use this. prefix: All class member access must use this. prefix for properties, methods, and fields
    // Good
    public void Execute(CreatePlayerCommand command)
    {
        this.ValidateCommand(command);
        this.RaiseEvent(new PlayerCreatedEvent
        {
            PlayerId = this.playerId,
            PlayerName = command.PlayerName
        });
    }
    
    // Bad
    public void Execute(CreatePlayerCommand command)
    {
        ValidateCommand(command); // Missing this.
        RaiseEvent(new PlayerCreatedEvent
        {
            PlayerId = playerId, // Missing this.
            PlayerName = command.PlayerName
        });
    }
    

Field Naming

  • No underscore naming: Private fields use camelCase without underscore prefix
    // Good
    private readonly ILogger logger;
    private readonly IDocumentStore store;
    
    // Bad
    private readonly ILogger _logger;
    private readonly IDocumentStore _store;
    
  • Exception for static fields: Static private fields may use underscore prefix when appropriate

Enum Usage

  • No C# enums: Do not use C# enum types to avoid serialization issues and maintain API flexibility
  • Use string constants: Replace enums with static classes containing string constants
    // Good - String constants
    public static class UserStatus
    {
        public const string Active = "Active";
        public const string Inactive = "Inactive";
        public const string Suspended = "Suspended";
        public const string Deleted = "Deleted";
    
        public static readonly string[] All = { Active, Inactive, Suspended, Deleted };
        public static bool IsValid(string status) => All.Contains(status);
    }
    
    // Usage
    public string Status { get; init; } = UserStatus.Active;
    
    // Bad - C# enum
    public enum UserStatus
    {
        Active,
        Inactive,
        Suspended,
        Deleted
    }
    

General Guidelines

  • No emojis: Do not use emojis in code, comments, documentation, or any files unless explicitly requested by the user
  • Nullable reference types: Always enabled (<Nullable>enable</Nullable>)
  • Implicit usings: Always enabled (<ImplicitUsings>enable</ImplicitUsings>)

Event Sourcing Patterns

Commands

[DataContract(Name = "CreateCompetition")]
[RequiresRoles(RoleDefinition.Admin)]
public sealed record CreateCompetitionCommand : DomainCommand<CompetitionId>
{
    [DataMember] public string Sport { get; init; } = string.Empty;
    [DataMember] public string Name { get; init; } = string.Empty;
    [DataMember] public string Slug { get; init; } = string.Empty;
}

Events

[DataContract(Name = "CompetitionCreated", Namespace = LBSNamespace.Default)]
public sealed record CompetitionCreatedEvent : DomainEvent<CompetitionId>
{
    [DataMember] public string Sport { get; init; } = string.Empty;
    [DataMember] public string Name { get; init; } = string.Empty;
    [DataMember] public string Slug { get; init; } = string.Empty;
    [DataMember] public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
}

Aggregates

public sealed class CompetitionAggregate : AggregateRoot<CompetitionId>
{
    private string name = string.Empty;
    private string sport = string.Empty;

    public void Execute(CreateCompetitionCommand command)
    {
        // Validation
        if (string.IsNullOrEmpty(command.Name))
            throw new ArgumentException("Competition name is required");

        // Raise event
        this.RaiseEvent(new CompetitionCreatedEvent
        {
            AggregateRootId = command.AggregateRootId,
            Sport = command.Sport,
            Name = command.Name,
            Slug = command.Slug,
            CreatedAt = DateTimeOffset.UtcNow
        });
    }

    public void Apply(CompetitionCreatedEvent @event)
    {
        this.Id = @event.AggregateRootId;
        this.name = @event.Name;
        this.sport = @event.Sport;
    }
}

Query Patterns

Query Definition

using System.Runtime.Serialization;
using System.Security.Claims;
using LBS.Anchor;
using LBS.Augment.Authentication;

[DataContract(Name = "PlayerSearch")]
public sealed class PlayerSearchQuery : ISearchQuery, IRequiresUserContext
{
    public string QueryType => this.GetType().GetQueryTypeName();
    public ClaimsPrincipal? User => UserContext.Current;

    public string? SearchTerm { get; set; }
    public List<string>? Teams { get; set; }
    public decimal? MinSalary { get; set; }

    // Pagination and ordering are carried on the query object
    public string? OrderBy { get; set; }
    public int Skip { get; set; }
    public int? Take { get; set; }
}

Query Handler

The interface is IQueryHandler<in TQuery, TResult> with a single-argument HandleAsync(TQuery query) that returns a (Results, TotalCount) tuple. Pagination and ordering are properties of the query object, not separate parameters.

public sealed class PlayerSearchQueryHandler : IQueryHandler<PlayerSearchQuery, PlayerContract>
{
    private readonly IDocumentSession session;

    public PlayerSearchQueryHandler(IDocumentSession session)
    {
        this.session = session;
    }

    public async Task<(IEnumerable<PlayerContract> Results, int TotalCount)> HandleAsync(PlayerSearchQuery query)
    {
        var queryable = this.session.Query<PlayerContract>();

        if (!string.IsNullOrEmpty(query.SearchTerm))
        {
            queryable = queryable.Where(p => p.PlayerName.Contains(query.SearchTerm));
        }

        if (query.Teams?.Any() == true)
        {
            queryable = queryable.Where(p => query.Teams.Contains(p.TeamName));
        }

        var totalCount = await queryable.CountAsync();
        var results = await queryable.Skip(query.Skip).Take(query.Take ?? 100).ToListAsync();

        return (results, totalCount);
    }
}

TypeScript/Svelte Standards

Component Structure

<!-- PlayerCard.svelte -->
<script lang="ts">
  import type { Player } from '$lib/types';

  interface Props {
    player: Player;
    onSelect?: (playerId: string) => void;
  }

  let { player, onSelect }: Props = $props();

  function handleClick() {
    onSelect?.(player.id);
  }
</script>

<button onclick={handleClick} class="player-card">
  <h3>{player.name}</h3>
  <p>{player.position}</p>
</button>

Naming Conventions

C# Naming

  • Classes: PascalCase (e.g., PlayerAggregate, CompetitionService)
  • Interfaces: IPascalCase (e.g., ICommandExecutor, IQueryHandler)
  • Methods: PascalCase (e.g., Execute, HandleAsync)
  • Properties: PascalCase (e.g., PlayerName, CreatedAt)
  • Private fields: camelCase (e.g., logger, documentStore)
  • Constants: PascalCase or UPPER_CASE (e.g., DefaultTimeout, MAX_RETRIES)
  • Events: Past tense with "Event" suffix (e.g., PlayerCreatedEvent, TeamUpdatedEvent)
  • Commands: Present tense with "Command" suffix (e.g., CreatePlayerCommand, UpdateTeamCommand)

TypeScript Naming

  • Components: PascalCase (e.g., PlayerCard, TeamList)
  • Functions: camelCase (e.g., handleClick, fetchPlayers)
  • Variables: camelCase (e.g., playerData, isLoading)
  • Types/Interfaces: PascalCase (e.g., Player, TeamStats)
  • Enums: Use string constants instead of TypeScript enums

Comments and Documentation

XML Documentation (C#)

/// <summary>
/// Creates a new player with the specified details.
/// </summary>
/// <param name="command">The player creation command containing player details.</param>
/// <returns>A list of domain events generated by the creation.</returns>
/// <exception cref="ArgumentException">Thrown when player name is empty.</exception>
public IReadOnlyList<IDomainEvent> Execute(CreatePlayerCommand command)
{
    // Implementation
}

JSDoc (TypeScript)

/**
 * Fetches player statistics for the specified team.
 * @param teamId - The unique identifier of the team
 * @param season - Optional season filter (defaults to current season)
 * @returns Promise resolving to array of player statistics
 */
export async function fetchPlayerStats(
  teamId: string,
  season?: string
): Promise<PlayerStats[]> {
  // Implementation
}

Testing Standards

Test Naming

[Test]
public async Task CreatePlayer_WithValidData_ShouldRaisePlayerCreatedEvent()
{
    // Arrange
    // Act
    // Assert
}

[Test]
public async Task CreatePlayer_WithEmptyName_ShouldThrowArgumentException()
{
    // Arrange
    // Act & Assert
}

Test Organization

  • Use AAA pattern (Arrange, Act, Assert)
  • One assertion concept per test
  • Use descriptive test names following the pattern: MethodName_StateUnderTest_ExpectedBehavior
  • Group related tests in test fixtures

Code Organization

File Structure

src/
├── Domain/
│   └── LBS.Domain.Core/
│       └── Player/
│           ├── Commands/
│           │   └── CreatePlayerCommand.cs
│           ├── Events/
│           │   └── PlayerCreatedEvent.cs
│           ├── Aggregates/
│           │   └── PlayerAggregate.cs
│           ├── Contracts/
│           │   └── PlayerContract.cs
│           └── Configuration/
│               ├── PlayerMartenModule.cs
│               └── PlayerServiceExtensions.cs

Feature Module Pattern

Each feature should include: 1. [Feature]MartenModule.cs - Marten configuration 2. [Feature]ServiceExtensions.cs - DI registration 3. Commands, Events, and Aggregates in appropriate folders 4. Query handlers and contracts

Security Standards

Authorization

// Always specify required roles on commands
[RequiresRoles(RoleDefinition.Admin, RoleDefinition.UserManager)]
public sealed record CreateUserCommand : DomainCommand<UserId> { }

// Use ISecureQuery for authenticated queries
[RequiresRoles(RoleDefinition.Member)]
[DataContract(Name = "SecurePlayer")]
public sealed class SecurePlayerQuery : ISecureQuery
{
    public string QueryType => this.GetType().GetQueryTypeName();
}

// Use IPublicQuery for public access
[DataContract(Name = "PublicTeam")]
public sealed class PublicTeamQuery : IPublicQuery
{
    public string QueryType => this.GetType().GetQueryTypeName();
}

Data Protection

  • Never log sensitive information (passwords, tokens, PII)
  • Always validate input at command/query boundaries
  • Use parameterized queries
  • Follow OWASP guidelines

Performance Guidelines

Database Access

// Good - Efficient query with projection
var players = await this.session
    .Query<PlayerContract>()
    .Where(p => p.TeamId == teamId)
    .Select(p => new { p.Id, p.Name, p.Position })
    .ToListAsync();

// Bad - Loading unnecessary data
var players = await this.session
    .Query<PlayerContract>()
    .ToListAsync();
var filtered = players.Where(p => p.TeamId == teamId);

Async/Await

// Good - Proper async all the way
public async Task<PlayerContract?> GetPlayerAsync(PlayerId id)
{
    return await this.session.LoadAsync<PlayerContract>(id.Value);
}

// Bad - Blocking async code
public PlayerContract? GetPlayer(PlayerId id)
{
    return this.session.LoadAsync<PlayerContract>(id.Value).Result;
}

Version Control

Commit Messages

  • Lead the subject with the Linear card id (e.g. LBS-1839 - <summary>)
  • Do not use conventional-commit prefixes (feat:, fix:, docs:, refactor:, test:)
  • Write the summary in present tense, describing what the change does
  • List multiple cards comma-separated when a change spans several (e.g. LBS-1770, LBS-1772 - ...)

Examples:

LBS-1839 - Add player statistics endpoint
LBS-1817 - Change clickhouse storage to use int[] instead of double[]
LBS-1770, LBS-1772 - Season hierarchical structure (Pools)

Pull Request Guidelines

  • Include Linear card numbers in PR title
  • Follow PR template
  • Ensure all tests pass
  • Request review from appropriate team members

Summary

These coding standards ensure: - Consistency: Uniform code style across the project - Maintainability: Easy to understand and modify - Quality: Reduced bugs and better performance - Security: Proper authorization and data protection - Team Efficiency: Clear expectations and patterns

Always prioritize readability and maintainability over clever solutions. When in doubt, follow the existing patterns in the codebase and consult with the team.