This document describes the coding conventions used in ActualLab.Fusion project that differ from standard .NET conventions.
- The coding style documented here takes precedence over standard .NET conventions, so...
- Follow .NET and C# best practices for code style and structure, BUT if you see a different convention is used here or in the existing source code, stick to it.
- All modern C# language features are preferred over the legacy ones. In particular:
- Use file-scoped namespaces
- Use pattern matching
- Use record types and default constructors
- Use expression-bodied members
- Use field-backed auto-properties and field keyword
- Use nullable reference types
- Use var instead of explicit types
- etc.
- When in Doubt, examine existing code in the same area and match its style.
This section applies to C# and TypeScript equally. Claude has a strong tendency to over-comment; read this section before writing a single comment, docstring, or XML doc. The rules here are deliberately strict.
Default to no comments. Code is the single source of truth. Names, types, and structure should carry the meaning; a comment that merely restates what the code already says doesn't add information — it doubles the reading load and goes stale the moment the code changes underneath it. Stale comments are worse than missing ones, because both Claude and human readers may trust them over the code.
Write a comment only when something is not straightforward to a reasonably experienced developer reading at normal pace (assume "senior, but not extremely senior" — competent but skimming, not studying). The mental test: imagine that reader going through the file fast. If the comment wastes their time because what follows is obvious, drop it. If it saves them time understanding a non-obvious invariant, constraint, workaround, or subtlety they'd likely miss on a quick read, keep it. A comment roughly doubles the text the reader processes for that spot — it has to earn that cost.
Don't document members by default. Typically document the class (or module/file in TypeScript) when its purpose isn't obvious from the name. For an individual member, add a note only when its behavior is unusual: a hidden side effect, a non-obvious precondition, surprising return semantics, a workaround for a specific bug. If you find yourself writing a page of docs on a single method, the method is wrong — rename it, split it, or rework its parameters until the signature carries the meaning.
For methods specifically: the method name plus parameter names should explain what it does. Reach for a comment only when they can't, and only for the part the signature doesn't already carry.
- DO write a
/// <summary>XML doc when the type's purpose isn't obvious from its name. - Keep it short: 5 lines maximum, 3 lines ideal. If a type doc keeps growing, split the type — don't keep writing.
- Use
<see cref="..."/>for cross-references.
- Do NOT write
/// <summary>XML docs on members. Ever. This is stricter than the default .NET guidance.///on members bloats IntelliSense with prose that ages faster than the signature. - If a member genuinely needs explanation (per the philosophy above), use a
regular
//comment.- C#: put the comment at the top of the method body (inside the braces).
- TypeScript: put the comment above the method declaration.
- If the name already explains what the method does, omit the comment — don't restate the signature in English.
- Keep comments short: a single line is almost always enough. Prefer a useful one-liner over a paragraph.
- Regular
//comment (optional, extra context not suitable for API docs) - Empty line (if regular comment is present)
/// <summary>XML documentation#pragmadirectives (if any)- Attributes
- Type declaration
Example — type doc:
// This type is used as an extra parameter of constructors to indicate newly generated Id required
/// <summary>
/// A unit-type constructor parameter indicating that a new identifier should be generated.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public readonly struct Generate : IEquatable<Generate>Example — type doc with <see cref="..."/>:
/// <summary>
/// A thread-safe object pool backed by a <see cref="ConcurrentQueue{T}"/>
/// and a <see cref="StochasticCounter"/> for approximate size tracking.
/// </summary>
public class ConcurrentPool<T> : IPool<T>Example — C# method comment (inside the body):
public Task<bool> SwitchFacing(CancellationToken cancellationToken)
{
// Clears _deviceId so the next state-sync SwitchCamera (which may carry
// a stale deviceId from LocalAppSettings) doesn't no-op.
_deviceId = "";
return _jsRef.InvokeAsync<bool>("switchFacing", cancellationToken).AsTask();
}Example — TypeScript method comment (above the declaration):
// Flips front/back by facingMode so the browser picks the primary lens per facing.
public async switchFacing(): Promise<boolean> {
...
}src/for the source code of ActualLab.Fusion projectssamples/for sample appstests/for test projectsdocs/for documentation
- Maximum line length: 120 characters
- Line endings: use LF (
\n) for all files (not CRLF) - Indent sizes:
- 4 spaces for C#, TypeScript, and CSS code
- 2 spaces for XML, JSON, YAML, and project files (instead of 4).
- Maximum 4 formal parameters on a single line (more restrictive than default)
- Maximum 6 invocation arguments on a single line (more restrictive than default).
- Maximum attribute length for the same line: 70 characters (more restrictive than default)
- Place field attributes on separate lines
- Place accessor holder attributes on separate lines (unless the owner is single-line).
- Follow the project's multi-targeting patterns with conditional compilation.
Directory.Build.props files may define some global usings, such as:
<Using Include="ActualLab" />
<Using Include="ActualLab.Api" />
<Using Include="ActualLab.Async" />Search for <Using> to get the full list. Avoid adding explicit usings for global usings.
- Private static readonly fields and constants: use PascalCase (
ReadonlyField) - All other private fields, including static ones: use underscore prefix with camelCase (
_fieldName) - Async method suffix: Do NOT use
Asyncsuffix for async methods. The only exception is slow-path async methods inside other async methods (e.g.,CompleteAsyncinsideWritemethod that handles the case when the operation cannot complete synchronously).
Mixed brace style that differs from consistent Allman or K&R:
- Classes, methods, constructors: opening brace on next line (Allman style)
- Everything else: opening brace on same line (K&R style)
- Any razor code: opening brace on same line (K&R style).
So in particular, the opening brace must be on same line (K&R style) for the following:
- Properties, accessors, local methods, anonymous methods
- If blocks, case blocks, and all other blocks that could be used inside method bodies
Example:
// Method - brace on next line
public void MethodName()
{
// method body
}
// Property - brace on same line
public string PropertyName {
get => _field;
set => _field = value;
}
// Anonymous method - brace on same line
var action = () => {
// body
};More restrictive than default:
- 0 blank lines inside namespaces (default allows 1)
- 0 blank lines inside types (default allows 1)
- 0 blank lines around single-line properties, fields, and methods
- Keep maximum 1 blank line in code (default allows more)
- A blank line typically follows any
return,break,continue,yield return, oryield breakstatement — i.e. any block-escaping statement — unless it's on the very last line of the enclosing statement block. - Methods whose body ends with one or more local functions typically have an
explicit
return;right before the first local function, followed by a blank line. This marks where the method's actual execution ends and makes the local-function section unambiguous to the reader.
Example:
protected override async Task OnRun(CancellationToken cancellationToken)
{
// ... main body ...
return;
void Helper() {
// ...
}
}- Expression-bodied members: preferred for all member types
including methods and constructors (default only suggests for properties/accessors).
The
=>arrow for one-line methods should be on the same line as return expression, and it's preferred to move it to the dedicated line for class method bodies, but not for property accessors. - Braces for single statements are not required, typically they're used only if the statement is prefixed with a comment, or when it significantly improves the readability.
- Place using directives outside namespace (C# 10+ default is inside).
Members within a class should be ordered as follows:
- Settings-style nested type, if any. The instance of this type is passed to every constructor. Other nested types are placed at the very end of the class.
- Static fields (public readonly, then public, then private)
- Instance fields (private, then internal)
- Instance properties and public fields ()
- Private, then protected properties - typically they are DI injected
- Public properties and fields are located closer to the constructor
- Lazy style is often preferred for DI-injected properties,
especially in the UI-related code.
Use
=> field ??= Services.GetRequiredService<T>(). - Constructor-like static NewXxx-style methods
- Constructors (public, then private), though primary constructors are preferred.
- Public methods, ordered by importance/usage frequency.
- Protected/internal methods.
Use
// Protected/internal methodscomment to separate this section - Private methods, such as helper methods and utilities.
Use
// Private methodscomment to separate this section. - All other nested types.
Use
// Nested typescomment to separate this section.
For typical RPC API (interface):
- Read methods go first.
Typically, these are
[ComputeMethod]methods. - Write methods go next,
Typically, these are
[CommandHandler]methods. - Command handler methods should have
Onprefix (e.g.,OnChange,OnUpdate). - Command handler commands should be declared right after API interface
in the same file. Their names should start with
{InterfaceNameWithoutI}_prefix, e.g.,Chat_EditforIChatinterface.
Special cases:
- API implementation classes should have the same member order as in the API interface.
- DI injected services typically follow more specific to more general
order, so services like
ILoggerare placed at the very end of DI injected member set. - If it's hard to determine the order, use alphabetical order.
Examples:
public class Chats(IServiceProvider services) : IChats
{
// 1. Static fields
public static readonly TileStack<long> ServerIdTileStack = Constants.Chat.ServerIdTileStack;
// 2. Dependency-injected services
private IAccounts Accounts { get; } = services.GetRequiredService<IAccounts>();
private IPlaces Places => field ??= services.GetRequiredService<IPlaces>();
private ICommander Commander { get; } = services.Commander();
private ILogger Log { get; } = services.LogFor<Chats>();
// 3. Public read methods (e.g., compute methods)
public virtual async Task<Chat?> Get(Session session, ChatId chatId, CancellationToken cancellationToken)
{ /* ... */ }
// 4. Public write methods (e.g., command handlers)
// [CommandHandler]
public virtual async Task<Chat> OnChange(Chats_Change command, CancellationToken cancellationToken)
{ /* ... */ }
// Protected methods
// 5. Protected/internal methods
[ComputeMethod]
protected virtual async Task<ReadPositionsStat> GetReadPositionsStatInternal(ChatId chatId, CancellationToken cancellationToken)
{ /* ... */ }
// Private methods
private async Task<PrincipalId> GetOwnPrincipalId(Session session, ChatId chatId, CancellationToken cancellationToken)
{ /* ... */ }
}
public interface IMediaBackend : IComputeService, IBackendService
{
[ComputeMethod]
Task<Media?> Get(MediaId? mediaId, CancellationToken cancellationToken);
[ComputeMethod]
Task<Media?> GetByMediaIdScope(string mediaIdScope, CancellationToken cancellationToken);
[ComputeMethod]
Task<Media?> GetByContentId(string contentId, CancellationToken cancellationToken);
[CommandHandler]
Task<Media?> OnChange(MediaBackend_Change command, CancellationToken cancellationToken);
[CommandHandler]
Task OnCopyChat(MediaBackend_CopyChat command, CancellationToken cancellationToken);
}
[DataContract, MemoryPackable(GenerateType.VersionTolerant)]
// ReSharper disable once InconsistentNaming
public sealed partial record MediaBackend_Change(
[property: DataMember, MemoryPackOrder(0)] MediaId Id,
[property: DataMember, MemoryPackOrder(1)] Change<Media> Change
) : ICommand<Media?>, IBackendCommand, IHasShardKey<MediaId>
{
[JsonIgnore, Newtonsoft.Json.JsonIgnore, IgnoreDataMember, MemoryPackIgnore]
public MediaId ShardKey => Id;
}
[DataContract, MemoryPackable(GenerateType.VersionTolerant)]
// ReSharper disable once InconsistentNaming
public sealed partial record MediaBackend_CopyChat(
[property: DataMember, MemoryPackOrder(0)] ChatId ChatId,
[property: DataMember, MemoryPackOrder(1)] string CorrelationId,
[property: DataMember, MemoryPackOrder(2)] MediaId[] MediaIds
) : ICommand<Unit>, IBackendCommand, IHasShardKey<ChatId>
{
[IgnoreDataMember, MemoryPackIgnore]
public ChatId ShardKey => ChatId;
}- Primary constructors, dependency injection, lazy DI style:
public class Chats(IServiceProvider services) : IChats
{
private IServiceProvider Services { get; } = services;
private IAccounts Accounts { get; } = services.GetRequiredService<IAccounts>();
private IPlaces Places => field ??= Services.GetRequiredService<IPlaces>();
private ICommander Commander => field ??= Services.Commander(); // Rarely needed
private ILogger Log => field ??= Services.LogFor<Chats>(); // Rarely needed
}- API records should be fully serializable, which typically implies the presence of the following attributes:
[DataContract, MemoryPackable(GenerateType.VersionTolerant)]
[method: MemoryPackConstructor, SerializationConstructor, JsonConstructor]
public sealed partial record TextEntry(
[property: DataMember(Order = 0), MemoryPackOrder(0), Key(0)] long LocalId,
[property: DataMember(Order = 1), MemoryPackOrder(1), Key(1)] string Content)
{ }- .ConfigureAwait(false) must be used in all async calls
in service layer code, and .ConfigureAwait(true) is typically needed
in the UI code, if the code after
awaituses instance properties or fields. Otherwise, it could beConfigureAwait(false).
Here is an example of how .ConfigureAwait(false) can be used in the UI code:
public override async Task Require(CancellationToken cancellationToken)
{
var mustBeActive = MustBeActive;
var mustBeAdmin = MustBeAdmin;
// Instance properties are cached, so .ConfigureAwait(false) is fine from here
var account = await Accounts.GetOwn(Session, cancellationToken).ConfigureAwait(false);
if (mustBeAdmin) {
account.Require(AccountFull.MustBeAdmin);
return; // No extra checks are needed in this case
}
if (mustBeActive)
account.Require(AccountFull.MustBeActive);
}-
Do not use
new TaskCompletionSource()directly. UseTaskCompletionSourceExt.New()orTaskCompletionSourceExt.New<T>()instead. -
Two overloads similar to
.ConfigureAwait(...)are used:
.SilentAwait(true/false)awaits a task w/o throwing any exceptions.ResultAwait(true/false)awaits a task and returnsResult<T>w/o throwing any exceptions.
- Prefer
FilePathoverstringfor file paths and file names. UseFilePathfromActualLab.IOinstead of raw strings when working with file paths or file names.FilePathprovides path combination via&and|operators,RelativeTo,DirectoryPath,FileNameWithoutExtension,Extension, and implicit conversion to/fromstring.
| Instead of | Use |
|---|---|
string filePath = "/some/path" |
FilePath filePath = "/some/path" |
Path.Combine(dir, fileName) |
dir & fileName or dir | fileName |
Path.GetFileName(path) |
path.FileName |
Path.GetExtension(path) |
path.Extension |
See ActualLab.IO.FilePath for the full API.
-
Prefer
sealedclasses and records unless inheritance is intended. -
Prefer
LogFor(GetType())overLogFor<T>()for the current type in non-static context. -
Prefer primary constructors for services when acceptable.
Search for <NoWarn> to see the list of disabled warnings.
See .editorconfig for the complete list of silenced analyzer warnings.
TypeScript follows the same flow-control spacing rules as C#:
- Never place a flow-control statement on the same line as its
if,for,while, or similar condition. - A
return,break,continue,throw, oryieldstatement is typically followed by a blank line unless it is the last statement in its enclosing block. - If the flow-control statement is the last statement in a nested block, put the blank line after that block instead, unless the block is the whole method or function body.
TypeScript uses the same member-section comments as .NET:
- Order class members similarly to .NET classes: static fields first, then instance fields/properties, constructor-like setup, public methods, protected/internal-style helpers, private methods, and nested/local types or constants last when applicable.
- Put private helper methods under a
// Private methodssection. - If protected/internal-style helpers are needed, use
// Protected/internal methodsbefore them and keep// Private methodsbelow that section. - Do not create ad hoc alternatives such as
// Helpers,// Utilities, or// Internalswhen the .NET section names apply.
Example:
// Wrong
if (Api._isDotNetRpcConnected === value) return;
// Correct
if (Api._isDotNetRpcConnected === value)
return;
Api._isDotNetRpcConnected = value;