C# Coding Convention

C# Coding Convention

A generic, project-agnostic convention for writing structured, readable, and polished C# code. It can be adopted as-is for a new project or package, used to refactor an existing codebase, or pasted into an AI prompt to describe exactly how the resulting code should look.

The core idea behind every rule: a reader who has seen one file should be able to predict the layout, naming, and style of every other file. Predictability beats cleverness.

All type and member names in the examples (notebooks, settings, carts) are placeholders — substitute your own domain; the shapes are what matter.


Table of Contents

  1. Core Principles
  2. Solution & Folder Structure
  3. Namespaces & Usings
  4. Naming
  5. Formatting
  6. File & Member Layout
  7. Type Design
  8. Documentation & Comments
  9. Error Handling & Defaults
  10. Concurrency & Resource Lifetime
  11. Performance Awareness
  12. API Design Patterns
  13. Testing
  14. Review Checklist

1. Core Principles

  • Consistency over cleverness. The same problem is always solved the same way, named the same way, and shaped the same way. A clever one-off that saves ten lines but breaks the pattern costs more than it saves.
  • Explicit over implicit. Explicit types instead of var, explicit braces on every block, explicit default values, explicit locks, explicit documentation of behavior at boundaries.
  • Code reads top-down like prose. Guard clauses first, then the happy path. Precision-critical algorithms are narrated with comments so a reader never has to reverse-engineer intent.
  • Small surface, deep structure. Public API is minimal and interface-shaped; everything else is private, protected, or internal. One type per file, one responsibility per type.
  • Document the contract, not just the signature. Every public member states what it returns when data is missing, whether ranges are inclusive or exclusive, and what the caller must do — written once at the topmost declaration and inherited everywhere else.

2. Solution & Folder Structure

2.1 One module = one assembly

Split the codebase into small assemblies (projects) named Company.Product.Module (e.g. MyApp.Notes, MyApp.Files, MyApp.Sync). Each module:

  • Has a root namespace equal to the assembly name.
  • References other modules through their public interfaces only.
  • Contains an AssemblyInfo.cs at its root for assembly-level attributes:
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("MyApp.Notes.Tests")]

2.2 Role-based folders inside a module

Organize files into folders by architectural role, not by C# kind. Folder names are short plural (or collective) nouns. A typical layered module:

MyApp.Notes/
├── AssemblyInfo.cs
├── NotesLibrary.cs  // Facade and other entry-point types live at the module root.
├── StoreFactory.cs
├── Common/          // Public contracts: interfaces shared across the module and consumers.
├── Records/         // Small immutable value types (readonly structs).
├── Models/          // Core domain logic implementing the contracts.
├── Data/            // Serializable DTOs — pure data, zero logic.
├── Providers/       // Persistence strategies (how data reaches disk/network).
├── Aggregators/     // Computation across multiple model instances.
└── Tests/           // Separate test assembly mirroring the structure above.

Because folders define namespaces (see 3), folder structure is kept deliberately shallow and collision-free:

  • Maximum one role level deep. Files live either at the module root or in exactly one role folder. If a role folder wants subfolders, the module has grown too large — split it into a new assembly instead of nesting deeper.
  • A folder name must never equal a type name. A Storage/ folder containing a Storage class produces the namespace-qualified name Storage.Storage and makes the simple name ambiguous for every neighbor. Plural role nouns avoid this naturally.
  • Entry-point types live at the module root. The facade, factory, and migration types that consumers touch first sit directly in the root namespace, so most consumers need only one using for everyday work.

The exact folder names follow your domain; the rule is that each folder answers "what role do these types play?" and a file's location is predictable from its suffix (*DataData/, *AggregatorAggregators/, interfaces → Common/).

2.3 Files

  • One top-level type per file. The file name equals the type name exactly (Notebook.cs contains Notebook).
  • Tests live in a Tests/ subfolder with their own assembly, organized into subfolders by concern (Models/, ReadWrite/, Tooling/). Test assemblies get access to internals via InternalsVisibleTo — never by widening production visibility.

