<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ivan Rossouw</title>
    <description>The latest articles on DEV Community by Ivan Rossouw (@iqtechsolutions).</description>
    <link>https://dev.to/iqtechsolutions</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4035800%2F9c84067b-fe9a-46e7-8144-5b05a4ffb504.png</url>
      <title>DEV Community: Ivan Rossouw</title>
      <link>https://dev.to/iqtechsolutions</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/iqtechsolutions"/>
    <language>en</language>
    <item>
      <title>Don’t Turn Delegated Access Into an Identity Oracle</title>
      <dc:creator>Ivan Rossouw</dc:creator>
      <pubDate>Mon, 12 Oct 2026 05:59:37 +0000</pubDate>
      <link>https://dev.to/iqtechsolutions/dont-turn-delegated-access-into-an-identity-oracle-59bn</link>
      <guid>https://dev.to/iqtechsolutions/dont-turn-delegated-access-into-an-identity-oracle-59bn</guid>
      <description>&lt;p&gt;Delegated access often starts with an innocent mismatch.&lt;/p&gt;

&lt;p&gt;A person knows an email address. The authorization system knows an account identifier. The obvious bridge is a search box: enter the address, find the account, then grant access.&lt;/p&gt;

&lt;p&gt;That bridge can become a privacy leak.&lt;/p&gt;

&lt;p&gt;If the screen behaves differently for a known address and an unknown one, every attempt answers a sensitive question: “Does this person have an account here?” An authenticated caller can repeat that question across many addresses. The feature may be correctly authorized for its intended task while still exposing more identity information than the task requires.&lt;/p&gt;

&lt;p&gt;The deeper lesson is simple: account discovery is not a necessary prerequisite for delegated access. Identity can be resolved later, at the moment the recipient proves ownership and gives consent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The convenient design carries a hidden query
&lt;/h2&gt;

&lt;p&gt;Consider a generic workflow in which one person wants to share a limited capability with another.&lt;/p&gt;

&lt;p&gt;The direct version usually looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Enter the recipient’s address.&lt;/li&gt;
&lt;li&gt;Search for a matching account.&lt;/li&gt;
&lt;li&gt;Show whether a match exists.&lt;/li&gt;
&lt;li&gt;Create access against that account.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Authorization to share the resource does not make the lookup harmless. Even subtle differences disclose the answer: different text, a disabled button, a profile preview, an HTTP status, or noticeably different timing.&lt;/p&gt;

&lt;p&gt;This is a useful design-review question: what does the caller need to accomplish, and what does the interface reveal merely because it is convenient?&lt;/p&gt;

&lt;h2&gt;
  
  
  Move identity resolution to acceptance
&lt;/h2&gt;

&lt;p&gt;An invitation workflow changes the order of proof.&lt;/p&gt;

&lt;p&gt;The initiator provides an address and the intended scope. The system records an invitation and returns the same public outcome whether the address currently belongs to an account or not. Nothing attempts to answer the account-existence question.&lt;/p&gt;

&lt;p&gt;Most importantly, the invitation itself grants no access.&lt;/p&gt;

&lt;p&gt;When the recipient follows the invitation, they authenticate. The system compares the account’s confirmed address with the invited address. Only a match may inspect the offer and accept it. The access record is then created against the identity that actually accepted.&lt;/p&gt;

&lt;p&gt;That ordering gives the workflow better semantics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the initiator names a person in a form they genuinely know;&lt;/li&gt;
&lt;li&gt;the system does not reveal whether an account already exists;&lt;/li&gt;
&lt;li&gt;possession of a link is insufficient on its own;&lt;/li&gt;
&lt;li&gt;the recipient proves control of the invited address;&lt;/li&gt;
&lt;li&gt;access records consent rather than an assumption made by the initiator.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A token is necessary, but not sufficient
&lt;/h2&gt;

&lt;p&gt;Invitation links are bearer secrets, so their handling deserves the same care as password-reset links.&lt;/p&gt;

&lt;p&gt;Persist a one-way hash rather than the raw token. Submit the token in a request body when practical so routine URL logging does not repeat it across server, proxy, and browser records.&lt;/p&gt;

&lt;p&gt;Bind the token to the intended identity as well. A forwarded or screenshotted link should not be enough to inspect the offer. Requiring a signed-in account with the matching confirmed address contains that failure mode.&lt;/p&gt;

&lt;p&gt;Resending needs a clear contract: mint a new token and invalidate the old one. Otherwise each resend increases the live secrets while the interface still shows one invitation.&lt;/p&gt;

&lt;p&gt;Finally, build the link from trusted server configuration. Request-controlled host information should not decide where a live token is sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry safety belongs in the workflow
&lt;/h2&gt;

&lt;p&gt;Mobile networks fail at awkward boundaries. The server may commit an invitation and send the message while the response never reaches the device.&lt;/p&gt;

&lt;p&gt;If pressing the button again creates a fresh invitation, one uncertain response can produce two live tokens. The interface may show only one of them.&lt;/p&gt;

&lt;p&gt;A stable command identity lets the server recognize a replay and return the original result without sending again. That identity must describe the request as well as the button press: reusing it with different arguments should be rejected, not silently mapped to an unrelated result.&lt;/p&gt;

&lt;p&gt;Delivery outcomes need honest language. A definite rejection differs from an unknown result after handoff. “Unknown” is safer than encouraging a duplicate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make consent and access one atomic transition
&lt;/h2&gt;

&lt;p&gt;Acceptance changes two facts: the invitation is consumed, and access is created.&lt;/p&gt;

&lt;p&gt;Persist those facts together.&lt;/p&gt;

&lt;p&gt;If access is written but the invitation remains pending, the token may be accepted again. That can create a second grant that nobody sees. Revoking the visible grant would leave the duplicate active.&lt;/p&gt;

&lt;p&gt;One transaction makes the invariant explicit: there is no state in which acceptance succeeded while the invitation is still redeemable.&lt;/p&gt;

&lt;p&gt;Re-check the initiator’s authority inside this transition too. Authority can change between invitation and acceptance. An invitation should not outlive the permission that allowed it to be created.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the negative space
&lt;/h2&gt;

&lt;p&gt;The most valuable tests are not only happy-path checks. They prove what the workflow refuses to reveal or create:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;known and unknown addresses produce indistinguishable initiator results;&lt;/li&gt;
&lt;li&gt;an invitation alone authorizes nothing;&lt;/li&gt;
&lt;li&gt;a different or unconfirmed account cannot inspect or accept it;&lt;/li&gt;
&lt;li&gt;retrying does not send again;&lt;/li&gt;
&lt;li&gt;resending invalidates the previous token;&lt;/li&gt;
&lt;li&gt;stale initiator authority blocks new access;&lt;/li&gt;
&lt;li&gt;repeated acceptance does not create duplicate grants;&lt;/li&gt;
&lt;li&gt;consumption and access creation succeed or fail together.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The trade-off is deliberate complexity
&lt;/h2&gt;

&lt;p&gt;This design adds states, expiry, delivery handling, retry receipts, token rotation, concurrency, and tests. It is unquestionably more work than a directory lookup followed by an insert.&lt;/p&gt;

&lt;p&gt;The added complexity corresponds to uncertainty the simpler design hides. The invitation makes privacy, consent, identity proof, and atomicity explicit.&lt;/p&gt;

&lt;p&gt;The practical takeaway is to inspect every workflow that translates a human-readable identifier into an account. Ask whether the caller truly needs account discovery, or whether identity can be resolved by the recipient at acceptance.&lt;/p&gt;

&lt;p&gt;Often, the safer flow is also the more truthful one.&lt;/p&gt;

</description>
      <category>security</category>
      <category>dotnet</category>
      <category>architecture</category>
      <category>testing</category>
    </item>
    <item>
      <title>When the Tool Cannot See the Whole Model</title>
      <dc:creator>Ivan Rossouw</dc:creator>
      <pubDate>Sun, 11 Oct 2026 06:52:43 +0000</pubDate>
      <link>https://dev.to/iqtechsolutions/when-the-tool-cannot-see-the-whole-model-1p9p</link>
      <guid>https://dev.to/iqtechsolutions/when-the-tool-cannot-see-the-whole-model-1p9p</guid>
      <description>&lt;p&gt;Modular architecture creates an uncomfortable class of failure: the application can understand its complete model while design-time tooling sees only a legitimate subset.&lt;/p&gt;

&lt;p&gt;That difference is easy to misread as drift. It is also dangerous to dismiss. The useful engineering move is to identify exactly what the tool cannot observe, then relocate each safety check to a boundary with enough context to answer the question correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The warning was real, but its conclusion was wrong
&lt;/h2&gt;

&lt;p&gt;Consider a shared EF Core context that can be extended by optional runtime modules. Each module contributes mappings without forcing the lower-level persistence package to reference every higher-level feature package. That dependency direction is valuable: the shared module remains reusable, and optional features remain optional.&lt;/p&gt;

&lt;p&gt;At runtime, the host loads the contributing modules and the complete model is assembled. At design time, the tooling process starts from a narrower package. Loading one contributor directly would create a dependency cycle, so that contributor is absent.&lt;/p&gt;

