Thank you for taking the time to contribute.
Clone the repository and make sure you have the prerequisites installed:
- .NET 10 SDK. The solution targets
net10.0 - Docker. Testcontainers-backed integration tests spin up a PostgreSQL container
- Node.js 20+. Required only for the K6 load tests under
tests/Alberto.Orders.LoadTests/
Run the full test suite:
dotnet testTests whose names start with Postgres or that live in a Testcontainers fixture class require a
running Docker daemon. The in-memory and unit tests run without Docker, and are selectable —
every Docker-backed class is tagged [Trait("Category", "Integration")]:
dotnet test tests/Alberto.Tests/Alberto.Tests.csproj --filter "Category!=Integration"That subset runs in a couple of seconds and is the fastest useful feedback loop while working. Keep the trait on any new test class that takes a Postgres fixture: mutation testing selects against it, and an untagged one silently puts a container start into a run that performs thousands of them.
Two numbers gate a PR, and both have a floor CI enforces:
- Line coverage over the shipped packages, from the whole suite.
- Mutation score on the code your branch changed, from Stryker. This is the one that usually needs attention, because a line can be covered by a test that asserts nothing about it.
Check either locally before pushing:
build/coverage.shbuild/mutation-test.sh --since mainNeither is a target to maximise, and a surviving mutant is not automatically a bug — see docs/development/mutation-testing.md for the thresholds, what is measured, and how to triage a survivor that is not worth killing.
- Every pull request must close an issue. Put
Closes #123in the PR description. This is enforced by thepr-policycheck, and it is not decoration. The issue's milestone is what decides which version your change ships in. See Releasing. - The linked issue must carry a milestone naming a real version (
1.1.0,1.0.1). The standingBacklogmilestone is not a valid target; an issue has to be triaged out of it before a PR can close it. If your issue is still inBacklog, ask for it to be scheduled. - Label a PR
breaking-changeif it breaks a consumer, and describe the migration in the PR description: what broke, and the before/after a caller needs.pr-policywill reject the label against a milestone that cannot legally carry a break. That description becomes the release notes, so write it for the person doing the upgrade. - One logical change per pull request.
- All public API additions or removals must be reflected in the project's
PublicAPI.Shipped.txt/PublicAPI.Unshipped.txt. See Public API tracking. A non-emptyPublicAPI.Unshipped.txtis also what tells the release job a minor bump is due. - Do not edit
CHANGELOG.md. The release workflow drafts each section from the milestone's closed issues, and the release PR is where that draft is edited into prose. Hand-editing[Unreleased]on every PR just produces merge conflicts. TreatWarningsAsErrorsis on. The build must be warning-free before a PR can be merged.
Every packable project under src/ carries two tracking files, and
Microsoft.CodeAnalysis.PublicApiAnalyzers fails the build on a public symbol that appears
in neither. The point is that widening the surface Alberto has to support for the life of a major
version is a line in a diff a reviewer can see, rather than something that lands with a feature.
The gate is conditioned on IsPackable (see Packability below), so the three non-packable
projects under src/ are outside it: Alberto.Admin.Postgres and Alberto.Commands.Analyzers
carry no tracking files at all, and Alberto.Admin carries them but is not checked against them.
Its files are there so unparking the admin surface is one property flip rather than a surface
audit. Nothing in those three assemblies is under semver until they are published.
The rules are set to error in the root .editorconfig. Nothing suppresses them. If you find
yourself adding a NoWarn, a .globalconfig, or a project-local .editorconfig to get a build
green, that is the gate working.
Adding public API. Build, then either apply the "Add to public API" fix in the IDE, or run:
dotnet format analyzers src/Alberto/Alberto.csproj --diagnostics RS0016 --severity errorEither way the new entries land in PublicAPI.Unshipped.txt. Read that diff before committing
it. It is the statement of what you are committing to support. (dotnet format does not accept
Alberto.slnx; pass the individual .csproj.)
Removing public API. Delete the entry by hand, label the PR breaking-change, and describe
the migration in the PR description. Removing a shipped entry is a major-version change. See
Releasing.
At release. Move everything from PublicAPI.Unshipped.txt into PublicAPI.Shipped.txt and
leave Unshipped with just its #nullable enable line.
RS0026 / RS0027 flag an added overload with optional parameters. They are evolution
guidelines, not bugs, and are satisfiable by justification when the overloads are separated by a
required parameter or by delegate shape rather than by the optional tail. Where that is the
case, add a [SuppressMessage] with a Justification naming which parameter does the separating.
See the existing ones in DcbModuleBuilderExtensions.ReactTo for the form. Do not reach for
#pragma warning disable, and do not lower the severity.
Packability. Directory.Build.props defaults IsPackable to false; each project under
src/ opts in with <IsPackable>true</IsPackable>. That opt-in is what brings in the package
metadata, SourceLink, and this gate, so a new package needs the property and the two tracking
files, and an app, test, or tool project needs neither.
A handful of interfaces are meant to be implemented outside this repository: someone writing a backend for a database Alberto does not ship, or a store that fits their own operational setup. Public API tracking above records that they widened; it cannot tell you the widening was safe.
The distinction it misses is between the two ways to add an interface member. A member with a default body is invisible to existing implementors. An abstract member breaks every one of them: at compile time when they rebuild, and at load time for anything already deployed against the old interface. There is no way to walk that back short of a major version.
So the rule is: after 1.0, new members on these interfaces ship with a default implementation.
ExtensionPointContractTests in tests/Alberto.Tests enforces it by pinning today's abstract
member set on IEventStoreBackend, IDeadLetterStore, IClaimableDeadLetterStore and
IProjectionRebuildStore. A member added with a default body does not appear in the reflected
abstract set and the test stays green.
If that test fails, in order of preference:
- Give the member a default, in terms of the members that already exist. The test stops seeing it.
- There is no correct default, which means the capability is optional, not universal. Put it
on its own interface and type-test for it at the call site.
IClaimableDeadLetterStoreandIFencedCheckpointStoreare the worked examples: the first exists because atomic claim-and-fence is not something every store can offer, and a default that looked right would have moved the break from compile time to a lost event under contention. - It genuinely has to be required. Update the baseline in the test, label the PR
breaking-change, and describe the migration in the PR description. That is a major-version change; the test exists so you cannot make one by accident.
Interfaces Alberto implements for itself are not in scope here. Widen those freely. If you are unsure which kind you are looking at, ask whether a third party could plausibly have written it.
IAdminReader and IAdminOperator are deliberately absent from the baseline. They are consumed
by the parked front doors on feature/admin-surface and must stay additive so that branch
keeps merging; see the note in CLAUDE.md.
The repo has a root .editorconfig. Its dotnet_diagnostic entries configure the public-API
analyzer described above; it is not a general formatting ruleset. For code style, follow the
surrounding code. Long explanatory comments are encouraged on non-obvious decisions. Do not add
narration comments on self-evident code.
Never turn an IEventEnvelope payload into a typed event object by calling
JsonSerializer.Deserialize, JsonDocument.Parse, or JsonNode.Parse directly.
Always route through EventSerializer.Deserialize.
EventSerializer.Deserialize applies the registered upcaster chain before returning the
typed event. A call to JsonSerializer.Deserialize skips that chain entirely, so a
processor handling a v2 event shape silently receives a v1 shape whenever it reads an old
envelope. The deserialization succeeds: the missing fields are just null or default,
so the bug is invisible until it corrupts projection state or triggers a null-reference
crash in a handler that assumed the field was populated.
This defect was introduced three separate times in three different places, each time with
a green test sitting next to it. The rule is enforced by a source-scanning guard test in
tests/Alberto.Tests/UpcasterBypassGuardTests.cs.
Inject EventSerializer from DI. Both the Postgres and InMemory backends register it as
part of AddAlberto(...):
// Constructor injection in a projection, reactor, or outbox mapper:
public MyProcessor(EventSerializer serializer)
{
_serializer = serializer;
}
// When you have the envelope:
var typedEvent = _serializer.Deserialize(envelope);If you are writing code that deserializes non-event JSON (projection state, metadata
dictionaries, CLI config, outbox payloads) and the file is not already in the guard's
allow-list, add an entry to AllowedFiles in UpcasterBypassGuardTests:
["Alberto.SomeLib/SomeFile.cs"] =
"Deserializes Dictionary<string,string> metadata column, not an event payload.",The justification must name the concrete type (e.g. Dictionary<string,string>,
TState, AlbertoConfig). "Not an event" alone is not sufficient.
Open a draft PR early if you want feedback on the direction. Mark it ready when the CI checks pass and you have addressed any self-review items.