3. Namespaces & Usings

  • The namespace mirrors the folder path: assembly root namespace plus the role folder. Models/Notebook.cs lives in MyApp.Notes.Models; NotesLibrary.cs at the module root lives in MyApp.Notes. This matches what every IDE and analyzer expects, and moving a file is a conscious API decision rather than a silent one.
  • Namespaces stay short and readable because folder depth is capped at one role level (see 2.2) — never more than Company.Product.Module.Role.
  • A type name must not repeat the last namespace segment (Notes.Notebook, never Notebook.Notebook), and a folder must never share a name with a type it could shadow.
  • Test namespaces follow the same rule: MyApp.Notes.Tests.Models.
  • Use block-scoped namespaces (braces), with all type content indented inside.
  • Place using directives above the namespace, sorted alphabetically as one group (no separation between System, third-party, and project namespaces).
  • Import only what is used — no leftover usings.
using MyApp.Notes.Records;
using Newtonsoft.Json;
using System;

namespace MyApp.Notes.Models
{
    public sealed class Notebook
    {
        // ...
    }
}

4. Naming

4.1 Casing

Element Style Example
Types, interfaces, enums PascalCase Notebook, INotebook
Methods, properties, events PascalCase QueryTotal, ActiveSession
Constants (const, static readonly) PascalCase DefaultCapacity, Separator
Private and protected fields _camelCase _lockObject, _fileSystem
Public fields (DTOs, struct data) PascalCase Name, CreatedUtc
Parameters and locals camelCase noteRecord, rangeStart
Type parameters T prefix TValue, TItem

The underscore prefix keeps fields visually distinct from locals and parameters, removes any need for this. disambiguation, and turns an accidental self-assignment into an obvious typo:

public StoreFactory(IFileSystem fileSystem, IClockService clockService)
{
    _fileSystem = fileSystem;
    _clockService = clockService;
}

4.2 Type name patterns

Type names are built from a domain concept plus a role suffix, so the role of any type is readable from its name alone:

Suffix / prefix Role Example
I prefix Contract / capability INotebook
…Data Serializable DTO (pure data, no logic) NotebookData
…Factory Constructs configured instances StoreFactory
…Aggregator Combines/computes across multiple instances TotalsAggregator
…Provider A persistence/source strategy in a family FileProvider, MemoryProvider
…Record A single immutable fact NoteRecord
…Migration Import/export/upgrade of stored data ArchiveMigration
…Service Injected capability from another module IClockService

Typed families share a strict pattern — <ValueType><Concept>: BoolSetting, LongSetting, DecimalSetting, StringSetting. Never break the pattern for one member of a family.

4.3 A small, consistent verb vocabulary

Pick one verb per kind of action and use it everywhere. A reader should be able to guess a method's cost and side effects from its first word:

Verb Meaning
Get… Return an existing thing cheaply; may create-on-demand if documented.
Query… Compute a result from stored data (read-only, possibly non-trivial).
Create… Construct and return a new instance.
Push… Append an item to an ordered collection/log.
Mark… Set a state flag (MarkDirty).
Read… / Write… Perform I/O against an external system.
Is… / Has… Boolean predicate with no side effects.
To… Convert to another representation (ToData).
Initialize Asynchronous post-construction setup.
Release… Drop cached references.
Import… / Export… / Wipe… Bulk data migration actions.

4.4 Symmetry and descriptive names

  • Paired concepts get paired names: Attach/Detach, Import/Export, rangeStart/rangeEnd, ReadX/WriteX, Connect/Disconnect.
  • Prefer full descriptive names over abbreviations: noteRecord not rec, subfolderName not subName, lastSeparatorIndex not lastSep. Widely-known abbreviations (utc, kvp, cts, json) are acceptable.
  • Loop indices: a single short letter (x, i) is fine for a simple scan. When loops nest or the body is non-trivial, give indices meaning: segmentIndex, readPosition, writePosition, or a letter tied to the collection (r for rows, c for columns).

5. Formatting

5.1 Braces and indentation

  • Allman style: every opening brace on its own line.
  • Braces on every block, including single-statement if/for bodies. No exceptions.
  • 4-space indentation, no tabs.
  • One blank line between members. No blank line directly after an opening brace or before a closing brace.

5.2 Explicit types — no var

Declare every variable with its explicit type. A var declaration is only readable if the variable name or the rest of the line happens to reveal the type — the reader is forced to infer instead of just seeing it. The explicit type makes every declaration self-contained. Use target-typed new() to avoid repeating the type on the right-hand side:

// Variable type is the constructed type: use new().
SortedList<EntryKey, NoteRecord> notes = new();
EntryKey entryKey = new(noteRecord.CreatedUtc, noteRecord.Id);

// Variable type is an interface or base type: spell out the constructed type.
IExportService exportService = new PdfExportService();
IFileSystem fileSystem = new LocalFileSystem(loggerService);

The same applies to foreach variables and out variables — always explicit:

foreach (KeyValuePair<string, Notebook> pair in notebooks)
{
    // ...
}
if (notebooks.TryGetValue(name, out Notebook notebook))
{
    // ...
}

5.3 Expression bodies — only when they read better

Expression-bodied members (=>) are welcome when the entire member is a single short expression that fits on one line and reads at a glance: trivial computed getters, pure delegation, equality operators, ToString. If the member locks, branches, spans multiple statements, or the expression gets long enough to wrap — use a full block body. Readability decides, not line count vanity: never cram logic into => just to save braces.

// Expression-bodied: one short expression, obvious at a glance.
public bool IsEmpty => Count == 0;
public override string ToString() => $"(CreatedUtc: {CreatedUtc}, Id: {Id})";
public static bool operator ==(EntryKey left, EntryKey right) => left.Equals(right);
public string GetSegment(int index) => GetSegmentSpan(index).ToString();

// Block-bodied: locking, branching, or multiple statements.
public IReadOnlyList<NoteRecord> Notes
{
    get
    {
        lock (_lockObject)
        {
            return _notes;
        }
    }
}

Auto-properties are used when there is genuinely no logic:

public SettingsStore Settings { get; }
protected Workspace Workspace { get; private set; }

A ternary is acceptable only for a small value selection inside a statement:

int priorityComparison = Priority.CompareTo(other.Priority);
return priorityComparison != 0 ? priorityComparison : Name.CompareTo(other.Name);

5.4 Multi-line conditions

Split long conditions with one clause per line, the operator trailing each line, and continuation lines aligned under the first clause:

if (entry.CreatedUtc >= rangeStart &&
    entry.CreatedUtc < rangeEnd &&
    !IsArchived(entry))
{
    matches.Add(entry);
}

5.5 Miscellaneous

  • Object initializers for DTO construction:

    NotebookData data = new()
    {
        Name = name,
        Notes = new NoteData[notes.Count],
        Tags = new string[tags.Count]
    };
    
  • String interpolation over concatenation: $"{fileSystem.RootPath}/{ExportsFolder}/{fileName}.{JsonExtension}".

  • nameof(...) instead of hardcoded identifier strings — in exceptions, paths, and logs.

  • Numeric literal suffixes where the type is not int: 0L, 32L, 1.5m.

  • Tuples for small multi-value returns, deconstructed with explicit types at the call site:

    (string filePath, Notebook notebook) = await GetNotebook(name);
    
  • No #region. Large symmetric classes are structured with single-line section comments instead (see 6.2).


6. File & Member Layout

6.1 Member order

Within a type, members appear in this order:

  1. Constants (const, then static readonly).
  2. Fields — injected dependencies first, then synchronization primitives, then state.
  3. Constructors (chained from least to most parameters where applicable).
  4. Properties.
  5. Public methods (grouped by theme).
  6. Protected/private helpers — either near their only caller or toward the bottom.
  7. Equality members and operator overloads.
  8. Private static helpers last.

An abstract base class may open with its abstract members so subclass authors see the contract first.

6.2 Section comments in large symmetric classes

When a class exposes a long, repetitive API family, split it into titled groups using a single-line comment ending with a period, surrounded by blank lines:

// Value queries.

public async Task<bool> ReadBool(string key) { /* ... */ }
public async Task<long> ReadLong(string key) { /* ... */ }

// Value writers.

public async Task WriteBool(string key, bool value) { /* ... */ }
public async Task WriteLong(string key, long value) { /* ... */ }

7. Type Design