&lt;p&gt;The migration snapshot still represents the complete model. The design-time model does not. A comparison therefore reports a difference and may propose removing an object that is not obsolete at all; it is merely invisible from that process.&lt;/p&gt;

&lt;p&gt;The warning accurately says, “these two views differ.” It cannot establish why they differ.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate database update from migration authoring
&lt;/h2&gt;

&lt;p&gt;The first mistake would be treating every operation as though it asks the same question.&lt;/p&gt;

&lt;p&gt;A database update applies migrations that have already passed review and validation. If a known model subset causes a framework warning to block that operation, a narrowly scoped suppression can be reasonable. The important word is narrowly. The exception belongs in the design-time path that has the structural blind spot, with an explanation of why the complete model cannot be assembled there.&lt;/p&gt;

&lt;p&gt;Migration authoring is different. A scaffolder turns its limited view into proposed schema changes. If it cannot see an extension-owned table, a removal operation may look perfectly logical. Suppressing a warning does nothing to make that proposal safe.&lt;/p&gt;

&lt;p&gt;So the second boundary needs a stronger invariant: inspect the generated migration operations and fail if they remove, rename, or destructively alter protected extension data. That converts a warning in prose into an executable gate.&lt;/p&gt;

&lt;p&gt;This distinction matters. “Allow the known update path” and “trust every future generated migration” are not the same decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep a complete-model drift check
&lt;/h2&gt;

&lt;p&gt;Once a noisy comparison is suppressed, the obvious risk is hiding real drift. The answer is not to pretend the partial view became authoritative. Keep a separate test that runs in a host where all contributors are loaded, builds the complete runtime model, and compares that model with the snapshot.&lt;/p&gt;

&lt;p&gt;Now each check has one clear responsibility:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the design-time update path tolerates the known subset;&lt;/li&gt;
&lt;li&gt;the full-model test detects genuine model-to-snapshot drift;&lt;/li&gt;
&lt;li&gt;the migration-operation test blocks destructive scaffolding.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is more precise than relying on one framework signal for three different questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove the guard is not vacuously green
&lt;/h2&gt;

&lt;p&gt;Negative tests deserve a positive control.&lt;/p&gt;

&lt;p&gt;Imagine a test that scans every migration and asserts that none removes a protected table. It passes. Good news, perhaps. But it also passes if migration discovery silently returns an empty collection.&lt;/p&gt;

&lt;p&gt;Add a companion assertion that the scan finds the migration that originally created the protected object. If discovery breaks, the positive control fails. The pair proves both that the dangerous operation is absent and that the evidence set is present.&lt;/p&gt;

&lt;p&gt;This pattern generalizes beyond migrations. Any test that says “no forbidden item exists” should make you ask whether it would still pass when the scanner found nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shared process state is another hidden boundary
&lt;/h2&gt;

&lt;p&gt;The same change exposed a smaller reliability lesson. Tests for design-time factories temporarily set a process-wide configuration value. Separate test classes ran concurrently, and one could restore the value while another was still using it. Each class passed alone; together they failed intermittently.&lt;/p&gt;

&lt;p&gt;The repair was not another retry. The tests were placed in one non-parallel collection because they share mutable process state.&lt;/p&gt;

&lt;p&gt;That is the same ownership principle in miniature: the scope of the resource determines the scope of coordination. A process-wide variable cannot safely be treated as test-local state.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-off is explicit complexity
&lt;/h2&gt;

&lt;p&gt;This approach is not free. There is now a documented exception, a complete-model drift test, a migration-operation guard, a positive control, and serialized tests around shared environment state. Future maintainers must understand why those pieces exist.&lt;/p&gt;

&lt;p&gt;But the alternative is false simplicity: a generic warning that blocks legitimate work, generated operations that can target valid data, and flaky tests that disguise the real contract.&lt;/p&gt;

&lt;p&gt;The practical rule I keep is this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When tooling has a structural blind spot, narrow the exception and move safety to executable checks that can observe the complete truth.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Do not silence evidence broadly. Do not force an architectural dependency merely to satisfy a tool. Give each check the context it needs, and make dangerous assumptions fail before they become database changes.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>efcore</category>
      <category>testing</category>
      <category>architecture</category>
    </item>
    <item>
      <title>The Async Result Belongs to the Snapshot That Started It</title>
      <dc:creator>Ivan Rossouw</dc:creator>
      <pubDate>Sat, 10 Oct 2026 06:01:01 +0000</pubDate>
      <link>https://dev.to/iqtechsolutions/the-async-result-belongs-to-the-snapshot-that-started-it-3gnl</link>
      <guid>https://dev.to/iqtechsolutions/the-async-result-belongs-to-the-snapshot-that-started-it-3gnl</guid>
      <description>&lt;p&gt;Modern client components are long-lived, reactive, and full of asynchronous work. That combination creates a subtle class of defect: a response can be factually correct for the request that produced it, yet wrong for the screen that exists when it arrives.&lt;/p&gt;

&lt;p&gt;This is especially important when a component mixes route parameters with identity-scoped state. The user can navigate while a request is in flight. Authentication can resolve, refresh, or change. The component instance may survive both events. Unless the component tracks which context owns each result, an older task can update a newer view.&lt;/p&gt;

&lt;p&gt;The core rule is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;An awaited result belongs to the input snapshot that started it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That snapshot is more than a component reference. It can include the current route value, the current authentication-state operation, and a monotonically increasing generation number.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the race forms
&lt;/h2&gt;

&lt;p&gt;Imagine a detail page that loads an item, then asks whether the current viewer has reacted to it. The page shows a highlighted action when the answer is yes.&lt;/p&gt;

&lt;p&gt;The component begins loading item A for identity A. Before the viewer-specific request returns, the user signs out and another identity becomes current—or the same component navigates to item B. The old request then completes with a truthful answer: identity A did react to item A.&lt;/p&gt;

&lt;p&gt;If the continuation writes directly to a shared &lt;code&gt;isActive&lt;/code&gt; field, that truth is now displayed in the wrong context. Item B may appear active, or identity B may see state derived from identity A.&lt;/p&gt;

&lt;p&gt;This is not merely a cosmetic race. Identity-derived state can influence which actions are enabled, what ownership badges appear, and which command a user can submit next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make ownership explicit
&lt;/h2&gt;

&lt;p&gt;A practical pattern is to treat every meaningful input change as a new generation.&lt;/p&gt;

&lt;p&gt;When a route or authentication input changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Increment the generation.&lt;/li&gt;
&lt;li&gt;Capture the route and authentication input in local variables.&lt;/li&gt;
&lt;li&gt;Clear state derived from the previous identity.&lt;/li&gt;
&lt;li&gt;Disable identity-scoped actions while the new identity is unresolved.&lt;/li&gt;
&lt;li&gt;After each &lt;code&gt;await&lt;/code&gt;, confirm that the generation and captured inputs are still current.&lt;/li&gt;
&lt;li&gt;Apply results only when those checks pass.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The checks matter after every awaited boundary, not only at the end. A component may load the main item, related content, entitlement state, reaction state, and cart state in sequence. Any await is a point where navigation or identity can change.&lt;/p&gt;

&lt;p&gt;Cancellation tokens can reduce wasted work, and they are worth using when the underlying operation supports cancellation. But cancellation alone is not the ownership proof. Cancellation can race with completion, and some dependencies cannot cancel promptly. The continuation still needs to ask, “Does this result belong to the current snapshot?”&lt;/p&gt;

&lt;h2&gt;
  
  
  Fail closed while identity is unknown
&lt;/h2&gt;

&lt;p&gt;There is another important state between old identity and new identity: unresolved identity.&lt;/p&gt;

&lt;p&gt;Keeping the old viewer-specific UI visible during that interval is convenient, but unsafe. The component should clear ownership badges, active reactions, and similar derived state before awaiting the new authentication result. Any action that depends on identity should be disabled until the new snapshot is ready.&lt;/p&gt;

&lt;p&gt;That brief disabled state is honest. The application does not yet know which identity owns the next command.&lt;/p&gt;

&lt;p&gt;This principle also prevents an action from being sent using newly refreshed credentials while the visible UI still describes the previous viewer. Read state and write authority move together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the interleaving, not only the outcome
&lt;/h2&gt;

&lt;p&gt;Ordinary happy-path tests rarely expose this defect because requests finish in the same order they started.&lt;/p&gt;

&lt;p&gt;A stronger component test controls the schedule:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Start a viewer-specific read and hold its response.&lt;/li&gt;
&lt;li&gt;Change the route or authentication identity.&lt;/li&gt;
&lt;li&gt;Wait for the new screen to settle.&lt;/li&gt;
&lt;li&gt;Release the old response.&lt;/li&gt;
&lt;li&gt;Assert that the current screen does not adopt the old result.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A separate test should hold authentication unresolved and verify that identity-scoped actions remain disabled. This proves that the UI fails closed during the transition.&lt;/p&gt;

&lt;p&gt;There is also a useful counter-test: rerender with exactly the same inputs while a valid request is pending. The component should not discard or duplicate work merely because the framework rendered again. The boundary is a meaningful input change, not every render cycle.&lt;/p&gt;

