Guidance for AI coding agents working in this repository.
See CLAUDE.md for deep architecture documentation, plugin contracts, and key design decisions.
- Don't guess, ask clarifying questions — especially when in doubt.
- Read before editing. Always read the full file (or relevant section) before making changes.
- Search before creating. Check if a type, method, or pattern already exists before writing new code.
- Batch independent operations. Identify all targets first, then edit them in parallel.
- Use existing patterns. Copy the structure, naming, and conventions from neighboring files.
- Understand the dependency graph before changing interfaces.
- Ignore backward compability.
- Never commit unless the user explicitly asks.
- Never delete files unless they are provably dead code (zero references across the entire solution).
- Never hardcode secrets, connection strings, or credentials. Use configuration, environment variables, or test fixtures.
- Never suppress warnings with
#pragmaor<NoWarn>unless there is a documented, unavoidable reason.
- Always build after making code changes:
dotnet build RayTree.slnx - Always run relevant tests after changes.
- If tests fail, fix the root cause. Do not modify tests unless the test itself was wrong.
- Run
dotnet format --verify-no-changesif unsure about style compliance.
| Principle | Rule |
|---|---|
| SRP | One reason to change per class. Publisher, subscriber, and tracker are separate. |
| OCP | Extend via new plugin implementations (IOutbox, IQueuePublisher, etc.), never by editing core. |
| LSP | Plugin implementations must be fully substitutable. |
| ISP | Keep interfaces narrow. IQueuePublisher and IQueueConsumer are separate. |
| DIP | Core depends on abstractions, never on concrete plugin types. |
| KISS | Simplest solution that works. No speculative abstractions or unused config knobs. |
| DRY | Shared logic lives in one place. No duplication across publisher and subscriber. |
| YAGNI | Do not add features for hypothetical future needs. |
| Constructor injection | All dependencies declared in the constructor. No property setters or post-construction wiring. |
| Dead code | Remove unused fields, parameters, methods, and classes immediately. |
- Prefer interfaces over abstract classes for plugin contracts.
- Constructor injection for all dependencies. No service locator, no
staticstate, no hidden dependencies. - Avoid
objectparameters in public APIs where possible — use generics or specific types. - Document public APIs with XML doc comments (
/// <summary>) — especially parameters, return values, and exceptions thrown. Keep doc as shorter as possible.
TreatWarningsAsErrors=trueis global — zero warnings are acceptable.- Nullable warnings are errors. Every reference type must be correctly annotated (
?or non-nullable). - New packages must be added to
Directory.Packages.propswith a version;.csprojreferences omit the version attribute. - Do not modify
Directory.Packages.propsversions unless explicitly asked.
- Follow
.editorconfig— it wins over any general suggestion. - Private/internal fields:
_camelCase. Static private/internal fields:PascalCase. Constants:PascalCase. - Expression-bodied members for single-expression methods, properties, and accessors.
usingdirectives outside the namespace; System namespaces first.- Braces always on a new line (
csharp_new_line_before_open_brace = all). - Don't use tuples in public contracts. Use records, classes or structs instead.
- Use method named params, especially - when there are multiple params with the same name.
- All I/O methods must be
asyncand acceptCancellationTokenas the last parameter. Never ignore it. - Never use
async void. Useasync Task. Exception: framework-imposed event handlers. - Never use
.Resultor.Wait()— alwaysawait. - Do not add
ConfigureAwait(false)— omit it everywhere for consistency. - Async methods carry the
Asyncsuffix.
- Catch the most specific exception type available.
- Never
catch (Exception)except at a top-level loop boundary; always log atErrorwith the original exception before continuing or rethrowing. - Never swallow exceptions silently.
- Let
OperationCanceledExceptionpropagate — do not catch it in inner loops. InvalidOperationException→ programmer error (wrong call order, missing config).ArgumentException/ArgumentNullException→ bad caller input.- Prefer
bool-returningTry*methods overtry/catchfor control flow.
- Use
IAsyncDisposablefor types that own async resources (channels, connections, background tasks). - Always
await using/usingat the call site; never callDispose()/DisposeAsync()manually. - Cancel the
CancellationTokenSourcebeforeDispose()on background-loop owners.
- Never enumerate an
IEnumerable<T>more than once — materialise with.ToList()/.ToArray()first. - Public APIs return
IReadOnlyList<T>orIReadOnlyCollection<T>when callers must not mutate. ReturnIAsyncEnumerable<T>for streaming results. UseIEnumerable<T>only when lazy streaming is intentional. - LINQ is for projections, not mutations. Use
foreachfor side-effects. - No more than three chained LINQ operators without an intermediate named variable.
- Use
Span<T>/ReadOnlySpan<T>for synchronous, stack-local slicing. Never store aSpan<T>in a field or closure. - Use
Memory<T>/ReadOnlyMemory<T>when the slice crosses anawaitor must be heap-stored. - Never allocate a new
byte[]to pass a sub-range — use.Slice(),AsSpan(), orAsMemory(). - Prefer
Span<T>overloads ofBinaryPrimitives,MemoryMarshal, andEncodingover array-allocating variants. - Pick one ownership model (
Span<T>orMemory<T>) per logical operation and stay consistent.
- Use
string.Emptyinstead of""for variables. Inline literals in interpolations are fine. - Prefer
$"..."interpolation over+orstring.Concat. - Do not call
.ToString()directly on a nullable — use?.ToString() ?? string.Empty.
NullLoggerFactory.Instance/NullLogger<T>.Instancedefaults belong only in builders and builder-extension methods.- All runtime service classes require a non-nullable
ILoggerFactory/ILogger<T>constructor parameter — no internal fallback. - Each class owns its own
ILogger<Self>(created from the injectedILoggerFactory) so log entries carry the emitter's category and per-category filtering works as expected. Do not pass a sharedILoggerinstance across multiple classes. - Configuration- and lifecycle-time log calls (those added by the
add-tracker-config-loggingchange inChangeTrackingBuilder,EntityBuilder<T>,SharedHandlerBuilder<T>,IsolatedHandlerBuilder<T>, andEntityChangeTracker.InitializeAsync) MUST be guarded withif (_logger.IsEnabled(<level>)) ...soNullLoggerFactoryproduces zero allocations and zero output. - See docs/configuration.md#what-gets-logged for the full per-class log inventory (level, trigger, structured properties).
- Connection-recovery is log-only — there are no connection metrics. The recovery logs live in the runtime
service classes that own the connection (
NotificationBasedPublisher,OutboxPublisherService,KafkaPublisher,KafkaConsumer,RabbitMqPublisher) and use those classes' existing non-nullableILogger<T>. Exception:RabbitMqConsumerhas no logger field (pre-existing exception to the logging-placement rule — message-receive errors silently NACK + requeue with no useful context to log). With the metrics removed it no longer subscribes to the SDK recovery events at all, so its connection disconnect/recovery is not observable — accepted, consistent with its no-logger posture. Each recovery-owning plugin tunes its loop via its own options type —PostgresConnectionRecoveryOptions(PostgreSQL) andKafkaConnectionRecoveryOptions(Kafka); there is no shared Core options type and no Hosting binding.RayTreeMeterexposes no public metric-emission API — all emit methods areinternaland reachable only by Core and IVT-privileged assemblies (NotificationBasedPublisher); its public surface is construct-and-observe only.
- Method names:
MethodUnderTest_Scenario_ExpectedBehaviour(e.g.,WriteAsync_WhenEntityIsNull_ThrowsArgumentNullException) - Structure: Arrange / Act / Assert, separated by blank lines. Add comment at each block beginning.
- One logical outcome per test; multiple
Assertcalls are fine for the same fact. - No shared mutable state between tests in the same class.
- Unit tests must not touch the file system, network, or
DateTime.UtcNow. UseTimeProvideror a clock abstraction. - Integration tests that share a container: mark
[NonParallelizable]and use unique topic/queue names per test. - Test edge cases: null inputs, empty collections, cancellation, exceptions, boundary values.
- Use
Assert.ThrowsAsync<T>()for async exception tests, nottry/catchwithAssert.Fail(). - Mock at the interface level. Use plugin interfaces (
IOutbox,IQueuePublisher, etc.) as mock boundaries.
- Every reference type must be annotated.
stringmeans non-null;string?means nullable. - Initialize required fields in the constructor or with a default value.
- Use
ArgumentNullException.ThrowIfNull()for guard clauses. - Return
Emptycollections instead ofnull. UseArray.Empty<T>(),Enumerable.Empty<T>(), orReadOnlyCollection<T>.Empty. - When in doubt, make it non-nullable and throw
ArgumentNullExceptionif the caller passes null.
src/
RayTree.Core/ # EntityChangeTracker, builder, publisher, subscriber
RayTree.Hosting/ # IHostedService + AddChangeTracking DI extension
RayTree.EntityFrameworkCore/ # SaveChangesAsync interceptor
RayTree.OpenTelemetry/ # OTel SDK wiring (peer assembly)
RayTree.Plugins.PostgreSQL/ # Outbox + Repository + NOTIFY/LISTEN publisher
RayTree.Plugins.InMemory/ # In-process queue (tests / local dev)
RayTree.Plugins.Kafka/ # KafkaPublisher + KafkaConsumer
RayTree.Plugins.RabbitMQ/ # RabbitMqPublisher + RabbitMqConsumer
RayTree.Plugins.Serializers.*/ # JSON, MessagePack, Protobuf
RayTree.Plugins.Compressors.*/ # Gzip, Brotli, LZ4
tests/
RayTree.Core.Tests/ # Unit tests (no Docker)
RayTree.Plugins.InMemory.Tests/ # Unit tests (no Docker)
RayTree.EntityFrameworkCore.Tests/ # Unit tests (no Docker)
RayTree.OpenTelemetry.Tests/ # Unit tests (no Docker)
RayTree.Plugins.Serializers.*.Tests/ # Unit tests (no Docker)
RayTree.Plugins.Compressors.*.Tests/ # Unit tests (no Docker)
RayTree.Plugins.PostgreSQL.Tests/ # Integration tests (Docker)
RayTree.Plugins.RabbitMQ.Tests/ # Integration tests (Docker)
RayTree.Plugins.Kafka.Tests/ # Integration tests (Docker)
# Build (Debug)
dotnet build RayTree.slnx
# Build (Release — used by CI)
dotnet build RayTree.slnx -c Release
# Unit tests (no Docker required)
dotnet test tests/RayTree.Core.Tests
dotnet test tests/RayTree.Plugins.InMemory.Tests
dotnet test tests/RayTree.EntityFrameworkCore.Tests
dotnet test tests/RayTree.OpenTelemetry.Tests
dotnet test tests/RayTree.Plugins.Compressors.{Brotli,Gzip,Lz4}.Tests
dotnet test tests/RayTree.Plugins.Serializers.{Json,MessagePack,Protobuf}.Tests
# Single test by name
dotnet test tests/RayTree.Core.Tests --filter "FullyQualifiedName~<TestName>"
# Integration tests (Docker required)
dotnet test tests/RayTree.Plugins.PostgreSQL.Tests
dotnet test tests/RayTree.Plugins.RabbitMQ.Tests
dotnet test tests/RayTree.Plugins.Kafka.TestsAlways build and run the relevant unit tests before considering a task complete. Integration tests are only required when changing PostgreSQL, Kafka, or RabbitMQ plugin code.
.github/workflows/ci.yml has three job groups: build (compile gate, uploads compiled output as an artifact with
1-day retention), unit-tests (9-way parallel matrix, no Docker, downloads build artifact — no rebuild),
integration-tests (3-way matrix: PostgreSQL / RabbitMQ / Kafka, also downloads build artifact). No job rebuilds the
solution; all test jobs depend on the shared artifact from build.