7.1 Interfaces define the public surface

  • Every capability consumed by another layer or module gets an interface in Common/.

  • Interfaces expose read-only views of internal collections (IReadOnlyDictionary<TKey, TValue>, IReadOnlyList<T>, IEnumerable<T>), while implementations hold the concrete mutable collections (SortedList, Dictionary, List).

  • Public members of concrete classes return interface types, not concrete types:

    /// <summary>
    /// Notes in this notebook, exposed as <see cref="IReadOnlyList{T}"/>
    /// to prevent callers from mutating internal state.
    /// </summary>
    public IReadOnlyList<NoteRecord> Notes { /* ... */ }
    
  • Tiny marker interfaces are welcome for grouping related value types, written on one line:

    /// <summary>
    /// Group of commands related to image editing.
    /// </summary>
    public interface IImageCommand : IEditorCommand { }
    

7.2 Classes

  • Mark classes sealed unless they are explicitly designed for inheritance.

  • Shared behavior lives in an abstract base class that owns common state and locking, and delegates the type-specific part to a protected abstract method named …Impl (template method pattern):

    public abstract class BatchWriter<TItem>
    {
        /// <summary>
        /// Write a batch of items to the underlying target.
        /// The lock is already held when this method is called.
        /// </summary>
        protected abstract void WriteBatchImpl(TItem[] batch);
    
        public void WriteBatch(TItem[] batch)
        {
            lock (_lockObject)
            {
                WriteBatchImpl(batch);
            }
        }
    }
    
  • Dependencies are injected through the constructor as interfaces and stored in private readonly fields. No service locators, no statics for dependencies.

7.3 Immutable value types

Small immutable records of fact are readonly structs implementing a marker interface. They carry an identity, get a [JsonConstructor] for deserialization, and a convenience constructor that generates the identity:

[Serializable]
public readonly struct NoteRecord : INoteRecord
{
    public readonly Guid Id { get; }
    public readonly long CreatedUtc { get; }
    public readonly string Text { get; }

    [JsonConstructor]
    public NoteRecord(Guid id, long createdUtc, string text)
    {
        Id = id;
        CreatedUtc = createdUtc;
        Text = text;
    }

    public NoteRecord(long createdUtc, string text)
    {
        Id = Guid.NewGuid();
        CreatedUtc = createdUtc;
        Text = text;
    }
}

Value types used as dictionary keys or sort keys implement the full equality/comparison set — IComparable<T>, IEquatable<T>, Equals(object), GetHashCode (via HashCode.Combine), ==/!= operators, and a readable ToString:

public override string ToString() => $"(CreatedUtc: {CreatedUtc}, Id: {Id})";

7.4 Serialization DTOs

DTOs are the only mutable-by-design data types, isolated in their own folder and suffixed Data:

  • [Serializable] classes with public fields (not properties), zero methods, zero logic.
  • Array fields default to Array.Empty<T>() so empty instances serialize cleanly.
  • The domain type owns both conversion directions: a constructor accepting the DTO, and a ToData() method producing it. Constructors and converters tolerate null collections.
/// <summary>
/// Serializable notebook data.
/// </summary>
[Serializable]
public class NotebookData
{
    public string Name;
    public NoteData[] Notes = Array.Empty<NoteData>();
    public string[] Tags = Array.Empty<string>();
}

7.5 Constants for defaults and magic values

Every store-like type declares its fallback as a named public constant, referenced by everything that needs the default (never a repeated literal):

public const int DefaultCapacity = 16;
public const char Separator = '/';
protected const string JsonExtension = "json";

7.6 Generic constraints

Constrain generics to exactly what the method needs, written at the end of the signature line:

public void Execute<T>(T command) where T : struct, IEditorCommand

8. Documentation & Comments

8.1 XML documentation

  • Every public type and every public member carries XML documentation. Even seemingly obvious members — the summary states the contract, not just the name.

  • Summaries are short imperative or declarative sentences ending with a period: Append a note to the notebook.

  • Behavioral contracts belong in the docs:

    • Boundary semantics: Start is inclusive, end is exclusive.
    • Missing-data behavior: Returns an empty string if the key has never been written.
    • Creation-on-demand: A new notebook will be created if one does not already exist.
    • Caller obligations, in <remarks>: The caller owns the returned stream and must dispose it.
  • Use <param>, <returns>, and <exception> on non-trivial public APIs; use <see cref="..."/>, <see langword="null"/>, <paramref name="..."/>, and <c>...</c> markup so documentation stays navigable and precise.

  • Write each contract exactly once — at the topmost declaration (the interface or the abstract base member). Implementations and overrides carry /// <inheritdoc/> so they are visibly documented without duplicating text that would inevitably drift:

    /// <inheritdoc/>
    public bool IsEmpty => _count == 0;
    
    • Implementation-specific notes go under the inherited contract:

      /// <inheritdoc/>
      /// <remarks>Returns a point-in-time snapshot, safe to iterate during concurrent writes.</remarks>
      public IEnumerable<Notebook> GetNotebooks() { /* ... */ }
      
    • When a member is not directly bound to the documented declaration, point at it explicitly: /// <inheritdoc cref="INotebook.QueryTitle"/>.

  • Document why a design decision exists, right where a reader would question it:

    /// <summary>
    /// Internal constructor that skips normalization.
    /// Only used when the caller guarantees that the key is already normalized.
    /// </summary>
    private CacheKey(string normalizedKey)
    