&lt;p&gt;These tests are valuable because they verify time and ownership, not just a final value. They turn a race that might appear once in a thousand interactions into a deterministic scenario.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-off
&lt;/h2&gt;

&lt;p&gt;Generation markers, captured snapshots, and repeated currentness checks add code. Temporarily disabling an action can introduce a small pause. Overusing the pattern can also make a component difficult to read.&lt;/p&gt;

&lt;p&gt;The answer is not to attach a counter to every asynchronous line. Use the pattern where results are scoped to changing inputs: routes, selected records, authentication identity, workspace, locale, or another boundary that can change while the component remains mounted. Extract a small helper when the same ownership rule appears repeatedly.&lt;/p&gt;

&lt;p&gt;The payoff is larger than visual correctness. Explicit async ownership reduces stale-route updates, prevents state from crossing identity boundaries, and stops commands from being enabled under an unresolved context.&lt;/p&gt;

&lt;p&gt;The humble takeaway is this: asynchronous correctness is not only about receiving the right answer. It is also about proving that the answer still belongs here.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>blazor</category>
      <category>csharp</category>
      <category>testing</category>
    </item>
    <item>
      <title>A Row Version Is a Cross-Layer Contract</title>
      <dc:creator>Ivan Rossouw</dc:creator>
      <pubDate>Sat, 03 Oct 2026 06:23:50 +0000</pubDate>
      <link>https://dev.to/iqtechsolutions/a-row-version-is-a-cross-layer-contract-398n</link>
      <guid>https://dev.to/iqtechsolutions/a-row-version-is-a-cross-layer-contract-398n</guid>
      <description>&lt;p&gt;A concurrency column can exist in the database and still be wrong.&lt;/p&gt;

&lt;p&gt;The name may match. The migration may have run. Inserts may appear healthy. Yet optimistic concurrency can still be weaker than the application assumes because the runtime model, the physical schema, and the test environment disagree about what that column means.&lt;/p&gt;

&lt;p&gt;The lesson is broader than one ORM feature: database-generated values are cross-layer contracts. Treating them as a property of only the entity class or only the table leaves a gap exactly where correctness matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the contract actually contains
&lt;/h2&gt;

&lt;p&gt;For a database-generated row version, at least five facts need to agree:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the physical type has true row-version semantics;&lt;/li&gt;
&lt;li&gt;the value is non-nullable;&lt;/li&gt;
&lt;li&gt;the database generates it on insert and update;&lt;/li&gt;
&lt;li&gt;the ORM marks it as a concurrency token;&lt;/li&gt;
&lt;li&gt;existing values remain intact during alignment or deployment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A binary column of the expected length is not automatically equivalent. A nullable token changes the invariant. A model that knows about concurrency but not database generation can try to write a value the database owns. Conversely, a correctly generated database column provides little application protection if the runtime model never includes it in optimistic-concurrency checks.&lt;/p&gt;

&lt;p&gt;The useful mental model is not “there is a column.” It is “every layer agrees who owns this value and how a stale write is detected.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Prefer verification over destructive repair
&lt;/h2&gt;

&lt;p&gt;Suppose the shipped database already has the correct non-null generated token, but the current EF Core model describes it incompletely. A tempting fix is to alter or recreate the column until the migration history looks tidy.&lt;/p&gt;

&lt;p&gt;That can be the riskiest part of the change. Replacing a generated token can touch every row, invalidate values held by active writers, or disguise an unexpected schema. The safer move is often smaller:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;align the runtime mapping with the physical contract;&lt;/li&gt;
&lt;li&gt;add a forward-only migration that asserts the exact expected shape;&lt;/li&gt;
&lt;li&gt;refuse to proceed when the real schema differs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This kind of assertion migration changes no business data. It asks the database whether the table, column, type, length, nullability, and generation semantics match the known contract. Running it twice should be safe. Encountering a missing, nullable, or look-alike column should fail clearly.&lt;/p&gt;

&lt;p&gt;Failing a rollout is inconvenient. Quietly weakening stale-write protection is worse. A fail-closed migration turns unknown drift into an explicit operational decision instead of an accidental data-integrity change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test at three different levels
&lt;/h2&gt;

&lt;p&gt;No single test provider can prove the whole contract efficiently. A layered suite gives each test one honest job.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Model metadata
&lt;/h3&gt;

&lt;p&gt;A cheap unit test can inspect EF Core metadata. It should verify that the property is required, marked as a concurrency token, and generated on add or update. This catches accidental mapping changes quickly, without starting a database.&lt;/p&gt;

&lt;p&gt;Metadata tests do not prove the database behaves that way. They prove the application describes its expectation correctly.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Lightweight application flows
&lt;/h3&gt;

&lt;p&gt;In-memory providers are useful for service and orchestration tests, but they do not automatically behave like a relational engine with generated row versions. Once a token becomes required, fixtures may fail because no value is produced.&lt;/p&gt;

&lt;p&gt;A narrowly scoped, test-only save interceptor can generate a fresh fixed-length token for inserted or modified entities. That keeps fast tests representative of the required-value lifecycle. It should be described honestly as an emulator, not provider parity.&lt;/p&gt;

&lt;p&gt;The emulator proves that application code can carry changing tokens through its flows. It cannot prove the SQL type, migration assertion, or actual stale-write mechanics.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A focused real-database proof
&lt;/h3&gt;

&lt;p&gt;The decisive test uses the real provider. Create the shipped shape, insert a synthetic row, and retain its generated token. Apply the alignment migration—ideally twice—and verify that both the row and original token survive.&lt;/p&gt;

&lt;p&gt;Then load the same row through two contexts. Save through the first and confirm its token changes. Attempt to save the stale copy through the second. The expected concurrency exception is the behavioural proof that the contract works end to end.&lt;/p&gt;

&lt;p&gt;Negative cases matter too. A missing table, missing column, nullable token, or ordinary binary column should be refused. These cases prevent a permissive assertion from becoming ceremonial.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the expensive proof on the required path
&lt;/h2&gt;

&lt;p&gt;A real-database test that developers can skip forever is documentation, not a gate. Register the small provider-faithful suite in the required CI job and include the relevant file scopes in change detection.&lt;/p&gt;

&lt;p&gt;This does not mean every application test should use a full database. That would make feedback slower and fixtures harder to maintain. The trade-off is deliberate separation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;metadata tests are cheap and broad;&lt;/li&gt;
&lt;li&gt;in-memory tests exercise application flows;&lt;/li&gt;
&lt;li&gt;real-database tests prove database-owned behaviour.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The suite costs more than one happy-path unit test. In return, failures become easier to interpret because each layer states what it can and cannot guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical review checklist
&lt;/h2&gt;

&lt;p&gt;When reviewing a database-generated concurrency token, ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does the runtime model match the shipped type, nullability, and generation rules?&lt;/li&gt;
&lt;li&gt;Does the migration preserve rows and existing token values?&lt;/li&gt;
&lt;li&gt;Does unexpected schema fail closed?&lt;/li&gt;
&lt;li&gt;Do fast fixtures represent the required token lifecycle without claiming provider equivalence?&lt;/li&gt;
&lt;li&gt;Does a real-provider test prove token change and stale-write rejection?&lt;/li&gt;
&lt;li&gt;Is that proof part of required CI?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The broader engineering habit is simple: when a value is owned by the database, verify the contract where each promise is made. Model tests describe intent. Fast tests protect application flow. Only the real provider can prove the final integrity boundary.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>testing</category>
      <category>database</category>
      <category>efcore</category>
    </item>
    <item>
      <title>Project the Response, Not the Cache</title>
      <dc:creator>Ivan Rossouw</dc:creator>
      <pubDate>Thu, 01 Oct 2026 05:48:18 +0000</pubDate>
      <link>https://dev.to/iqtechsolutions/project-the-response-not-the-cache-42p5</link>
      <guid>https://dev.to/iqtechsolutions/project-the-response-not-the-cache-42p5</guid>
      <description>&lt;p&gt;A response can be correct for one caller and still leave the application in a worse state for everyone who follows.&lt;/p&gt;

&lt;p&gt;This is an easy trap in ASP.NET Core when a result filter, formatter, or middleware needs to change a few fields before a response leaves the server. Localization is a useful example: a DTO contains source-language text, the current request asks for another language, and the pipeline replaces selected strings before serialization.&lt;/p&gt;

&lt;p&gt;The first response looks right. The implementation may even be pleasantly small.&lt;/p&gt;

&lt;p&gt;The danger is ownership. If the returned DTO came from an in-process cache, the response pipeline may be holding the same object instance that another request, background job, or command path will read later. What looked like response formatting was actually a write to shared state.&lt;/p&gt;

&lt;p&gt;The broader lesson is this: request-specific presentation should be a projection, not a mutation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bug Hides Behind a Successful Response
&lt;/h2&gt;

&lt;p&gt;Imagine a cached catalogue item with a description in the system’s source language. A request asks for a translated version. The tempting implementation walks the object graph and assigns the translated string to the DTO property.&lt;/p&gt;

&lt;p&gt;The response is translated correctly. A test that checks only that response passes.&lt;/p&gt;

