ASP.NET Core Pending state notifications often start with a deceptively simple rule: if the new state is Ready, notify the responsible people. That condition observes a snapshot, not a transition. Once an item is already Ready, editing its title, notes, ranking, or any unrelated field satisfies the same condition again. Users receive repeated messages even though the business event happened only once.
The durable rule compares two values: the state before the update and the state after it. A notification is eligible only when the item crosses from any non-Ready state into Ready. A second concern then appears: the same accepted change may be delivered or evaluated more than once. A stable event identity must suppress those replays without suppressing a genuine later re-entry. This article turns those two concerns into a narrow, testable contract rather than another broad message-queue design.
The source note has been abstracted deliberately. No organization names, application routes, data values, or host details are carried into the examples. The reproducible lab targets .NET 10, keeps its entire working state in memory, and contacts no web site, database, or external service. Its purpose is to prove the decision rule, not to imitate a deployed application.
The core answer: test the boundary, not the snapshot
Short answer: before != Ready && after == Ready means “entered Ready”; Ready -> Ready means “remained Ready” and must not send again. A two-value predicate distinguishes what this operation did from where the item happens to be after the operation.
A snapshot can answer only “where is the item now?” It cannot reveal whether the item just arrived, has remained there for a week, or was reconstructed from a replayed request. A transition is directional. Draft to Ready is an entry. Ready to Ready is a stay. Ready to Done is an exit. Done to Ready is a later re-entry. Each pair carries a different meaning even when two pairs share the same final value.
This distinction exposes incomplete method contracts. A method that receives only the new state does not have enough information to recognize entry. The update path must capture the old state before applying incoming changes, or the persistence operation must compare against the old state explicitly. Reading the property after a tracked object has already been mutated is not equivalent; both apparent values may then be Ready, and the evidence of the boundary has been erased.
Store the old value in an immutable local value before mapping the request. If concurrent writers are possible, include the observed version or state in the update condition. A zero-row update is a conflict to resolve, not permission to send based on stale information. Otherwise, two requests can both observe Draft, both update, and both claim to be the unique transition into Ready.
C# pattern matching can express tests over values, types, and shapes in is, switch, and switch expressions, as documented by Microsoft Learn. A simple Boolean predicate is sufficient here, while a tuple switch can make a larger transition table readable. The important property is centralization: the rule should not be copied independently into a controller, a batch loop, and a maintenance command.
Separate event identity from item identity
Short answer: the transition decides whether a change is eligible, while the event identity decides whether that exact change was already handled. Boundary detection and replay suppression solve different problems and should be tested independently.
An item identity refers to the work item being edited. An event identity refers to one accepted change. The same item can legitimately enter Ready more than once over its lifetime: it enters, later leaves, and later enters again. Those are different events and should produce two notifications. Conversely, the same Draft-to-Ready event may be presented twice after a timeout or process restart. It must retain its identity so that the second presentation produces no additional notification.
The lab uses a HashSet to illustrate this semantic split. The set makes a second call with the same event identity return false. It is intentionally not presented as a multi-process implementation. In-memory state disappears on restart and does not coordinate several application instances. A deployed design can use a durable processed-event record with a uniqueness rule so that “first acceptance” has an atomic outcome. The technology is replaceable; stable identity and uniqueness are the contract.
The order of operations also needs a declared meaning. The sample records every event identity before testing whether it is a target transition. Therefore, an identity represents one immutable event and cannot later be reused with different states. Another system might record only eligible events, but then it must define what happens when the same identity is first observed with a non-target pair and later with a target pair. Do not leave that behavior to incidental code order.
Avoid generating a fresh identity on every retry. Doing so converts one replayed event into a series of apparently new events and defeats the entire guard. The identity should be created where the business change is accepted and should travel unchanged through later attempts. A notification row identity is not necessarily the same thing; it can be generated after the event has already won the first-processing decision.
Preserve the old state in every update path
Short answer: single-item and batch paths must capture a separate before value for each item before applying after. A single old value outside a batch loop cannot describe a mixed collection.
A single-item application service can load the current state, copy it to a local value, validate the requested transition, apply the change, save it, and then submit a neutral notification intent. If optimistic concurrency is used, the save result must be checked before the intent is accepted. A transition computed from an outdated object should not become a notification after another writer has already changed the row.
Batch updates require extra discipline. Suppose three items currently have Draft, Ready, and Done, and a batch request sets all three to Ready. Only the first and third cross into the target. The middle item remains there. Loading one item and reusing its old state for the loop will either notify too many recipients or miss valid entries. Mutating the whole tracked collection before taking snapshots has the same problem because the original values are no longer readily available to ordinary application code.
Capture a compact immutable snapshot for every item: item identity, old state, and concurrency version. Then derive one change event per accepted update. Give each event its own stable identity. The batch can share a command identity for auditing, but that command identity cannot replace per-event identity if individual items can be retried or completed separately.
Keep the rule behind one service boundary. Controllers should parse requests and shape responses. A domain or application component should own allowed transitions. A notification gate should decide target entry and replay status. This separation lets an edit page, a batch endpoint, and a compensating worker call the same rule. If every entry point contains its own if (state == Ready), fixing only one path leaves the others as duplicate-notification gaps.
A useful review question is, “Where did this before value come from?” The answer should identify a read or comparison that occurred before mutation and was validated during persistence. “It came from the entity” is insufficient if the entity had already been mapped from the request. Another useful question is, “Can two writers both claim entry?” The answer should refer to a checked concurrency outcome or an atomic first-processing rule.
Define failure policy without hiding notification loss
Short answer: decide whether notification failure may roll back the main update, then align logging, retry, and replay rules with that decision. Catching an exception can protect the primary operation, but an empty catch converts an observable failure into silent loss.
For informational in-site notifications, it is often reasonable for the business state change to remain saved when notification rendering or delivery fails. That is a policy decision, not a universal law. When that policy is chosen, capture structured diagnostic fields such as event identity, item identity, before state, after state, stage, and outcome. Microsoft documents ASP.NET Core's ILogger API and configurable logging providers, which provide a standard foundation for this observability.
Protecting the primary update does not imply discarding the transition fact. If a later attempt is required, retain enough information to reconstruct the notification and reuse the same event identity. The retry must not invent a new identity. This article intentionally stops short of a generic queue architecture; the narrow requirement is that an eligible transition remains traceable, a retry remains recognizable, and a successful result remains deduplicated.
Check returned results as well as thrown exceptions. A helper may catch an internal failure and return a null or a failure value. A caller that equates “no exception escaped” with success will publish false monitoring data. Prefer an explicit result such as Sent, AlreadyHandled, NotTargetTransition, or Failed. Each result should have one meaning and one corresponding observation.
Logging should preserve these distinctions. NotTargetTransition is normal and can be counted without an error message. AlreadyHandled indicates replay protection working. Failed means the attempt did not complete and may need follow-up. Combining all three under “notification skipped” prevents operators and developers from distinguishing correct suppression from loss.
Keep recipient and content rules outside the transition gate
Short answer: let the state rule emit a neutral intent; let separate components expand recipients, build display content, and validate links. Boundary tests should not depend on personal names, UI routes, or a particular front-end framework.
Recipient selection is a different contract. Resolve an active role or group at processing time rather than embedding individual names in the state service. When a rule intends to find exactly one person or record, use a unique identifier or exact predicate. A fuzzy text match can look harmless with a tiny fixture and later expand one notification to many recipients as similar names accumulate.
Content is another boundary. A useful in-site notification needs a recognizable title and an actionable link, but that link should be checked against the application's registered route table. Define a stable payload shape before serialization, such as a message plus a list of link objects. Test serialization and front-end parsing with synthetic identifiers. None of those concerns should alter the answer to Draft-to-Ready versus Ready-to-Ready.
A neutral NotificationIntent can contain the event identity, item identity, before and after states, and the minimum display fields. A recipient resolver expands the applicable role. A content builder creates the view model. A writer returns an explicit result. This decomposition makes failures classifiable: transition error, recipient error, content error, or write error.
It also makes change safer. A route rename should require content tests, not edits to transition logic. A role-membership change should require recipient tests, not a new state predicate. A new target state should change the transition matrix and its acceptance tests. Keeping these axes independent reduces the chance that a presentation change reintroduces duplicate sending.
Verification: reproduce entry and replay behavior on .NET 10
Short answer: a minimum lab should cover entry, stay, exit, replay, later re-entry, and the final count. This lab was actually built and run with .NET SDK 10.0.400 on Windows. All six checks passed, the final notification count was two, and the program performed zero external writes.
Use the following reproducible procedure:
- Create an empty directory and run
dotnet new console --framework net10.0. Confirm thatdotnet --versionprints10.0.400. ReplaceProgram.cswith the complete listing below; no additional package is required. - Run
dotnet build --configuration Release. Continue only when the real build reports zero warnings and zero errors. An editor's syntax coloring is not a substitute for a compiler result. - Run
dotnet run --configuration Release --no-build. Compare every PASS line and the final total. A missing line, different Boolean value, different count, or nonzero exit code means the contract has not passed. - When extending the matrix, keep the same identity for the replay case and assign a new identity only to a genuinely new re-entry. If every invocation receives a new identity, replay suppression is never exercised.
enum WorkState { Draft, Ready, Done }
sealed record Change(string EventId, WorkState Before, WorkState After);
sealed class NotificationGate
{
private readonly HashSet _processed = [];
public int SentCount { get; private set; }
public bool Apply(Change change)
{
if (!_processed.Add(change.EventId)) return false;
if (change.Before == WorkState.Ready || change.After != WorkState.Ready) return false;
SentCount++;
return true;
}
}
static class Check
{
public static void Equal(T expected, T actual, string name)
{
if (!EqualityComparer.Default.Equals(expected, actual))
throw new InvalidOperationException($"FAIL {name}: expected={expected}, actual={actual}");
Console.WriteLine($"PASS {name}: {actual}");
}
}
static class Program
{
public static void Main()
{
var gate = new NotificationGate();
Check.Equal(true, gate.Apply(new("evt-001", WorkState.Draft, WorkState.Ready)), "enter-target-sends");
Check.Equal(false, gate.Apply(new("evt-002", WorkState.Ready, WorkState.Ready)), "stay-target-does-not-send");
Check.Equal(false, gate.Apply(new("evt-003", WorkState.Ready, WorkState.Done)), "leave-target-does-not-send");
Check.Equal(false, gate.Apply(new("evt-001", WorkState.Draft, WorkState.Ready)), "replay-same-event-does-not-send");
Check.Equal(true, gate.Apply(new("evt-004", WorkState.Done, WorkState.Ready)), "reenter-target-new-event-sends");
Check.Equal(2, gate.SentCount, "exact-total");
Console.WriteLine("RESULT all 6 checks passed; notifications=2; externalWrites=0");
}
}
The observed execution output was:
PASS enter-target-sends: True
PASS stay-target-does-not-send: False
PASS leave-target-does-not-send: False
PASS replay-same-event-does-not-send: False
PASS reenter-target-new-event-sends: True
PASS exact-total: 2
RESULT all 6 checks passed; notifications=2; externalWrites=0
The test does not start ASP.NET Core, issue HTTP requests, or connect to persistence. It proves the pure gate and an in-process replay set only. It does not prove multi-instance atomicity, transaction behavior, recipient expansion, serialization, or browser presentation. Those require integration checks with isolated synthetic data and a durable uniqueness boundary.
For a persistence integration test, submit the same event concurrently from two execution paths and assert that exactly one processed-event record is created. Then assert that exactly one notification intent exists. Checking only two successful HTTP responses is insufficient: both callers may receive an acceptable response while the side-effect count is wrong.
Build a transition matrix and regression suite
Short answer: record before state, after state, replay status, expected count, and reason for every case, then require every update path to pass the same matrix. Behavior-oriented names survive refactoring better than tests named after one private method.
| before | after | event condition | expected result | reason |
|---|---|---|---|---|
| Draft | Ready | first presentation | send once | enters target boundary |
| Ready | Ready | first presentation | do not send | remains in target |
| Ready | Done | first presentation | do not send | exits target |
| Draft | Ready | same identity replayed | do not send again | already handled |
| Done | Ready | new event | send once | later target entry |
Add a mixed batch fixture. Start three synthetic items in Draft, Ready, and Done, then set all three to Ready. Exactly two intents should be created. Replay all three events with the same identities and keep the count at two. Next, create a genuinely new Done-to-Ready event and verify that it can increment the count. This sequence catches three defects at once: using one old value for a batch, checking only the new value, and generating a new identity for each retry.
A durable implementation also needs a concurrency case. Launch two paths with the same immutable event. Exactly one should acquire the unique processed-event record and continue. The other should receive AlreadyHandled rather than a generic failure. Read back both the processed count and notification count. A method returning true is not sufficient evidence unless true has a precise, documented meaning.
Track operational outcomes in separate categories: NotTargetTransition, FirstAccepted, ReplaySuppressed, and Failed. A large NotTargetTransition count can be perfectly normal because most edits do not cross the boundary. A rising ReplaySuppressed count may indicate upstream retries. A Failed count deserves investigation. One blended success percentage would hide all three stories.
Regression tests should also confirm that unrelated field changes cannot trigger a notification while the state stays Ready. This is more expressive than calling the gate directly with the same state because it reflects the original failure mode: a valid edit became an accidental business event. At the service level, change a synthetic title, preserve Ready, save, and assert that no new intent appears.
Official references and evidence limits
Short answer: Microsoft documentation supports the C# condition syntax, ASP.NET Core logging foundation, and .NET unit-test workflow; the product-specific boundary and replay policy comes from the explicit acceptance cases in this guide. Documentation cannot decide when a particular application should notify its users, so that rule must remain visible in tests.
- C# pattern matching overview explains how patterns test values, types, and shapes in
is,switch, and switch expressions. - Logging in .NET and ASP.NET Core documents
ILogger, built-in providers, and provider configuration. - Get started with C# and MSTest demonstrates creating a test project and running tests with
dotnet test.
The isolated program uses a small custom assertion so it can run without adding packages and so its output is easy to inspect. A maintained application can move the same matrix into MSTest or its existing framework. The acceptance criteria remain unchanged: send only when crossing into the target state, do not send on stay or exit, do not increment on the same event replay, and allow a later genuine re-entry with a new event identity.
This focus is intentionally narrower than a general message-delivery system and different from a publishing workflow state machine. It addresses the point where an ordinary ASP.NET Core update becomes a user-facing notification. Keeping that scope narrow makes the rule teachable, the test deterministic, and failures attributable to a specific boundary.