8.2 Inline comments

  • Comments are full sentences: capitalized, ending with a period.

  • Narration is reserved for precision-critical code. When a method must be verifiably correct and is expected to stay stable for years — parsers, normalizers, merge logic, concurrency primitives, anything where a subtle bug corrupts data — narrate each logical paragraph of the algorithm so it can be audited line by line:

    // Default result when no entry matches.
    string latestText = DefaultText;
    // Iterate backwards through entries, newest first.
    for (int x = entries.Count - 1; x >= 0; x--)
    {
        NoteRecord entry = entries[x];
        // Select the newest entry within the requested range that is not archived.
        if (entry.CreatedUtc >= rangeStart &&
            entry.CreatedUtc < rangeEnd &&
            !IsArchived(entry))
        {
            latestText = entry.Text;
            break;
        }
    }
    

    Simple, self-evident code gets no narration — a comment that restates an obvious statement is noise and rots into a lie the first time the line changes.

  • Comment why, especially for performance and correctness decisions:

    // Casting to uint makes a single comparison catch both negative values and
    // values >= Count, because negative ints become very large uints.
    if ((uint)index >= (uint)Count)
    
  • Intentional emptiness is commented. An empty constructor or deliberately empty catch block always says why it is empty:

    public Notebook() : base()
    {
        // Initialize new empty notebook.
    }
    
    catch (Exception)
    {
        // Individual write failure — loop continues and retries on the next interval.
    }
    
  • No commented-out code, no changelog comments, no #region.


9. Error Handling & Defaults

  • Programmer errors throw. Validate arguments at public boundaries and throw standard exception types with a clear message and nameof:

    if (string.IsNullOrEmpty(name))
    {
        throw new ArgumentException("Notebook name must not be empty.", nameof(name));
    }
    throw new ArgumentException($"Key '{key}' already exists in the index.");
    
  • Missing data does not throw. Absent files, unknown names, and empty stores resolve to a documented safe default (empty instance, DefaultValue, empty string) so read paths are total functions:

    NotebookData data = await fileSystem.ReadJsonFile<NotebookData>(filePath);
    if (data == null)
    {
        return new Notebook();
    }
    return new Notebook(data);
    
  • Guard clauses and early returns keep nesting shallow. Check the exceptional case, return, and let the happy path run at the lowest indentation level.

  • Exceptions are swallowed only deliberately — in resilience loops or best-effort shutdown — and every swallow carries a comment explaining the decision.


10. Concurrency & Resource Lifetime

10.1 Locking

  • Each thread-safe class owns a dedicated private readonly object _lockObject = new();. Never lock on this or on public objects.

  • Lock at the public boundary: every public member that touches shared state acquires the lock; private …Impl methods assume the lock is already held.

  • Snapshot under the lock, work outside it. Copy collections to an array inside the lock, then iterate or perform I/O outside, and say so:

    lock (_lockObject)
    {
        // Copy values into an array snapshot to avoid holding the lock during enumeration.
        Notebook[] snapshot = new Notebook[_notebooks.Count];
        _notebooks.Values.CopyTo(snapshot, 0);
        return snapshot;
    }
    

10.2 Async caching

Lazy async initialization uses the double-checked pattern with a SemaphoreSlim(1, 1): check the cache, await the semaphore, re-check inside try, release in finally:

// Check for cached notebook.
if (_notebook != null)
{
    return _notebook;
}
// Lock and wait to double check cache.
await _semaphore.WaitAsync();
try
{
    // Double check cached notebook.
    if (_notebook == null)
    {
        // Load notebook from file system.
        _notebook = await ReadNotebook(filePath);
    }
    return _notebook;
}
finally
{
    _semaphore.Release();
}