&lt;p&gt;Now consider the next reader. If the cache returns the same instance, the cached item is no longer in its authoritative source form. A source-language request may receive translated text. A command that maps the DTO back into another model may persist presentation text as source data. A background process may observe a value chosen for somebody else’s request.&lt;/p&gt;

&lt;p&gt;Nothing about the first HTTP response reveals this. The defect is temporal: the observable failure belongs to a later reader.&lt;/p&gt;

&lt;p&gt;That is why “the output is correct” is not a sufficient assertion when inputs can be shared.&lt;/p&gt;

&lt;h2&gt;
  
  
  Draw the Ownership Boundary Explicitly
&lt;/h2&gt;

&lt;p&gt;A useful model is to separate three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The cached or domain DTO is authoritative input.&lt;/li&gt;
&lt;li&gt;Request context selects presentation rules.&lt;/li&gt;
&lt;li&gt;The HTTP payload is response-owned output.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Once those roles are explicit, several safe implementations become available. You can map into a dedicated response model. You can clone the graph and change the clone. Or you can project replacement values at the serialization boundary.&lt;/p&gt;

&lt;p&gt;In the last approach, the traversal discovers which string properties need different values but never assigns those values to the source objects. The serializer then substitutes the replacements while creating a JSON tree owned by the response. Nested objects, collections, readonly wrappers, and init-only properties keep their wire shape because the original serializer contract still defines the payload.&lt;/p&gt;

&lt;p&gt;The important property is not the particular API used. It is that no request-specific operation writes into caller-owned or cache-owned objects.&lt;/p&gt;

&lt;p&gt;This idea also applies to redaction, unit formatting, enrichment, API-version shaping, and field masking. If the transformation varies by caller or request, it probably belongs in an output projection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preserve the Common-Case Fast Path
&lt;/h2&gt;

&lt;p&gt;Projection has a cost. It may require an additional JSON materialization, replacement lookup, and short-lived allocations. That cost should be acknowledged rather than hidden behind a correctness slogan.&lt;/p&gt;

&lt;p&gt;The practical response is to make the cheap path obvious. When the request already uses the source representation, pass the original result through untouched. Only requests that need transformation pay for projection.&lt;/p&gt;

&lt;p&gt;Failure behavior also matters. If the projection cannot handle an unusual graph, the pipeline should not leave a half-mutated object behind. A safe fallback retains the original result and logs the failure according to the application’s policy. Whether returning the untransformed payload is acceptable depends on the feature and data classification; sensitive redaction may need to fail closed instead.&lt;/p&gt;

&lt;p&gt;Security-sensitive payloads and error results may also deserve explicit exclusion. Presentation machinery should not accidentally inspect tokens, secrets, or payload shapes that were never part of its contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the Second Reader
&lt;/h2&gt;

&lt;p&gt;The highest-value test is a sequence, not a snapshot:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Put a source-language object into the real cache implementation used by the host.&lt;/li&gt;
&lt;li&gt;Request a transformed response and verify its output.&lt;/li&gt;
&lt;li&gt;Read the cached object again and verify that it is unchanged.&lt;/li&gt;
&lt;li&gt;Issue a source-language request and verify that it still receives the source value.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Run that sequence through the real result filter and serializer options, not only a mocked translation service. The ownership bug lives in the composition between cache identity, result handling, graph traversal, and serialization.&lt;/p&gt;

&lt;p&gt;Add focused cases for ordinary object results and explicit JSON results, nested collections, readonly response wrappers, init-only properties, error responses, exempt payloads, and projection failure. These tests establish both the intended transformation and the things it must never change.&lt;/p&gt;

&lt;p&gt;A mutation-resistance assertion is especially valuable: keep a reference to the original object and prove its properties retain their exact values after the response executes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trade-Off Is Isolation Versus Local Simplicity
&lt;/h2&gt;

&lt;p&gt;In-place mutation is easy to understand in one method and can avoid an extra materialization. Its cost appears elsewhere: temporal coupling, language or user context leaking between requests, and tests that pass individually while failing in sequence.&lt;/p&gt;

&lt;p&gt;Response projection adds code, allocations, and serializer-specific reasoning. In return, it makes ownership visible. The cache remains authoritative, requests remain isolated, and failures cannot leave shared objects partially transformed.&lt;/p&gt;

&lt;p&gt;That is a trade I will usually take. I would still measure the transformed path with representative payload sizes and keep the source path cheap. But performance work should begin after the ownership contract is correct.&lt;/p&gt;

&lt;p&gt;The practical rule is short: if a value is shared, treat it as immutable. If presentation depends on the request, produce response-owned output. And when you test it, always ask what the next reader sees.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>aspnetcore</category>
      <category>testing</category>
    </item>
    <item>
      <title>Treat Every ID as an Authorization Claim</title>
      <dc:creator>Ivan Rossouw</dc:creator>
      <pubDate>Wed, 30 Sep 2026 10:58:57 +0000</pubDate>
      <link>https://dev.to/iqtechsolutions/treat-every-id-as-an-authorization-claim-4c7f</link>
      <guid>https://dev.to/iqtechsolutions/treat-every-id-as-an-authorization-claim-4c7f</guid>
      <description>&lt;p&gt;Authentication answers one important question: who made this request? It does not answer a second question that often hides inside an ordinary-looking query parameter: may this caller name the record represented by this identifier?&lt;/p&gt;

&lt;p&gt;That gap is easy to miss in APIs that accept a parent ID, child ID, account ID, project ID, or tenant ID. The route is authenticated. The identifier has the correct shape. The query returns a valid result. Yet none of those facts proves that the record belongs inside the caller's authorized scope.&lt;/p&gt;

&lt;p&gt;A useful engineering rule is to treat every caller-supplied identifier as an authorization claim before treating it as a query input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authentication Is Not Object Ownership
&lt;/h2&gt;

&lt;p&gt;Imagine an endpoint that returns activities for a supplied household record. The controller requires a signed-in user, then passes the supplied ID into a query service. If the ID is changed in transit, the query may still succeed—just for somebody else's data.&lt;/p&gt;

&lt;p&gt;The vulnerable assumption is subtle: “signed in” becomes “allowed to ask about any existing record.” The database faithfully answers the question it receives. The missing control belongs before the query.&lt;/p&gt;

&lt;p&gt;The server should derive the caller's authority from trusted identity and relationship data. For an ordinary user, that might mean resolving the account to its authoritative person record, then traversing explicit relationships to the permitted records. For an operational role, a specific permission may allow a wider scope because that role already performs work requiring the same visibility.&lt;/p&gt;

&lt;p&gt;This also explains why email equality is a poor ownership rule. Addresses are corrected, reused, shared by families, copied into contact fields, and sometimes retained after account changes. An email can help locate a candidate during migration, but it should not silently replace an authoritative account-to-person relationship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the Boundary Before the Query
&lt;/h2&gt;

&lt;p&gt;A safe request pipeline makes the order explicit:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Identify the caller from trusted authentication state.&lt;/li&gt;
&lt;li&gt;Resolve the caller's authoritative records and relationships on the server.&lt;/li&gt;
&lt;li&gt;Evaluate each supplied identifier against that scope.&lt;/li&gt;
&lt;li&gt;Only then construct and execute the data query.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This order prevents a common failure mode: retrieving a broad result and hoping a later projection removes everything sensitive. Late filtering spreads the security contract across repositories, mappers, and UI code. Early scope resolution gives the query a smaller, already-authorized input.&lt;/p&gt;

&lt;p&gt;Do not bundle independent identifiers into one vague decision. If a request contains both a parent filter and a child filter, check each one. Mixed requests are exactly where “one valid field makes the request valid” bugs appear. A valid parent must not bless an unrelated child; an unrelated parent must not erase a valid child's boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reject by Default, Narrow Only Deliberately
&lt;/h2&gt;

&lt;p&gt;For most data-specific routes, a cross-scope identifier should be rejected. Rejection is direct, observable, and easier to reason about. It tells the client that its request crossed a boundary and gives operators a signal worth investigating.&lt;/p&gt;

&lt;p&gt;Legacy mobile clients complicate that clean answer. An installed client may send an old identifier shape, interpret a forbidden response as a session failure, or sign the user out. Waiting for every old version to disappear can leave a real data boundary open longer than necessary.&lt;/p&gt;

&lt;p&gt;There is a narrow alternative: compatibility-preserving narrowing. The server validates every supplied identifier, discards any that are not authorized, and executes only a known-safe baseline query. The key invariant is monotonicity: removing an unauthorized filter must never widen the response into the requested protected data.&lt;/p&gt;

&lt;p&gt;For example, a calendar route might combine household-specific entries with public entries. If an old client sends an account ID where a domain record ID is expected, the server can remove that invalid household filter and return only the public baseline. It must not substitute another household, guess from a weak attribute, or run an unscoped “all entries” query.&lt;/p&gt;

&lt;p&gt;This is not a general recommendation to hide authorization failures. It is an endpoint-specific migration tool. Document why it exists, record how it is used, and give it a removal condition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the Boundary, Not Just the Happy Path
&lt;/h2&gt;

&lt;p&gt;Authorization tests should form a matrix around relationships and permissions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the caller names their own authoritative record;&lt;/li&gt;
&lt;li&gt;the caller names a related child record;&lt;/li&gt;
&lt;li&gt;the caller names another household's record;&lt;/li&gt;
&lt;li&gt;the caller has no corresponding relationship;&lt;/li&gt;
&lt;li&gt;a shared or copied email appears on a record the caller does not own;&lt;/li&gt;
&lt;li&gt;a staff role lacks the exact cross-scope permission;&lt;/li&gt;
&lt;li&gt;a staff role holds that permission;&lt;/li&gt;
&lt;li&gt;one filter is authorized while another is not;&lt;/li&gt;
&lt;li&gt;the legacy request shape still receives the same safe baseline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last two cases are especially valuable. Mixed filters catch accidental “any match” logic. Legacy-shape tests prevent a future cleanup from turning a deliberate compatibility response back into either a leak or a destructive client error.&lt;/p&gt;

&lt;p&gt;Tests should assert returned data as well as status codes. A successful response is safe only if its contents stay inside the proven scope. A forbidden response is useful only if the client contract can tolerate it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the Exception Expensive to Forget
&lt;/h2&gt;

&lt;p&gt;Compatibility code tends to become permanent when its rationale lives only in memory. Keep the exception close to the boundary, name the safe baseline, add telemetry that contains no sensitive identifiers, and define the client version or date after which strict rejection can replace narrowing.&lt;/p&gt;

&lt;p&gt;The trade-off is real. Strict rejection gives a simpler model and clearer misuse signals. Narrowing adds policy, tests, and the possibility of masking a broken client. Its value is speed: the server can close the data boundary now while preserving a safe experience for older installations.&lt;/p&gt;

&lt;p&gt;The practical standard is modest but powerful: never let a caller-provided ID reach a query merely because it parses. First prove that the caller may name it. If compatibility forces an exception, make the request narrower, make the safe response explicit, and make the exception temporary.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>security</category>
      <category>aspnetcore</category>
    </item>
    <item>
      <title>Treat Every ID as an Authorization Claim</title>
      <dc:creator>Ivan Rossouw</dc:creator>
      <pubDate>Wed, 30 Sep 2026 10:30:51 +0000</pubDate>
      <link>https://dev.to/iqtechsolutions/treat-every-id-as-an-authorization-claim-14ke</link>
      <guid>https://dev.to/iqtechsolutions/treat-every-id-as-an-authorization-claim-14ke</guid>
      <description>&lt;p&gt;Authentication answers one important question: who made this request? It does not answer a second question that often hides inside an ordinary-looking query parameter: may this caller name the record represented by this identifier?&lt;/p&gt;

&lt;p&gt;That gap is easy to miss in APIs that accept a parent ID, child ID, account ID, project ID, or tenant ID. The route is authenticated. The identifier has the correct shape. The query returns a valid result. Yet none of those facts proves that the record belongs inside the caller's authorized scope.&lt;/p&gt;

&lt;p&gt;A useful engineering rule is to treat every caller-supplied identifier as an authorization claim before treating it as a query input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authentication Is Not Object Ownership
&lt;/h2&gt;

&lt;p&gt;Imagine an endpoint that returns activities for a supplied household record. The controller requires a signed-in user, then passes the supplied ID into a query service. If the ID is changed in transit, the query may still succeed—just for somebody else's data.&lt;/p&gt;

&lt;p&gt;The vulnerable assumption is subtle: “signed in” becomes “allowed to ask about any existing record.” The database faithfully answers the question it receives. The missing control belongs before the query.&lt;/p&gt;

&lt;p&gt;The server should derive the caller's authority from trusted identity and relationship data. For an ordinary user, that might mean resolving the account to its authoritative person record, then traversing explicit relationships to the permitted records. For an operational role, a specific permission may allow a wider scope because that role already performs work requiring the same visibility.&lt;/p&gt;

&lt;p&gt;This also explains why email equality is a poor ownership rule. Addresses are corrected, reused, shared by families, copied into contact fields, and sometimes retained after account changes. An email can help locate a candidate during migration, but it should not silently replace an authoritative account-to-person relationship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the Boundary Before the Query
&lt;/h2&gt;

&lt;p&gt;A safe request pipeline makes the order explicit:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Identify the caller from trusted authentication state.&lt;/li&gt;
&lt;li&gt;Resolve the caller's authoritative records and relationships on the server.&lt;/li&gt;
&lt;li&gt;Evaluate each supplied identifier against that scope.&lt;/li&gt;
&lt;li&gt;Only then construct and execute the data query.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This order prevents a common failure mode: retrieving a broad result and hoping a later projection removes everything sensitive. Late filtering spreads the security contract across repositories, mappers, and UI code. Early scope resolution gives the query a smaller, already-authorized input.&lt;/p&gt;

&lt;p&gt;Do not bundle independent identifiers into one vague decision. If a request contains both a parent filter and a child filter, check each one. Mixed requests are exactly where “one valid field makes the request valid” bugs appear. A valid parent must not bless an unrelated child; an unrelated parent must not erase a valid child's boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reject by Default, Narrow Only Deliberately
&lt;/h2&gt;

&lt;p&gt;For most data-specific routes, a cross-scope identifier should be rejected. Rejection is direct, observable, and easier to reason about. It tells the client that its request crossed a boundary and gives operators a signal worth investigating.&lt;/p&gt;

&lt;p&gt;Legacy mobile clients complicate that clean answer. An installed client may send an old identifier shape, interpret a forbidden response as a session failure, or sign the user out. Waiting for every old version to disappear can leave a real data boundary open longer than necessary.&lt;/p&gt;

&lt;p&gt;There is a narrow alternative: compatibility-preserving narrowing. The server validates every supplied identifier, discards any that are not authorized, and executes only a known-safe baseline query. The key invariant is monotonicity: removing an unauthorized filter must never widen the response into the requested protected data.&lt;/p&gt;

&lt;p&gt;For example, a calendar route might combine household-specific entries with public entries. If an old client sends an account ID where a domain record ID is expected, the server can remove that invalid household filter and return only the public baseline. It must not substitute another household, guess from a weak attribute, or run an unscoped “all entries” query.&lt;/p&gt;

&lt;p&gt;This is not a general recommendation to hide authorization failures. It is an endpoint-specific migration tool. Document why it exists, record how it is used, and give it a removal condition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the Boundary, Not Just the Happy Path
&lt;/h2&gt;

&lt;p&gt;Authorization tests should form a matrix around relationships and permissions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the caller names their own authoritative record;&lt;/li&gt;
&lt;li&gt;the caller names a related child record;&lt;/li&gt;
&lt;li&gt;the caller names another household's record;&lt;/li&gt;
&lt;li&gt;the caller has no corresponding relationship;&lt;/li&gt;
&lt;li&gt;a shared or copied email appears on a record the caller does not own;&lt;/li&gt;
&lt;li&gt;a staff role lacks the exact cross-scope permission;&lt;/li&gt;
&lt;li&gt;a staff role holds that permission;&lt;/li&gt;
&lt;li&gt;one filter is authorized while another is not;&lt;/li&gt;
&lt;li&gt;the legacy request shape still receives the same safe baseline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last two cases are especially valuable. Mixed filters catch accidental “any match” logic. Legacy-shape tests prevent a future cleanup from turning a deliberate compatibility response back into either a leak or a destructive client error.&lt;/p&gt;

&lt;p&gt;Tests should assert returned data as well as status codes. A successful response is safe only if its contents stay inside the proven scope. A forbidden response is useful only if the client contract can tolerate it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the Exception Expensive to Forget
&lt;/h2&gt;

&lt;p&gt;Compatibility code tends to become permanent when its rationale lives only in memory. Keep the exception close to the boundary, name the safe baseline, add telemetry that contains no sensitive identifiers, and define the client version or date after which strict rejection can replace narrowing.&lt;/p&gt;

&lt;p&gt;The trade-off is real. Strict rejection gives a simpler model and clearer misuse signals. Narrowing adds policy, tests, and the possibility of masking a broken client. Its value is speed: the server can close the data boundary now while preserving a safe experience for older installations.&lt;/p&gt;

&lt;p&gt;The practical standard is modest but powerful: never let a caller-provided ID reach a query merely because it parses. First prove that the caller may name it. If compatibility forces an exception, make the request narrower, make the safe response explicit, and make the exception temporary.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>aspnetcore</category>
      <category>csharp</category>
      <category>security</category>
    </item>
    <item>
      <title>A Lifetime Is a Hold, Not the Worker</title>
      <dc:creator>Ivan Rossouw</dc:creator>
      <pubDate>Tue, 29 Sep 2026 05:57:46 +0000</pubDate>
      <link>https://dev.to/iqtechsolutions/a-lifetime-is-a-hold-not-the-worker-51c1</link>
      <guid>https://dev.to/iqtechsolutions/a-lifetime-is-a-hold-not-the-worker-51c1</guid>
      <description>&lt;p&gt;&lt;code&gt;IDisposable&lt;/code&gt; and &lt;code&gt;IAsyncDisposable&lt;/code&gt; make lifetime management feel pleasantly local: acquire something, use it, dispose it. That model is reliable when the acquired value has exclusive ownership of an independent resource.&lt;/p&gt;