Cross-thread flags are volatile; shared maps that need lock-free reads use ConcurrentDictionary.

10.3 Disposal

  • Dispose is idempotent: an _isDisposed guard returns immediately on re-entry.

  • A class disposes exactly the resources it owns; ownership that moves (e.g. a background task disposing its own CancellationTokenSource after the final iteration) is documented at both ends:

    finally
    {
        // Dispose CTS and semaphore here (instead of in Dispose) to guarantee they
        // remain alive while the task is still running, preventing ObjectDisposedException.
        _cts.Dispose();
        _semaphore.Dispose();
    }
    
  • Background loops observe a CancellationToken, perform a final flush of pending work on cancellation, and never let a single failure kill the loop.

  • Intentional fire-and-forget is explicit: _ = FlushLoop();.


11. Performance Awareness

Performance work is visible and commented, never silent cleverness:

  • Array.Empty<T>() for empty defaults; pre-sized arrays with CopyTo instead of LINQ (ToArray, Select, etc.) on hot paths — prefer plain loops where allocation matters.

  • ReadOnlySpan<char> / AsSpan for substring work; offer a span-based API alongside the allocating one and steer callers in the docs: Prefer the span-based overload in performance-sensitive code paths.

  • stackalloc for small temporary buffers, with an explicit size threshold fallback:

    Span<char> buffer = (bufferLength <= 256)
        ? stackalloc char[bufferLength]
        : new char[bufferLength];
    
  • Return the original instance when no transformation was needed (// No changes needed — return the original string to avoid allocation.).

  • Cheap comparisons short-circuit expensive ones:

    // Fast-path: comparing Length (a cheap int comparison) can short-circuit
    // the more expensive string comparison when lengths differ.
    return Length == other.Length && string.Equals(Value, other.Value, StringComparison.Ordinal);
    
  • String comparisons always pass an explicit StringComparison (usually Ordinal).


12. API Design Patterns

12.1 Layered module

A module flows through clear layers, each depending only on the one below:

Contracts (interfaces)  →  Domain logic  →  DTOs  →  Persistence  →  Aggregation  →  Facade
      Common/                 Models/       Data/     Providers/     Aggregators/    (root)

12.2 Facade with named, documented entry points

The module's top-level type exposes each data domain as a get-only property with a summary explaining what belongs there — the facade doubles as the module's index:

/// <summary>
/// App settings and internal flags.
/// </summary>
public SettingsStore Settings { get; }

/// <summary>
/// Notebooks the user has created, loaded on demand and cached.
/// </summary>
public NotebookLibrary Notebooks { get; }

12.3 Factory for construction

A small factory owns the injected dependencies and hands out configured instances, keeping constructors of domain types simple and consumers decoupled from wiring:

public SettingsStore CreateSettingsStore(string storeName)
{
    string filePath = $"{_fileSystem.RootPath}/{storeName}.{JsonExtension}";
    return new SettingsStore(filePath, _fileSystem, _clockService);
}

12.4 Overload pairs

Convenience overloads fill in a default and delegate to the explicit overload — logic lives in exactly one place, and each overload documents its own behavior:

/// <summary>
/// Append a note to the notebook.
/// Automatically uses current UTC time as the note timestamp.
/// </summary>
public async Task AppendNote(string text) => await AppendNote(text, _clockService.UtcNow);

12.5 Symmetric typed families — a situational tool

When the same capability exists for a small, closed set of value types (ReadBool/ReadLong/ReadDecimal/ReadString), writing every variant explicitly with an identical shape — same parameter order, same doc structure, same body pattern — is a legitimate, deliberate choice, not a failure to abstract. For 2–5 stable variants it buys:

  • Call sites that read naturally with zero generic gymnastics (ReadLong(key) vs Read<long>(key) plus constraints).
  • No boxing or generic dispatch on hot paths with value types.
  • Perfect greppability — every variant is a plain, searchable name.

The price is N edits per shape change, so this is a trade to make consciously:

  • Choose repetition when the family is small, closed (new variants are rare and deliberate), and performance or call-site clarity matters.
  • Choose abstraction (generic methods, a shared base, or source generators) when variants multiply, the shared shape churns often, or the family is open-ended.

Whichever side is chosen, symmetry is non-negotiable: all variants must remain structurally identical, so that reading one means having read them all.

12.6 Bidirectional conversion

Domain types convert to and from their DTOs through a symmetric pair — a constructor taking the DTO and a ToData() method — so serialization never leaks into domain logic.


13. Testing

  • Shared arrangement in an abstract base fixture. The base class owns [SetUp]/ [TearDown], constructs the system under test with real or debug implementations of the module's interfaces, and exposes what tests need as protected properties. Concrete fixtures per type inherit it and contain only tests.

  • Test classes are internal (paired with InternalsVisibleTo), named <Subject>Tests, and mirror the production folder structure.

  • Test method names are scenario phrases in PascalCase, no underscores: EmptyTotal, AddSingleItem, RemoveItem, ClearEverything.

  • Steps are commented with the action and the running expected state, so a failing test is debuggable from the source alone:

    // Add item priced 30 (total=30).
    CartItem item1 = new(UtcNow + 0, 30m);
    await cart.AddItem(item1);
    
    // Add item priced 45 (total=75).
    CartItem item2 = new(UtcNow + 1, 45m);
    await cart.AddItem(item2);
    
    // Remove item1 (total=45).
    await cart.RemoveItem(item1.Id);
    
  • Determinism: capture UtcNow once in setup and derive event times as UtcNow + 0, UtcNow + 1, … so ordering is explicit and repeatable.

  • Round-trip tests verify persistence honestly: write, dispose, recreate from disk, query again, compare.

  • Manual developer tooling (debug commands, sample-data generators, snapshot import/export) lives in the test assembly too, separated in its own folder — never in production code.


14. Review Checklist

Use this as a quick gate before merging (or as a compact style contract in a prompt):

Structure

  • [ ] One type per file; file name equals type name; file is in the folder matching its role.
  • [ ] Namespace mirrors the folder path; folder depth is at most one role level.
  • [ ] No folder or namespace segment shares a name with a type; entry-point types at module root.
  • [ ] Usings are alphabetical and minimal.

Naming

  • [ ] Types carry a role suffix (Data, Factory, Aggregator, Record, …).
  • [ ] Methods start with a vocabulary verb (Get, Query, Push, Create, Mark, …).
  • [ ] Private/protected fields are _camelCase; constants and public fields are PascalCase.
  • [ ] Names are full words; paired concepts have paired names (rangeStart/rangeEnd).

Formatting

  • [ ] Allman braces on every block; 4-space indent; one blank line between members.
  • [ ] No var — explicit types with target-typed new(); explicit foreach/out types.
  • [ ] Expression bodies only for single short expressions; blocks for locking/branching/multi-statement.
  • [ ] Multi-line conditions with trailing &&/||; object initializers for DTOs; interpolation and nameof instead of string concatenation.

Design

  • [ ] Public surface is interface-shaped; collections exposed as read-only views.
  • [ ] Classes sealed unless designed for inheritance; shared logic in abstract base + …Impl.
  • [ ] Immutable facts are readonly structs; key types implement full equality/comparison.
  • [ ] DTOs are logic-free [Serializable] classes with Array.Empty<T>() defaults.
  • [ ] Defaults are named constants; dependencies injected as interfaces into readonly fields.

Documentation

  • [ ] Every public type and member is documented; contracts written once at the topmost declaration; implementations and overrides carry <inheritdoc/>.
  • [ ] Contracts state range semantics, missing-data behavior, and caller obligations.
  • [ ] Precision-critical algorithms narrated with full-sentence comments; simple code is not.
  • [ ] Empty blocks and swallowed exceptions explain themselves; no commented-out code.

Robustness

  • [ ] Guard clauses with early returns; argument errors throw with nameof.
  • [ ] Missing data resolves to documented defaults instead of throwing.
  • [ ] Shared state locked at public boundaries; snapshots taken under lock, used outside.
  • [ ] Dispose idempotent; background loops cancellable with a final flush.
  • [ ] Explicit StringComparison; allocations on hot paths minimized and commented.

Tests

  • [ ] Shared setup in an abstract fixture; scenario-named PascalCase test methods.
  • [ ] Steps commented with running expected values; persistence verified by round-trip.