&lt;p&gt;It becomes dangerous when several activations share one background worker.&lt;/p&gt;

&lt;p&gt;Mobile applications encounter this more often than it first appears. Returning online can reconcile a capability that is already running. A foreground transition can overlap an earlier activation. A bounded startup wait can stop waiting for slow native work, while that work completes after a newer activation has already joined the same resource.&lt;/p&gt;

&lt;p&gt;If every returned lifetime means “stop the worker,” one participant can tear down work that another still needs. The sharper contract is: a returned lifetime is a hold on shared work. Disposing it releases only that participant’s hold. The worker stops when the final hold leaves.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug hides behind a reasonable API
&lt;/h2&gt;

&lt;p&gt;Imagine a mobile shell with two optional capabilities: a small status refresher and a device-token relay. Each activation returns an asynchronous lifetime to the orchestrator. The orchestrator keeps the latest lifetime and disposes the one it replaces.&lt;/p&gt;

&lt;p&gt;That sounds tidy, but activations are not necessarily independent. The second activation may join the refresher or event relay started by the first. If the first lifetime calls a global &lt;code&gt;StopAsync&lt;/code&gt;, disposing the replaced lifetime stops the shared worker beneath the second activation.&lt;/p&gt;

&lt;p&gt;The state machine can now tell a convincing lie. Its newest activation completed successfully, so the capability still appears ready. Underneath, polling has stopped or the event handler has been removed.&lt;/p&gt;

&lt;p&gt;Warning, cancellation, and stale-completion paths deserve the same scrutiny. Cleanup code is often written defensively in several branches. If each branch performs terminal teardown, one failed attempt can shut down a resource held by a successful attempt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write the ownership sentence first
&lt;/h2&gt;

&lt;p&gt;Before implementing the lifetime, describe what disposal means:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Disposing this value releases this activation’s participation. It does not necessarily stop the shared resource.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That sentence changes the design. Starting becomes “start or join, then take a hold.” Releasing becomes “release this hold, then stop only if it was the last.” Both transitions need one synchronization boundary.&lt;/p&gt;

&lt;p&gt;It is not enough to increment a counter and subscribe in separate steps. Another thread can observe the gap. A release can unsubscribe after a new hold has been counted, or two starts can install duplicate listeners. The zero-to-one decision and the actual start belong in one atomic operation. So do the one-to-zero decision and the stop.&lt;/p&gt;

&lt;p&gt;The hold itself should release at most once. A double &lt;code&gt;Dispose&lt;/code&gt; must not decrement the count twice. A failed start must not record a hold, otherwise the next activation may “join” work that never started.&lt;/p&gt;

&lt;h2&gt;
  
  
  Old holds must not reach into new runs
&lt;/h2&gt;

&lt;p&gt;Reference counting alone is not sufficient when the resource can stop and restart.&lt;/p&gt;

&lt;p&gt;Suppose an old activation holds generation A. The application moves offline, so generation A is stopped. Later, the application returns online and starts generation B. If the late lifetime from A merely decrements a global count, it can steal a hold from B and stop the restarted worker.&lt;/p&gt;

&lt;p&gt;Bind each hold to the run it joined. Releasing a hold from an ended run becomes a no-op for the current one.&lt;/p&gt;

&lt;p&gt;The orchestrator may need a run identity too. Some stop events invalidate a broad session lease; others, such as backgrounding or going offline, may intentionally leave that session identity unchanged. A slow activation can therefore finish with a lease that still looks current even though the capability run it belonged to has ended. Tracking the capability run separately prevents that late completion from being adopted as ready.&lt;/p&gt;

&lt;p&gt;This distinction is useful beyond mobile software. Connection pools, shared subscriptions, refresh loops, file watchers, and in-process event relays all have “same logical session, different resource run” failure modes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test transitions, not only outcomes
&lt;/h2&gt;

&lt;p&gt;A happy-path test that starts and stops once proves very little about shared ownership.&lt;/p&gt;

&lt;p&gt;Useful tests make each boundary visible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;take two holds, release one, and prove the worker remains active;&lt;/li&gt;
&lt;li&gt;release the final hold and prove the worker stops exactly once;&lt;/li&gt;
&lt;li&gt;dispose one hold twice and prove it releases once;&lt;/li&gt;
&lt;li&gt;fail the first start and prove a later acquisition tries again;&lt;/li&gt;
&lt;li&gt;restart the resource and prove an old hold cannot affect the new run;&lt;/li&gt;
&lt;li&gt;complete an abandoned activation after a newer one and prove the newer work survives; and&lt;/li&gt;
&lt;li&gt;repeatedly take, inspect, and release holds from two threads while the count crosses zero.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last test is important. A single-thread loop can leave mutation survivors because it never exposes the gap between “count says active” and “listener is actually active.” Two competing threads make zero crossings collide with acquisition and release, which is where split synchronization shows itself.&lt;/p&gt;

&lt;p&gt;Concurrency tests still need discipline: bounded rounds, a clear invariant, and no claim that passing them mathematically proves correctness. Their job is to exercise the race window while deterministic tests pin the state-machine rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;Counted holds add a lock, a run identifier, idempotent release state, and more involved tests. They also force a clear distinction between releasing one participant and terminally disposing the owning dependency scope.&lt;/p&gt;

&lt;p&gt;The simpler design is to prohibit overlap. That can be the right choice when the orchestrator can guarantee one activation at a time, cancel it reliably, and wait for completion before starting another. But if native calls can outlive a budget, or reconciliation can join already-running work, the simplicity is imaginary.&lt;/p&gt;

&lt;p&gt;The practical takeaway is broader than reference counting: make ownership semantics part of the public contract. When a lifetime represents participation in shared work, call it a hold and test it like one. A local cleanup action should never silently become a global stop.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>dotnet</category>
      <category>csharp</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Design the Cursor, Not Just the Query</title>
      <dc:creator>Ivan Rossouw</dc:creator>
      <pubDate>Mon, 28 Sep 2026 06:23:59 +0000</pubDate>
      <link>https://dev.to/iqtechsolutions/design-the-cursor-not-just-the-query-35e</link>
      <guid>https://dev.to/iqtechsolutions/design-the-cursor-not-just-the-query-35e</guid>
      <description>&lt;p&gt;Pagination is often introduced as a performance improvement: fetch fewer rows, render less markup, and keep response sizes predictable. That is only half the job.&lt;/p&gt;

&lt;p&gt;For an operational queue that changes while people work, pagination also defines what it means to continue reading. If that contract is weak, the database can answer every query correctly while the person using the screen still sees duplicates or misses work.&lt;/p&gt;

&lt;p&gt;The important design question is not merely, “How many rows should this query return?” It is, “What does the next request mean after the underlying set has changed?”&lt;/p&gt;

&lt;h2&gt;
  
  
  Why offset paging drifts
&lt;/h2&gt;

&lt;p&gt;Consider a queue ordered newest first. A reviewer asks for the first twenty-five rows. The next request uses an offset of twenty-five.&lt;/p&gt;

&lt;p&gt;Between those requests, a new row arrives at the top. Every earlier row has moved down one position. The second query now begins with the final row from page one, so the reviewer sees a duplicate. More importantly, one unseen row has been pushed beyond the requested range.&lt;/p&gt;

&lt;p&gt;This is not an exotic race. Append-mostly queues invite it because adding work is the ordinary operation. Offset paging treats positions as stable even though insertion changes every position below it.&lt;/p&gt;

&lt;p&gt;Mutable sorting creates a similar problem. If the queue is ordered by status or last-updated time, an operator’s decision can move a row while the walk is in progress. A continuation based on that value inherits the instability of the workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Continue from an immutable boundary
&lt;/h2&gt;

&lt;p&gt;A stronger contract says: continue after the boundary returned by the previous read.&lt;/p&gt;

&lt;p&gt;For a newest-first queue, that boundary can be an immutable creation or submission timestamp. New rows arriving above the boundary are not part of the current walk. They will appear when the reviewer starts again from the top, but they do not shift the meaning of “next.”&lt;/p&gt;

&lt;p&gt;Equal timestamps need deliberate handling. Adding an identifier as a tie-breaker becomes fragile when data providers sort that type differently.&lt;/p&gt;

&lt;p&gt;Another approach is to carry the boundary timestamp plus the number of rows already consumed at that exact timestamp. The database remains responsible for its native ordering within the tie, while the continuation records how far through that tie the walk has progressed. The detail matters less than the invariant: ties must neither duplicate nor hide rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the continuation part of the contract
&lt;/h2&gt;

&lt;p&gt;An opaque cursor should not be a loosely encoded page number. It is protocol state, so treat it that way.&lt;/p&gt;

&lt;p&gt;Useful safeguards include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a version, so the format can evolve deliberately;&lt;/li&gt;
&lt;li&gt;a strict length limit and parse validation;&lt;/li&gt;
&lt;li&gt;the immutable boundary and tie position;&lt;/li&gt;
&lt;li&gt;the active filter identity; and&lt;/li&gt;
&lt;li&gt;sensible bounds on any stored count.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Binding the cursor to its filter is especially important. A position in “all open items” does not mean the same thing in “items for one category.” Reusing the cursor after changing filters can silently skip work. Rejecting that request is clearer than pretending the cursor is still meaningful.&lt;/p&gt;

&lt;p&gt;Likewise, a malformed continuation should fail explicitly. Silently dropping it and returning the first page converts a bad request into a plausible-looking duplicate page. Operational software benefits from visible invalid states.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep every downstream read bounded
&lt;/h2&gt;

&lt;p&gt;Paging the primary table is not enough if enrichment still loads data for the entire result set.&lt;/p&gt;

&lt;p&gt;A common pattern reads the page, extracts its identifiers, and then fetches related summaries in a second query. That identifier list must come from the bounded page. Otherwise the visible query is paged while a hidden IN clause continues growing with the queue.&lt;/p&gt;

&lt;p&gt;Fetching one extra primary row is a simple way to determine whether a continuation is needed. Return the requested page size, and issue a next cursor only when the extra row exists. The presence of that cursor—not whether the current page happens to be full—becomes the authoritative “has more” signal.&lt;/p&gt;

&lt;p&gt;This also avoids an exact count on every read. For many live queues, “Page 3” plus Previous and Next is the more honest interface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Carry the contract into the Blazor UI
&lt;/h2&gt;

&lt;p&gt;Server-side continuation paging should not be wrapped in a client component that first downloads the entire queue. The UI has to follow the same contract.&lt;/p&gt;

&lt;p&gt;A practical Blazor page can keep a small trail containing the cursor used to open each visited page. Next stores the returned cursor. Previous reuses the earlier cursor to re-read that logical page. After an operator records a decision, refresh the current page rather than throwing them back to the beginning.&lt;/p&gt;

&lt;p&gt;Page changes should also clear any pending confirmation tied to a row that is no longer visible. That is a small state-management detail with a large clarity benefit: the user should never be asked to confirm an action on an item they cannot see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the movement, not only the shape
&lt;/h2&gt;

&lt;p&gt;Happy-path tests that assert “twenty-five rows returned” do not prove a paging contract.&lt;/p&gt;

&lt;p&gt;The valuable cases exercise movement:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;insert a newer row after reading page one, then verify page two has no gap or duplicate;&lt;/li&gt;
&lt;li&gt;create several rows with the same boundary timestamp;&lt;/li&gt;
&lt;li&gt;reject zero, negative, and excessive page sizes;&lt;/li&gt;
&lt;li&gt;reject malformed, oversized, and filter-mismatched cursors;&lt;/li&gt;
&lt;li&gt;verify related-data queries receive only current-page identifiers;&lt;/li&gt;
&lt;li&gt;navigate Next and Previous in the component; and&lt;/li&gt;
&lt;li&gt;perform an action and confirm the current page is refreshed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These tests describe the promise a reviewer experiences, not merely an implementation detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;Continuation paging is more work than offset paging. It needs a stable sort, a cursor format, versioning, filter binding, careful tie semantics, and focused tests. It also makes random access and exact page counts less convenient.&lt;/p&gt;

&lt;p&gt;That cost is justified when the list is both live and operationally important. Bounded queries protect the system under bursty load; stable continuation protects the human workflow under concurrent change.&lt;/p&gt;

&lt;p&gt;Takeaway: choose pagination from the mutation pattern, not from the table widget. If ordinary inserts can change what “next” means, the continuation itself deserves first-class design.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>blazor</category>
      <category>testing</category>
    </item>
    <item>
      <title>Transfer Workspace Ownership Safely from Blazor</title>
      <dc:creator>Ivan Rossouw</dc:creator>
      <pubDate>Sat, 26 Sep 2026 22:46:09 +0000</pubDate>
      <link>https://dev.to/iqtechsolutions/transfer-workspace-ownership-safely-from-blazor-dh4</link>
      <guid>https://dev.to/iqtechsolutions/transfer-workspace-ownership-safely-from-blazor-dh4</guid>
      <description>&lt;p&gt;Ownership transfer is one reviewed decision over two versioned membership rows. The browser freezes the displayed current-owner version and target-member version, while the server remains responsible for authorization, tenant scope, concurrency, and the atomic role swap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capture a complete snapshot
&lt;/h2&gt;

&lt;p&gt;The action is available only when exactly one current-user row is an owner and the target is another ordinary member. Selecting it captures the target ID and email plus both versions. Refresh clears that pending snapshot before loading, so newer roster data can never be paired with an older confirmation.&lt;/p&gt;

&lt;p&gt;The confirmation names the recipient and both consequences: the target becomes owner, while the caller becomes a member and loses member administration. One pending-action union keeps removal and transfer mutually exclusive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decode a finite HTTP contract
&lt;/h2&gt;

&lt;p&gt;The adapter sends the encoded target route and exactly expectedOwnerVersion plus expectedTargetVersion. It accepts only status-specific outcomes. Bad Request maps to Invalid only for invalid_transfer. Conflict maps to Stale only for stale_version. A malformed body, duplicate or extra property, mismatched code, unexpected status, transport error, or timeout-like cancellation becomes Failure. Caller-requested cancellation propagates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Never replay a consequential command
&lt;/h2&gt;

&lt;p&gt;Known Stale and Not Found outcomes clear the confirmation and perform one no-store roster reload. The owner must inspect and choose again. Invalid and ambiguous Failure outcomes keep the last roster only as untrusted context, disable all mutations, focus an alert, and require manual refresh. Unauthorized and Forbidden clear private data.&lt;/p&gt;

&lt;p&gt;The submitting guard is set synchronously before the adapter is awaited. Every mutation control is disabled while the request is in flight, and a bUnit test holds the response open to prove rapid activation still dispatches only one command.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make confirmed access loss a terminal success
&lt;/h2&gt;

&lt;p&gt;After 204 No Content, the former owner no longer has owner authorization. Automatically refreshing the owner roster would predictably return Forbidden and could overwrite the authoritative success message. The component therefore clears the private roster, retires owner controls, names the new owner, and enters a terminal reauthentication state without another GET. Renewing authentication updates stale claims; it does not restore the former owner's permission.&lt;/p&gt;

&lt;p&gt;The tests prove the wire contract, frozen dual versions, confirmation semantics, refresh invalidation, mutual exclusion, conflict recovery without replay, ambiguous failure locking, access clearing, accessible focus, and zero follow-up roster requests after success. Existing integration tests continue to own the atomic server guarantee.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>csharp</category>
      <category>dotnet</category>
      <category>blazor</category>
    </item>
    <item>
      <title>When EF Core's Design-Time Model Is Supposed to Be Incomplete</title>
      <dc:creator>Ivan Rossouw</dc:creator>
      <pubDate>Sat, 26 Sep 2026 06:00:06 +0000</pubDate>
      <link>https://dev.to/iqtechsolutions/when-ef-cores-design-time-model-is-supposed-to-be-incomplete-5bke</link>
      <guid>https://dev.to/iqtechsolutions/when-ef-cores-design-time-model-is-supposed-to-be-incomplete-5bke</guid>
      <description>&lt;p&gt;Framework warnings are useful because they compress hard-earned experience into an early signal. But a signal is not an invariant. In an extensible EF Core architecture, design-time tooling can legitimately see less of the model than the running application. If that difference is intentional, treating every pending-model warning as real drift can block a safe database update—or encourage a destructive migration.&lt;/p&gt;

&lt;p&gt;The answer is not to ignore the warning casually. It is to understand exactly why it is false, then replace the lost signal with checks that understand the real architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a Valid Model Becomes Invisible
&lt;/h2&gt;

&lt;p&gt;Consider a modular .NET application with a DbContext owned by a foundational module. Higher-level modules can contribute entity configurations through runtime discovery. This keeps the dependency direction clean: the foundational data module does not need references back to every feature that extends it.&lt;/p&gt;

&lt;p&gt;At runtime, the host loads the feature assemblies and the DbContext receives the complete set of configurations. At design time, the EF Core tooling starts from the foundational project. Loading a higher-level contributor from there would create a circular dependency, so the design-time process builds a smaller model.&lt;/p&gt;

&lt;p&gt;The migration snapshot still represents the complete model. The design-time differ compares that snapshot with its partial view and concludes that the contributed entity has disappeared. A probe migration may therefore contain a drop operation for a table that remains valid and necessary at runtime.&lt;/p&gt;

&lt;p&gt;The tool is behaving consistently with the inputs it can see. The architectural context is what it lacks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove the False Positive Before Suppressing It
&lt;/h2&gt;

&lt;p&gt;Before changing warning behaviour, reduce the difference to something explainable.&lt;/p&gt;

&lt;p&gt;Generate a disposable probe migration and inspect every operation. If the only reported difference is the known extension entity that design time cannot load, you have evidence for a visibility gap. If the probe also changes columns, indexes, relationships, or unrelated tables, stop: that may be genuine drift.&lt;/p&gt;

&lt;p&gt;Then verify the full model independently. Run a snapshot comparison in a test process where all contributing assemblies are loaded. That test should compare the migration snapshot against the same model the application actually builds.&lt;/p&gt;

&lt;p&gt;Only after both checks agree should the design-time factory suppress the specific pending-model warning. The suppression belongs at the narrow boundary where the false positive occurs, not in a broad configuration that hides the warning for unrelated contexts.&lt;/p&gt;

&lt;p&gt;This is an important distinction: the warning is disabled because a stronger source of truth exists, not because the warning is inconvenient.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turn the Destructive Failure Mode Into a Build Failure
&lt;/h2&gt;

&lt;p&gt;Even after database update is unblocked, migration scaffolding may still use the incomplete design-time model. A future developer can generate a migration and receive a plausible-looking drop operation.&lt;/p&gt;

&lt;p&gt;Comments and runbooks help, but they do not stop a merge. An executable guard can.&lt;/p&gt;

&lt;p&gt;Inspect every migration's operations and fail if a protected extension table is dropped, renamed, or destructively altered. Cover structured operations such as table drops and column drops, but also inspect raw SQL for equivalent destructive statements. The goal is not a general SQL parser. It is a focused tripwire for the known architectural blind spot.&lt;/p&gt;

&lt;p&gt;Add a positive control as well. The suite should prove that migration discovery found the migration which creates the protected table. Without that check, a broken scanner could inspect zero migrations and report a reassuring green result. A rejection-only test cannot distinguish “nothing is wrong” from “nothing ran.”&lt;/p&gt;

&lt;p&gt;That small positive assertion dramatically improves the credibility of the safety test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the Test Harness Honest
&lt;/h2&gt;

&lt;p&gt;Design-time factory tests often need temporary configuration, such as a process-wide environment variable containing a connection string. Two test classes that mutate the same variable can race: one restores the original value while the other is still constructing its context.&lt;/p&gt;

&lt;p&gt;That creates a flaky failure which disappears when either class runs alone.&lt;/p&gt;

&lt;p&gt;Treat process-wide configuration as shared mutable state. Put those tests in one serialized collection, restore the previous value in a &lt;code&gt;finally&lt;/code&gt; block, and make it explicit that the connection value is only used to construct options when no database call is required.&lt;/p&gt;

&lt;p&gt;This detail is adjacent to the model problem, but it matters. A safety test that flakes trains people to distrust or bypass it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trade-Off Is Custom Safety Work
&lt;/h2&gt;

&lt;p&gt;This approach is not free. The team must maintain a full-model snapshot test, a focused migration-operation guard, a positive control, serialized environment-sensitive tests, and documentation explaining which CLI signal is intentionally unreliable for this context.&lt;/p&gt;

&lt;p&gt;The alternative is worse: reverse the dependency direction, abandon modular extension points, block legitimate database updates, or risk committing a destructive migration produced by a partial model.&lt;/p&gt;

&lt;p&gt;The general rule is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Identify why the generic check is wrong.&lt;/li&gt;
&lt;li&gt;Prove the exact boundary of the false positive.&lt;/li&gt;
&lt;li&gt;Suppress only at that boundary.&lt;/li&gt;
&lt;li&gt;Replace the lost signal with a stronger invariant.&lt;/li&gt;
&lt;li&gt;Add a positive control so the invariant cannot pass vacuously.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Framework defaults are valuable, but architecture-specific evidence is stronger. When design time and runtime intentionally see different worlds, make that difference explicit—and make the dangerous interpretation impossible to merge unnoticed.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>dotnet</category>
      <category>architecture</category>
      <category>efcore</category>
    </item>
    <item>
      <title>A Page Is Not Shipped Until People Can Find It</title>
      <dc:creator>Ivan Rossouw</dc:creator>
      <pubDate>Fri, 25 Sep 2026 06:06:10 +0000</pubDate>
      <link>https://dev.to/iqtechsolutions/a-page-is-not-shipped-until-people-can-find-it-375o</link>
      <guid>https://dev.to/iqtechsolutions/a-page-is-not-shipped-until-people-can-find-it-375o</guid>
      <description>&lt;p&gt;A routed page can compile, pass its authorization checks, have documentation, and still be functionally absent. If the people entitled to use it cannot discover a normal way in, the product behaves as though the capability was never built.&lt;/p&gt;

&lt;p&gt;That is easy to miss in a modular Blazor application. One team adds pages in one project, another module owns a second page tree, and a central hub or navigation catalogue tries to present the whole product coherently. Each local change can look correct while the experience between modules quietly breaks.&lt;/p&gt;

&lt;p&gt;The remedy is not another release checklist. It is an executable discoverability contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the invariant at the page boundary
&lt;/h2&gt;

&lt;p&gt;A useful rule is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Every static operational page must have one intentional entry point whose visibility matches the page's access rule.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;“Static” matters. A detail page with an identifier in its route is normally reached from a list, search result, or parent screen. It does not need a global menu item. A screen that answers on two route aliases is still one page; adding two cards would create duplication rather than discoverability.&lt;/p&gt;

&lt;p&gt;The rule should cover pages, not merely strings that resemble URLs. That keeps the test aligned with the product concept people actually use.&lt;/p&gt;

&lt;p&gt;It should also say “intentional entry point,” not “mentioned somewhere.” A link buried inside another page may technically make a route reachable, but it does not necessarily make the capability findable. Likewise, a help article that names a screen is documentation, not navigation. Requiring a deliberate card or menu entry forces the information architecture to be explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scan every module, not only the original tree
&lt;/h2&gt;

&lt;p&gt;Architecture tests often begin with a useful local assumption: all administrative pages live under one directory, namespace, or assembly. The test passes for years. Then a new module introduces another tree and the guard continues to report green because it never sees the new pages.&lt;/p&gt;

&lt;p&gt;This is a particularly awkward failure because both the feature and the guard are “working as designed.” The design boundary is simply stale.&lt;/p&gt;

&lt;p&gt;The test therefore needs a completeness check as well as a matching check. Enumerate every known page tree. Parse the routed components. Exclude only clearly contextual routes. Then compare the resulting pages with the navigation catalogue.&lt;/p&gt;

&lt;p&gt;Add a sanity assertion too. If a mature tree suddenly produces zero—or far fewer—pages, the parser or directory assumption probably broke. Without that guard, a failed scan can look like perfect compliance.&lt;/p&gt;

&lt;p&gt;When a new module owns pages, extending the scan is part of integrating that module. This turns architectural coverage into an explicit responsibility rather than inherited folklore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep navigation and authorization separate
&lt;/h2&gt;

&lt;p&gt;The navigation entry should use the same access decision as the destination page. If the card is broader, people see an invitation that ends in refusal. If it is narrower, people who are entitled to use the page cannot find it.&lt;/p&gt;

&lt;p&gt;It is tempting to reuse a nearby permission because it is convenient. That can be wrong in both directions. When no existing decision expresses the page's real audience, adding a focused visibility flag is usually clearer than bending an unrelated one.&lt;/p&gt;

&lt;p&gt;But matching visibility does not turn navigation into security. A hidden card only changes what the interface presents. A direct request, a bookmarked address, or another client can still reach the endpoint. The page or server operation must continue to enforce authorization independently.&lt;/p&gt;

&lt;p&gt;This separation produces a healthy pair of invariants:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;navigation makes entitled capabilities discoverable;&lt;/li&gt;
&lt;li&gt;authorization rejects requests that are not entitled.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Testing both is stronger than asking either mechanism to do two jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prefer a precise contract over “linked somewhere”
&lt;/h2&gt;

&lt;p&gt;A broad reachability scan can be a good first diagnostic. Search the codebase for links to each route and identify pages that have no inbound reference. That quickly reveals obvious orphans.&lt;/p&gt;

&lt;p&gt;As a permanent contract, however, “linked somewhere” is weak. A tab rail is visible only after someone has already reached a page in that tab set. A contextual link may depend on data or state. A help page may mention the route without providing product navigation. Source text can prove that a string exists; it cannot prove that the intended audience has a clear entry point.&lt;/p&gt;

&lt;p&gt;Tightening the rule to require the chosen navigation catalogue makes the standard less ambiguous. It also creates useful pressure: when a page fits nowhere, the team must decide whether the navigation model is incomplete, the page is contextual, or the feature should not be exposed yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Accept the maintenance cost deliberately
&lt;/h2&gt;

&lt;p&gt;The stricter contract adds work. New pages need catalogue entries, accurate visibility mapping, and suitable grouping. Route conventions must be understood well enough for the test to distinguish static pages, detail pages, and aliases. Large navigation surfaces may expose sections that no longer fit their historical categories.&lt;/p&gt;

&lt;p&gt;Those are not test problems to hide with a long exemption list. They are product and architecture decisions the test has made visible.&lt;/p&gt;

&lt;p&gt;Keep exemptions small, named, and justified by a navigation model rather than by inconvenience. Fail when an exemption becomes stale. If a page genuinely belongs behind a list or workflow, encode that reason. If it is an operational destination, give it an intentional entry point.&lt;/p&gt;

&lt;p&gt;The payoff is modest but durable: completed capabilities do not disappear between modules, permission drift becomes easier to see, and a new page cannot quietly land outside the product's navigation contract.&lt;/p&gt;

&lt;p&gt;A page is not shipped merely because its route exists. It is shipped when the right person can find it—and the wrong person still cannot execute it.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>blazor</category>
      <category>dotnet</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
