<?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>Graceful Degradation Must Fail Closed</title>
      <dc:creator>Ivan Rossouw</dc:creator>
      <pubDate>Tue, 11 Aug 2026 06:00:20 +0000</pubDate>
      <link>https://dev.to/iqtechsolutions/graceful-degradation-must-fail-closed-1h5l</link>
      <guid>https://dev.to/iqtechsolutions/graceful-degradation-must-fail-closed-1h5l</guid>
      <description>&lt;p&gt;Graceful degradation sounds simple: if one dependency is unavailable, keep the independent parts of the application running.&lt;/p&gt;

&lt;p&gt;That is the right goal. The trap is treating degraded mode as one boolean that turns a feature off. In a routed web application, each endpoint sits inside a graph of framework services, middleware, policies, and downstream systems. Removing one dependency does not remove the others.&lt;/p&gt;

&lt;p&gt;A recent committed change made that distinction unusually clear.&lt;/p&gt;

&lt;h2&gt;
  
  
  One extra path segment changed the result
&lt;/h2&gt;

&lt;p&gt;The host had two broad capabilities:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;public pages that could render without a database;&lt;/li&gt;
&lt;li&gt;a private area backed by a database and an identity store.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The desired degraded state was reasonable. If the database configuration was absent, public pages should remain available and the private capability should report that it was unavailable.&lt;/p&gt;

&lt;p&gt;An initial fallback middleware checked the private path prefix and returned a controlled response. A nearby child URL produced the expected result, so the approach appeared sound.&lt;/p&gt;

&lt;p&gt;The exact protected route behaved differently. It matched a real endpoint decorated with an authorization requirement. The framework reached endpoint authorization before the fallback could compensate. Because no authentication scheme had been registered in that configuration, the request ended as a bare 500.&lt;/p&gt;

&lt;p&gt;The lookalike URL had tested a different pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Degraded mode is a dependency graph
&lt;/h2&gt;

&lt;p&gt;The public pages had shed their database dependency. The protected endpoint had not shed its dependency on ASP.NET Core's authentication and authorization contracts.&lt;/p&gt;

&lt;p&gt;That leads to a useful rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If an endpoint remains mapped, keep the framework contracts required to process it valid in every supported configuration.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For a protected route, “valid” does not mean pretending that sign-in can succeed. It means the security pipeline remains coherent and fails closed.&lt;/p&gt;

&lt;p&gt;A simplified shape looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;identityStoreIsAvailable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddTheRealIdentityStore&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAuthentication&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Unavailable"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddCookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Unavailable"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAuthorization&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Authentication and authorization middleware can then remain unconditional. In the unavailable branch, nobody can become authenticated because there is no identity store behind the fallback scheme. The protected endpoint challenges safely instead of throwing or accidentally opening.&lt;/p&gt;

&lt;p&gt;The important idea is not this exact registration. It is the explicit unavailable implementation. Missing infrastructure is represented as a supported state rather than as an incomplete application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give each route an honest outcome
&lt;/h2&gt;

&lt;p&gt;Once the security pipeline was coherent, the application could express several outcomes deliberately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;independent public pages continued to return success;&lt;/li&gt;
&lt;li&gt;database-dependent access paths returned an honest unavailable response;&lt;/li&gt;
&lt;li&gt;an anonymous request in the healthy configuration was challenged normally;&lt;/li&gt;
&lt;li&gt;no protected route failed open;&lt;/li&gt;
&lt;li&gt;no supported configuration produced an unhandled exception.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is more precise than asking whether “the site” is up. Parts of the host can have different availability without weakening the security boundary between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Liveness and readiness answer different questions
&lt;/h2&gt;

&lt;p&gt;There is an operational trade-off. If public pages are intentionally database-free, a liveness probe can remain green while the private capability is unavailable.&lt;/p&gt;

&lt;p&gt;That is not dishonest if the probes are named and monitored correctly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Liveness:&lt;/strong&gt; Is the process running and able to serve independent work?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Readiness:&lt;/strong&gt; Are the dependencies required for the full capability available?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The unavailable state must also be loud in logs. Otherwise graceful degradation can turn into silent degradation: users see only the healthy surface while operators miss a broken private one.&lt;/p&gt;

&lt;p&gt;This design adds configuration branches, a fallback scheme, route-specific responses, and monitoring semantics. The return is a smaller failure domain and a security posture that remains fail-closed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turn the discovery into a route matrix
&lt;/h2&gt;

&lt;p&gt;The most valuable follow-up is an integration-test matrix built around exact endpoints, not representative-looking strings.&lt;/p&gt;

&lt;p&gt;For each supported dependency state, exercise:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;an independent public route;&lt;/li&gt;
&lt;li&gt;the exact protected route;&lt;/li&gt;
&lt;li&gt;a near-miss or unmatched route;&lt;/li&gt;
&lt;li&gt;the sign-in entry point;&lt;/li&gt;
&lt;li&gt;liveness and readiness.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Assert the intended status or redirect for each cell. Also assert two negative properties across the whole matrix: no 500 responses and no authorization bypass.&lt;/p&gt;

&lt;p&gt;The committed change was manually checked across configured and unconfigured states. That was enough to expose the framework-ordering mistake. Encoding the same matrix as integration tests would turn the discovery into a durable contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical takeaway
&lt;/h2&gt;

&lt;p&gt;When designing graceful degradation, list capabilities first, then trace every mapped route through routing, authentication, authorization, storage, and external services. Decide which dependencies each route may shed and which framework contracts must remain intact.&lt;/p&gt;

&lt;p&gt;Finally, test the exact route through the real pipeline. A lookalike URL can make a broken fallback look correct.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>aspnetcore</category>
      <category>security</category>
      <category>testing</category>
    </item>
    <item>
      <title>Never Let Migration Tooling Guess the Database</title>
      <dc:creator>Ivan Rossouw</dc:creator>
      <pubDate>Sun, 09 Aug 2026 07:57:33 +0000</pubDate>
      <link>https://dev.to/iqtechsolutions/never-let-migration-tooling-guess-the-database-3ehf</link>
      <guid>https://dev.to/iqtechsolutions/never-let-migration-tooling-guess-the-database-3ehf</guid>
      <description>&lt;p&gt;One of the more dangerous database configuration errors is not a failed connection. It is a successful connection to a database nobody deliberately selected.&lt;/p&gt;

&lt;p&gt;That is why an EF Core design-time &lt;code&gt;DbContext&lt;/code&gt; factory deserves more scrutiny than “CLI plumbing”. It decides how tooling constructs the model outside the application’s normal startup path. If it also searches broadly for connection details, it can grant a migration command ambient authority.&lt;/p&gt;

&lt;p&gt;A recent committed cleanup made this concrete for me. One design-time factory still fell back to configuration owned by an application host that no longer owned the module. The runtime architecture had been separated, but the tooling still crossed the old boundary.&lt;/p&gt;

&lt;p&gt;The change removed that fallback. The lesson is broader: design-time configuration is a database-safety boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  A fallback is an authority decision
&lt;/h2&gt;

&lt;p&gt;Application startup often combines JSON files, environment variables, secret stores, and deployment configuration. That flexibility is useful because the running application has a defined environment and composition root.&lt;/p&gt;

&lt;p&gt;The EF Core CLI operates in a different context. It may run from a module directory, a developer workstation, or CI. A design-time factory that walks the repository or borrows another host’s settings can discover a connection string the operator never consciously selected.&lt;/p&gt;

&lt;p&gt;The unintended target does not have to be production to cause harm. It might be a shared development database, an integration environment, or a database belonging to another application boundary. The problem is the same: success looks legitimate even though intent was never established.&lt;/p&gt;

&lt;p&gt;Convenient fallback logic answers a security-relevant question: “If no target was chosen, which target should the tool receive?” The safest answer is none.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model construction is not connection authority
&lt;/h2&gt;

&lt;p&gt;Throwing immediately when configuration is absent is safe, but it can be unnecessarily blunt. Several design-time operations need the model and migration assembly, not a live database. Developers should be able to inspect or scaffold migrations without carrying database credentials.&lt;/p&gt;

&lt;p&gt;This gives us two distinct capabilities:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Construct the model for offline design-time work.&lt;/li&gt;
&lt;li&gt;Open a database connection and potentially change state.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;They should not share an implicit permission boundary.&lt;/p&gt;

&lt;p&gt;A useful pattern is to let the provider options use an unmistakably unreachable sentinel when no connection is configured. Model-only commands can still create the context. Any operation that opens a connection fails with a recognisable error instead of guessing a real target.&lt;/p&gt;

&lt;h2&gt;
  
  
  A generic factory shape
&lt;/h2&gt;

&lt;p&gt;The following example is intentionally generic. The sentinel value is provider-specific and belongs in a small, clearly named helper; it should never resemble a real server.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DesignTimeFactory&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IDesignTimeDbContextFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AppDbContext&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;AppDbContext&lt;/span&gt; &lt;span class="nf"&gt;CreateDbContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;explicitRoot&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetEnvironmentVariable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"MIGRATIONS_CONFIG_PATH"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;explicitRoot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Directory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetCurrentDirectory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;explicitRoot&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;configuration&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ConfigurationBuilder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetBasePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddJsonFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"appsettings.DesignTime.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddEnvironmentVariables&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"MIGRATIONS_"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;configuredConnection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;configuration&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetConnectionString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Database"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;configuredConnection&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;UnreachableDesignTimeConnection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;
            &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;configuredConnection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;DbContextOptionsBuilder&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AppDbContext&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseSqlServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;AppDbContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The useful properties are more important than the exact APIs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An explicit root controls where design-time files are read.&lt;/li&gt;
&lt;li&gt;The current directory is a local, visible fallback.&lt;/li&gt;
&lt;li&gt;Environment variables are added last, so matching values override files.&lt;/li&gt;
&lt;li&gt;No unrelated application host is searched.&lt;/li&gt;
&lt;li&gt;Missing connection authority produces an impossible target, not a plausible default.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Avoid using &lt;code&gt;localhost&lt;/code&gt;, a familiar shared server, or an empty value as the sentinel. &lt;code&gt;localhost&lt;/code&gt; may contain real data. A familiar name defeats the boundary. An empty value may prevent the provider from constructing options, which blocks the offline work we are trying to preserve.&lt;/p&gt;

&lt;p&gt;The sentinel is only a defensive default. It does not replace least-privilege credentials, network controls, deployment review, backups, or a safe migration process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test both sides of the boundary
&lt;/h2&gt;

&lt;p&gt;Tests should describe the capability split, not merely prove that the factory returns a context.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;With no configuration, a model-only command can construct the context.&lt;/li&gt;
&lt;li&gt;With no configuration, a connecting command fails against the recognisable sentinel.&lt;/li&gt;
&lt;li&gt;With explicit configuration, a connecting command reaches only an isolated disposable database.&lt;/li&gt;
&lt;li&gt;Configuration precedence is deterministic and documented.&lt;/li&gt;
&lt;li&gt;Narrowing the configuration path does not change the model or hide migration history.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last check matters because a safety refactor should not accidentally produce schema drift. In the committed change that prompted this lesson, the recorded verification covered the existing test suite, migration discovery, and a model-drift check. This scheduled review did not rerun repository commands, so I treat those as committed evidence rather than fresh execution results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make invisible trust visible
&lt;/h2&gt;

&lt;p&gt;There is a real trade-off. Explicit configuration adds ceremony. A missing setting may not fail until a command actually tries to connect. Developers need a short guide explaining which commands work offline and how to opt into database access.&lt;/p&gt;

&lt;p&gt;That cost is modest compared with ambiguous success.&lt;/p&gt;

&lt;p&gt;Design-time tools sit outside the normal runtime composition root, but they can still hold production-capable privileges. Review their fallbacks as authorisation decisions. Removing one ambient source does not validate every remaining source; it removes one unintended-target path. Keep model work easy, narrow the permitted sources, and make missing configuration conspicuous.&lt;/p&gt;

&lt;p&gt;Where does your migration workflow still select authority on the operator’s behalf?&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>efcore</category>
      <category>architecture</category>
      <category>devops</category>
    </item>
    <item>
      <title>Make Invalid Identifier Formats Unrepresentable</title>
      <dc:creator>Ivan Rossouw</dc:creator>
      <pubDate>Sat, 08 Aug 2026 11:28:53 +0000</pubDate>
      <link>https://dev.to/iqtechsolutions/make-invalid-identifier-formats-unrepresentable-2lpg</link>
      <guid>https://dev.to/iqtechsolutions/make-invalid-identifier-formats-unrepresentable-2lpg</guid>
      <description>&lt;p&gt;The dangerous identifier bug is not always malformed input. Sometimes both values are valid, both parse successfully, and both refer to the same conceptual thing. They simply use different textual representations.&lt;/p&gt;

&lt;p&gt;That sounds cosmetic until a storage boundary compares them as raw strings.&lt;/p&gt;

&lt;p&gt;I recently reviewed a committed .NET refactor built around exactly that failure mode. One boundary stored a compact canonical key. Elsewhere, a runtime identifier was converted with another valid representation. The lookup returned no rows. Then the reader supplied a plausible default, so the application continued without an exception.&lt;/p&gt;

&lt;p&gt;The result was not obviously broken software. It was quietly wrong software.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two valid values can still disagree
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;Guid&lt;/code&gt; has several standard string formats. A database, cache, external API, or configuration catalogue may choose one of them as its canonical key. If another layer uses a different valid format, parsing both values proves very little. Raw equality still fails.&lt;/p&gt;

&lt;p&gt;The risk grows when “not found” is intentionally convenient:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;runtime identifier
    -&amp;gt; valid but non-canonical string
    -&amp;gt; exact lookup returns no row
    -&amp;gt; reader supplies defaults
    -&amp;gt; caller treats defaults as configured data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every step can be locally reasonable. Together they create a silent semantic failure.&lt;/p&gt;

&lt;p&gt;That failure direction matters. A thrown exception attracts attention. A believable fallback can survive code review, automated tests, and monitoring because the system remains green.&lt;/p&gt;

&lt;h2&gt;
  
  
  A helper method is still a convention
&lt;/h2&gt;

&lt;p&gt;The first repair is usually to change one conversion call. That fixes the immediate defect, but it leaves the real contract implicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Settings&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;LoadAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing in this signature tells a caller which representation is required. Any string compiles. A nearby comment or helper improves discoverability, but the compiler still cannot help.&lt;/p&gt;

&lt;p&gt;The same mistake can return six months later in a new call site, during a refactor, or inside a test fake that ignores the supplied key.&lt;/p&gt;

&lt;p&gt;If representation changes lookup semantics, the representation is part of the type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put canonicality at the service seam
&lt;/h2&gt;

&lt;p&gt;A small value type can make the contract explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Settings&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;LoadAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CatalogKey&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CatalogKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;runtimeId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LoadAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact implementation is less important than the responsibilities:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one factory converts the runtime identifier into the canonical form;&lt;/li&gt;
&lt;li&gt;one parser validates compact input, normalises case, and rejects hyphenated or malformed input;&lt;/li&gt;
&lt;li&gt;equality follows the canonical representation;&lt;/li&gt;
&lt;li&gt;service contracts accept the semantic key, not an arbitrary string;&lt;/li&gt;
&lt;li&gt;consumers unwrap the value only at the storage boundary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now a raw &lt;code&gt;Guid&lt;/code&gt; or &lt;code&gt;string&lt;/code&gt; cannot cross the seam accidentally. The caller must make the conversion decision explicitly, at a place where reviewers can see it.&lt;/p&gt;

&lt;p&gt;This is a useful modular-architecture pattern: translate once at the boundary, then carry a truthful type inside the module.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the default value fail loudly
&lt;/h2&gt;

&lt;p&gt;C# value types have one awkward edge: &lt;code&gt;default(T)&lt;/code&gt; exists even when no public constructor permits an empty value.&lt;/p&gt;

&lt;p&gt;Returning an empty string from an uninitialised key would recreate the original failure. The lookup would miss, the reader could fall back, and the invalid state would again appear healthy.&lt;/p&gt;

&lt;p&gt;For a boundary type like this, reading an uninitialised value should fail loudly. If callers genuinely need optionality, represent it explicitly with a nullable key or a result type. Do not let “missing” masquerade as a valid empty key.&lt;/p&gt;

&lt;p&gt;This is an important test case because factory-only tests cannot reach the runtime's zero-initialisation path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrate incrementally without claiming total safety
&lt;/h2&gt;

&lt;p&gt;Changing every string-based seam at once may create an unnecessarily large blast radius. The reviewed refactor migrated several related service contracts first and retained an architecture guard for older seams that still accepted raw strings.&lt;/p&gt;

&lt;p&gt;That is a pragmatic transition:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;introduce the semantic type;&lt;/li&gt;
&lt;li&gt;migrate the highest-risk boundary cluster;&lt;/li&gt;
&lt;li&gt;test factories, parsing, equality, and the default instance;&lt;/li&gt;
&lt;li&gt;keep a focused guard around the remaining convention-based surface;&lt;/li&gt;
&lt;li&gt;remove the guard only when the raw-string path truly disappears.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The type protects only APIs that require it. Keeping the guard acknowledges that partial migration honestly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-off: useful friction
&lt;/h2&gt;

&lt;p&gt;Strong boundary types create work. Signatures change. Call sites need explicit conversions. ORMs may require unwrapping to a local scalar before translating a query. Test fixtures that relied on permissive fakes may need more realistic assertions.&lt;/p&gt;

&lt;p&gt;That friction is the point when the alternative is a silent miss.&lt;/p&gt;

&lt;p&gt;A raw string optimises for movement. A semantic key optimises for correctness, discoverability, and reviewability. Use it where representation carries business or storage meaning, not for every incidental string in the application.&lt;/p&gt;

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

&lt;p&gt;Before leaving an identifier as a string, ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Can the same identifier have multiple valid representations?&lt;/li&gt;
&lt;li&gt;Does the downstream system compare the representation exactly?&lt;/li&gt;
&lt;li&gt;Can “not found” become a plausible default rather than an error?&lt;/li&gt;
&lt;li&gt;Is the required format visible in the method signature?&lt;/li&gt;
&lt;li&gt;Are uninitialised, malformed, and alternative-format paths tested?&lt;/li&gt;
&lt;li&gt;Are remaining raw-string seams still guarded during migration?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If several answers make you uncomfortable, the string convention is already a domain concept. Give it a name, give it a type, and make the wrong representation difficult to express.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>dotnet</category>
      <category>csharp</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Test the Wire, Not the Serializer</title>
      <dc:creator>Ivan Rossouw</dc:creator>
      <pubDate>Fri, 07 Aug 2026 14:33:11 +0000</pubDate>
      <link>https://dev.to/iqtechsolutions/test-the-wire-not-the-serializer-4e37</link>
      <guid>https://dev.to/iqtechsolutions/test-the-wire-not-the-serializer-4e37</guid>
      <description>&lt;p&gt;Two JSON serializers can each behave correctly and still fail when they meet.&lt;/p&gt;

&lt;p&gt;That sounds obvious in hindsight, but it is easy to miss in a .NET solution. An ASP.NET Core server may use System.Text.Json by default while a long-lived typed client, shared library, or older integration still uses Newtonsoft.Json. Both libraries can serialise ordinary objects. Both can pass their own round-trip tests. The failure appears only when one writes the payload and the other reads it.&lt;/p&gt;

&lt;p&gt;A recent committed C# fix made the distinction concrete. The private domain details are not important. The reusable lesson is: a same-serializer round trip proves self-consistency, not interoperability.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mismatch hides at the real boundary
&lt;/h2&gt;

&lt;p&gt;Imagine a response containing an abstract base type with two supported derived shapes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonPolymorphic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TypeDiscriminatorPropertyName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonDerivedType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ImmediateRule&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;"immediate"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonDerivedType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WindowRule&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;"window"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;abstract&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;DeliveryRule&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;System.Text.Json understands those attributes and writes a discriminator such as &lt;code&gt;"kind": "window"&lt;/code&gt;. A Newtonsoft.Json consumer does not automatically interpret System.Text.Json's polymorphism metadata. It sees the abstract base type, cannot choose a concrete type, and fails to construct the object.&lt;/p&gt;

&lt;p&gt;Nothing is malformed on the wire. The producer followed its contract. The consumer followed a different contract.&lt;/p&gt;

&lt;p&gt;This is why an in-process path may work while a REST-backed path fails. The in-process path never serialises. It passes the object directly and quietly bypasses the boundary that is broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat polymorphism as a wire protocol
&lt;/h2&gt;

&lt;p&gt;Once a payload carries derived types, the discriminator is not an implementation detail. It is protocol data.&lt;/p&gt;

&lt;p&gt;The contract needs to answer a few explicit questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What property identifies the concrete shape?&lt;/li&gt;
&lt;li&gt;Which discriminator values are supported?&lt;/li&gt;
&lt;li&gt;Are names case-sensitive?&lt;/li&gt;
&lt;li&gt;Which fields are required for each shape?&lt;/li&gt;
&lt;li&gt;What happens when the value is missing or unknown?&lt;/li&gt;
&lt;li&gt;Must the contract work in one direction or both?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Attributes on a C# type answer those questions only for libraries that understand the attributes. They do not make the protocol universal.&lt;/p&gt;

&lt;p&gt;In the change I reviewed, the practical repair was a compatibility converter for the second serializer. It read the discriminator emitted by the server and constructed the correct derived shape. Its write path emitted the same discriminator so payloads could also travel in the reverse direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross the boundary in the test
&lt;/h2&gt;

&lt;p&gt;The most valuable change was not the converter. It was the direction of the tests.&lt;/p&gt;

&lt;p&gt;A weak test looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;wire&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;copy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Deserialize&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BaseType&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;wire&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is useful, but it asks one library whether it agrees with itself.&lt;/p&gt;

&lt;p&gt;An interoperability test should model the deployed path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;wire&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;serverOptions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;received&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Newtonsoft&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JsonConvert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DeserializeObject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Envelope&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;wire&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the reverse direction exists, test that separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;wire&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Newtonsoft&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JsonConvert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SerializeObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;received&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Deserialize&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BaseType&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;wire&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;serverOptions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then repeat the test for every supported derived shape. A single happy subtype can hide a missing discriminator, a date-format difference, enum handling, nullability, or a field that only exists on another subtype.&lt;/p&gt;

&lt;p&gt;Also include a negative test for an unknown discriminator. Silently defaulting to a base shape can be more dangerous than failing, because it turns contract drift into plausible but incomplete data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The adapter has a cost
&lt;/h2&gt;

&lt;p&gt;An explicit converter duplicates contract knowledge. The base type, each serializer's configuration, the converter, and the tests must evolve together. Adding a subtype is no longer a one-line change.&lt;/p&gt;

&lt;p&gt;Standardising the whole system on one serializer can remove that duplication. It may also be a broad migration touching clients, stored payloads, casing, date handling, enum representation, reference loops, and error behaviour. A narrow adapter is often the safer repair when compatibility matters immediately.&lt;/p&gt;

&lt;p&gt;There is another implementation trap: a converter attached to a base type can re-enter itself if it delegates deserialisation of the derived type through the same configured serializer. Depending on the library, constructing the derived record explicitly or using a converter-free inner path can avoid recursion. Pin that behaviour with a focused test rather than relying on intuition.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical contract-test checklist
&lt;/h2&gt;

&lt;p&gt;Before calling a JSON contract covered, write down:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The actual producer library and configuration.&lt;/li&gt;
&lt;li&gt;The actual consumer library and configuration.&lt;/li&gt;
&lt;li&gt;Every supported polymorphic shape.&lt;/li&gt;
&lt;li&gt;The discriminator and required fields.&lt;/li&gt;
&lt;li&gt;Unknown, missing, null, and malformed behaviour.&lt;/li&gt;
&lt;li&gt;Every direction the payload travels in production.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The unit of confidence is not "this serializer can round-trip this type." It is "this producer's bytes can be consumed by that reader without losing meaning."&lt;/p&gt;

&lt;p&gt;Where are your contract tests still testing each endpoint in isolation instead of crossing the wire between them?&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>testing</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Reconcile Before You Expire: Authority Checks at Irreversible Boundaries</title>
      <dc:creator>Ivan Rossouw</dc:creator>
      <pubDate>Thu, 06 Aug 2026 10:53:57 +0000</pubDate>
      <link>https://dev.to/iqtechsolutions/reconcile-before-you-expire-authority-checks-at-irreversible-boundaries-4h2</link>
      <guid>https://dev.to/iqtechsolutions/reconcile-before-you-expire-authority-checks-at-irreversible-boundaries-4h2</guid>
      <description>&lt;p&gt;Expiry looks like housekeeping. A timestamp passes, a background worker finds the stale row, and the system moves it to a terminal state.&lt;/p&gt;

&lt;p&gt;That model is safe only when your database is authoritative for the outcome. The moment another system can complete the work, a timeout becomes much weaker evidence. It tells you that your local clock ran out. It does not prove that nothing happened elsewhere.&lt;/p&gt;

&lt;p&gt;A recent committed C# change made this distinction concrete. The implementation and names are private, but the lesson is broadly useful: before an expiry worker made an irreversible local transition, it first reconciled with the external authority.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Failure Hidden Inside a Timer
&lt;/h2&gt;

&lt;p&gt;Consider a generalized hosted operation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your application creates an intent with immutable expected values.&lt;/li&gt;
&lt;li&gt;An external system accepts the operation and returns a reference.&lt;/li&gt;
&lt;li&gt;The user or caller leaves your process.&lt;/li&gt;
&lt;li&gt;A callback or browser return normally confirms the result.&lt;/li&gt;
&lt;li&gt;Your local intent eventually reaches its expiry time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The dangerous assumption is step five: “No callback arrived, therefore the external operation did not complete.”&lt;/p&gt;

&lt;p&gt;Callbacks are delivery mechanisms, not proof of non-completion. A browser can close. A webhook can be delayed. A network path can fail after the external commit but before your acknowledgement. The local row can remain stale while the external outcome is already final.&lt;/p&gt;

&lt;p&gt;If a cleanup worker then marks that row expired, the data looks tidy while the business truth becomes harder to recover.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authority Is an Architectural Relationship
&lt;/h2&gt;

&lt;p&gt;The central design question is not “Which service runs the expiry job?” It is “Which system owns the fact we are about to assert?”&lt;/p&gt;

&lt;p&gt;Your database may be authoritative for your workflow state. The external system may still be authoritative for whether its operation completed. Those are different facts.&lt;/p&gt;

&lt;p&gt;That gives us a useful rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Before an irreversible transition, reconcile with the system that owns the outcome.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In practical terms, a worker can select expired candidates, find the external reference, locate a verifier for that kind of operation, and ask for the current outcome. A confirmed result should only be accepted when it matches the local intent’s frozen invariants, such as identity and expected value. Then the confirmation must be persisted before cleanup continues.&lt;/p&gt;

&lt;p&gt;The expiry worker does not invent new truth. It asks the authority and applies an already-defined verification contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preserve “Uncertain” as a Real State
&lt;/h2&gt;

&lt;p&gt;Distributed workflows rarely have only two honest answers. They usually have three:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Confirmed and matching: advance the workflow and persist the result.&lt;/li&gt;
&lt;li&gt;Clearly not completed: follow the normal expiry path.&lt;/li&gt;
&lt;li&gt;Unavailable, conflicting, or ambiguous: preserve evidence and route to review.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Collapsing the third answer into “failed” is where reliability problems become data-integrity problems.&lt;/p&gt;

&lt;p&gt;Suppose the authority times out during reconciliation. Failing the entire sweep may create a retry storm and block unrelated candidates. Expiring the operation anyway is worse: absence of an answer has been treated as a negative answer.&lt;/p&gt;

&lt;p&gt;A safer compromise is failure isolation. Log the verification problem, retain the external reference and relevant audit evidence, move the candidate to a reviewable state, and let the sweep continue. Uncertainty remains visible and recoverable.&lt;/p&gt;

&lt;p&gt;This pattern also makes operational ownership clearer. The manual-review queue is not an embarrassment. It is the explicit cost of refusing to manufacture certainty.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trade-Off Is Real
&lt;/h2&gt;

&lt;p&gt;Reconciliation adds work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;another network dependency in a scheduled process;&lt;/li&gt;
&lt;li&gt;extra latency and provider load;&lt;/li&gt;
&lt;li&gt;rate-limit and back-off concerns;&lt;/li&gt;
&lt;li&gt;longer retention for ambiguous records;&lt;/li&gt;
&lt;li&gt;an operational queue that someone must own;&lt;/li&gt;
&lt;li&gt;more state-transition and concurrency tests.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those costs should be designed, not ignored. Batch candidates. Bound concurrency. Use cancellation and timeouts. Record the last reconciliation attempt. Back off repeated uncertainty. Make confirmation idempotent. Prevent a concurrent callback and the sweep from applying contradictory transitions.&lt;/p&gt;

&lt;p&gt;The return is not merely “fewer bugs.” It is a stronger integrity boundary. An automated cleanup task can no longer silently overwrite a result owned elsewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the Missed-Acknowledgement Paths
&lt;/h2&gt;

&lt;p&gt;A happy-path expiry test proves very little. The valuable regression tests exercise the boundary:&lt;/p&gt;

&lt;h3&gt;
  
  
  Completed, but the acknowledgement was missed
&lt;/h3&gt;

&lt;p&gt;The external verifier reports a matching completion. The worker persists it and does not expire the operation.&lt;/p&gt;

&lt;h3&gt;
  
  
  The authority is unavailable
&lt;/h3&gt;

&lt;p&gt;Verification throws or times out. The sweep continues, but the operation retains its evidence and moves to review rather than a clean terminal failure.&lt;/p&gt;

&lt;h3&gt;
  
  
  The authority does not confirm completion
&lt;/h3&gt;

&lt;p&gt;The worker follows the documented evidence-preserving policy. Depending on the remaining evidence, that may mean normal expiry or review. The important part is that a single inconclusive query does not erase stronger durable evidence.&lt;/p&gt;

&lt;p&gt;I would add two more tests where risk justifies them: a callback racing the sweep, and a repeated reconciliation proving idempotency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Else This Applies
&lt;/h2&gt;

&lt;p&gt;The same authority check appears far beyond one kind of integration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a deployment controller deleting resources after a local timeout;&lt;/li&gt;
&lt;li&gt;a message dispatcher retrying when the broker may already have accepted the message;&lt;/li&gt;
&lt;li&gt;a provisioning job rolling back while the cloud control plane is still converging;&lt;/li&gt;
&lt;li&gt;a refund workflow closing locally before the financial system settles;&lt;/li&gt;
&lt;li&gt;a reservation expiring while the downstream allocation already exists.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whenever another system can cross the irreversible boundary, your cleanup job needs more than a clock.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Practical Review Checklist
&lt;/h2&gt;

&lt;p&gt;Before shipping an expiry or cleanup worker, ask:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Which system is authoritative for the outcome?&lt;/li&gt;
&lt;li&gt;Can completion occur without our acknowledgement arriving?&lt;/li&gt;
&lt;li&gt;What durable reference lets us reconcile later?&lt;/li&gt;
&lt;li&gt;Which invariants must match before we accept confirmation?&lt;/li&gt;
&lt;li&gt;What state preserves uncertainty without blocking the whole batch?&lt;/li&gt;
&lt;li&gt;Are confirmation, retry, and concurrent callbacks idempotent?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Timeouts are useful scheduling signals. They are not universal evidence of failure. At an irreversible boundary, ask the authority, match the facts, and preserve ambiguity.&lt;/p&gt;

&lt;p&gt;Where does one of your cleanup jobs currently make a decision that belongs to another system?&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>architecture</category>
      <category>sre</category>
    </item>
    <item>
      <title>A URL Is Only Safe for the Sink That Consumes It</title>
      <dc:creator>Ivan Rossouw</dc:creator>
      <pubDate>Wed, 05 Aug 2026 11:55:54 +0000</pubDate>
      <link>https://dev.to/iqtechsolutions/a-url-is-only-safe-for-the-sink-that-consumes-it-3m1i</link>
      <guid>https://dev.to/iqtechsolutions/a-url-is-only-safe-for-the-sink-that-consumes-it-3m1i</guid>
      <description>&lt;p&gt;Safety is not a permanent property of a string. A value can be acceptable in one browser context and dangerous in another.&lt;/p&gt;

&lt;p&gt;That sounds obvious when we compare HTML text with executable script. It is easier to miss when both values look like URLs.&lt;/p&gt;

&lt;p&gt;A recent committed correction I reviewed had exactly that shape. A shared resolver had been designed for asset sources. In that context, the application intentionally supported a wider set of values, including inline media. The same resolver was then reused for clickable anchors. Its name still sounded reassuring, but the destination had gained more power: the browser could now navigate when a person clicked the value.&lt;/p&gt;

&lt;p&gt;The lesson is simple: validate for the sink that consumes the value.&lt;/p&gt;

&lt;h2&gt;
  
  
  One resolver was doing two different jobs
&lt;/h2&gt;

&lt;p&gt;Consider these two destinations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"..."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"..."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Open document&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both attributes receive a URL-shaped string, but they are not the same contract. An application may deliberately permit a constrained inline image source while refusing that same scheme as a navigation target. An anchor can also hand control to an external origin or a custom application handler.&lt;/p&gt;

&lt;p&gt;A helper called something broad such as &lt;code&gt;NormaliseUrl&lt;/code&gt; hides this difference. Callers see a normalised string and may infer that it is safe everywhere. The helper has accidentally become a security promise it cannot keep.&lt;/p&gt;

&lt;p&gt;Prefer contracts that name the destination: &lt;code&gt;ResolveAssetSource&lt;/code&gt;, &lt;code&gt;ResolveAnchorHref&lt;/code&gt;, or another equally explicit boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resolve first, then apply policy
&lt;/h2&gt;

&lt;p&gt;Validation order matters. Relative values may be combined with a trusted base URI, while absolute values can ignore that base entirely. A check performed only on the raw input can therefore approve one shape and produce another after resolution.&lt;/p&gt;

&lt;p&gt;The safer sequence is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Trim and normalise the input.&lt;/li&gt;
&lt;li&gt;Resolve it against the expected base when appropriate.&lt;/li&gt;
&lt;li&gt;Parse the final result.&lt;/li&gt;
&lt;li&gt;Apply the allowlist for the exact destination.&lt;/li&gt;
&lt;li&gt;Return a deliberate, non-executable fallback when the policy rejects the scheme.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is a simplified, newly written example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nf"&gt;SafeAnchorHref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Uri&lt;/span&gt; &lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"#"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;normalised&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;'\\'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sc"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;normalised&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"//"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StringComparison&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ordinal&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"#"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;resolved&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ResolveAgainstOrigin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;normalised&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;IsAllowedForAnchor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;resolved&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"#"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;#&lt;/code&gt; fallback prevents external or script-scheme navigation, but it can still affect scroll position or history. Where the interface must truly do nothing, render plain text or omit the link instead.&lt;/p&gt;

&lt;p&gt;The exact policy will differ by product. Some systems may need mail links or a carefully controlled app scheme. The important part is that each additional capability is explicit, reviewed, and tested. “Anything URI-shaped” is not a useful allowlist.&lt;/p&gt;

&lt;p&gt;A scheme allowlist only controls what the browser may execute or delegate. It does not prove that an HTTP or HTTPS destination is trustworthy; origin policy, redirect handling, download policy, and content controls are separate decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make rejection boring
&lt;/h2&gt;

&lt;p&gt;Security controls work better when rejected data degrades predictably.&lt;/p&gt;

&lt;p&gt;For a document list, that might mean keeping a safely encoded display label but replacing the navigation target with a same-document fragment. Another interface might render plain text instead of an anchor. Either choice is preferable to throwing during rendering or letting the browser interpret an unexpected scheme.&lt;/p&gt;

&lt;p&gt;Display text and navigation data should also be separate. Decoding an encoded filename for readability does not mean the decoded string should become the &lt;code&gt;href&lt;/code&gt;. The label goes through HTML text encoding; the resolved navigation value goes through the anchor policy.&lt;/p&gt;

&lt;p&gt;This separation makes both behaviours easier to reason about.&lt;/p&gt;

&lt;h2&gt;
  
  
  A new tab is another boundary
&lt;/h2&gt;

&lt;p&gt;Opening external links in a new tab or window adds a second small contract. Set the component’s real target parameter rather than assuming an arbitrary attribute will survive rendering. Pair a blank target with &lt;code&gt;noopener&lt;/code&gt; and &lt;code&gt;noreferrer&lt;/code&gt; when that matches the product’s privacy policy.&lt;/p&gt;

&lt;p&gt;These details are easy to dismiss as markup polish. They control whether the opened page can reach its opener and whether it receives a referrer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test boundary shapes, not only happy URLs
&lt;/h2&gt;

&lt;p&gt;The focused tests in the reviewed change were valuable because they described the boundary, not the implementation. A useful anchor-policy suite should include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;mixed-case script-capable schemes;&lt;/li&gt;
&lt;li&gt;inline-data, blob, file, intent, and unknown custom schemes;&lt;/li&gt;
&lt;li&gt;protocol-relative values;&lt;/li&gt;
&lt;li&gt;malformed and blank input;&lt;/li&gt;
&lt;li&gt;valid HTTP and HTTPS URLs;&lt;/li&gt;
&lt;li&gt;root-relative paths and fragments;&lt;/li&gt;
&lt;li&gt;percent-encoded filenames;&lt;/li&gt;
&lt;li&gt;the expected target and relationship attributes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also test the asset-source policy separately. A security fix for anchors should not silently break a legitimate image path elsewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-off: compatibility for explicit safety
&lt;/h2&gt;

&lt;p&gt;A narrow allowlist can replace old dirty links with a same-document fallback. It can also block a legitimate integration until its scheme is deliberately supported. That is real compatibility work, not a reason to keep the boundary broad.&lt;/p&gt;

&lt;p&gt;The alternative is worse: a generic helper quietly expands its promise as it moves into more powerful contexts. Rejection is deterministic, fails closed, and leaves the underlying data repairable. An unsafe navigation path may remain unnoticed until someone supplies the wrong stored value.&lt;/p&gt;

&lt;p&gt;My practical rule is now: when data crosses into a browser attribute, name the sink, validate the final resolved value, and test every capability you intend to allow.&lt;/p&gt;

&lt;p&gt;Where in your application does a “safe” URL move between contexts with different powers?&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>security</category>
      <category>blazor</category>
    </item>
    <item>
      <title>Your Blazor Test Is Racing the Renderer</title>
      <dc:creator>Ivan Rossouw</dc:creator>
      <pubDate>Tue, 04 Aug 2026 06:12:12 +0000</pubDate>
      <link>https://dev.to/iqtechsolutions/your-blazor-test-is-racing-the-renderer-3b1n</link>
      <guid>https://dev.to/iqtechsolutions/your-blazor-test-is-racing-the-renderer-3b1n</guid>
      <description>&lt;p&gt;A flaky component test can look almost insultingly simple: the test finds a button, raises its event, and occasionally observes nothing. The tempting responses are familiar - add a delay, retry the interaction, or blame the component's asynchronous work.&lt;/p&gt;

&lt;p&gt;Sometimes the more useful question is: which render did the test actually interact with?&lt;/p&gt;

&lt;p&gt;A recent focused test correction highlighted a narrow Blazor lesson. The test located an element and dispatched its event as separate steps outside one renderer-owned action. An asynchronous re-render could occur between those steps. The stored element then belonged to an older render tree, and the test no longer had a reliable path to the current handler.&lt;/p&gt;

&lt;p&gt;The correction was small: schedule element lookup and event dispatch together through the renderer, then wait for an observable postcondition. The principle is broader than that one test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap between finding and dispatching
&lt;/h2&gt;

&lt;p&gt;Blazor's renderer owns component rendering and event processing. A component may update after a callback, a completed task, a parameter change, or a service notification. Those updates can replace elements and their associated handlers.&lt;/p&gt;

&lt;p&gt;Test code, meanwhile, often reads like ordinary sequential C#:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;var save = rendered.Find("[data-action='save']");

// An asynchronous state change may render here.

save.Click();
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The selector can be perfectly correct when Find runs. That does not guarantee that the captured wrapper still represents the active element when Click runs.&lt;/p&gt;

&lt;p&gt;The vulnerable window may be tiny. It can disappear while debugging, widen under load, or change when unrelated assertions are added. That makes arbitrary delays especially seductive. A delay changes the timing, but it does not define ownership or prove that the current render received the event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the interaction one renderer-owned action
&lt;/h2&gt;

&lt;p&gt;The safer shape is to keep acquisition and dispatch inside a single action scheduled through the renderer:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;await rendered.InvokeAsync(() =&amp;gt;
{
    rendered.Find("[data-action='save']").Click();
});
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Exact APIs differ between test libraries, but the important unit is the same:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Enter the renderer's dispatch context.&lt;/li&gt;
&lt;li&gt;Find the element from the current render.&lt;/li&gt;
&lt;li&gt;Raise its event before leaving that action.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Re-querying avoids carrying an element across a possible render boundary. Dispatching through the renderer aligns the test interaction with Blazor's event-processing model.&lt;/p&gt;

&lt;p&gt;This is not a call to put the entire test inside one renderer callback. Keep the critical interaction small. Arrange inputs first, wait for any required precondition, then combine only the final lookup and event dispatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wait for behaviour, not elapsed time
&lt;/h2&gt;

&lt;p&gt;Dispatching an event is not the behaviour the user cares about. The useful contract is what becomes observable afterwards.&lt;/p&gt;

&lt;p&gt;Depending on the component, that might be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;rendered confirmation or validation text;&lt;/li&gt;
&lt;li&gt;a button becoming disabled or enabled;&lt;/li&gt;
&lt;li&gt;navigation to an expected route;&lt;/li&gt;
&lt;li&gt;one recorded call to a dependency;&lt;/li&gt;
&lt;li&gt;a loading indicator appearing and then clearing; or&lt;/li&gt;
&lt;li&gt;preserved input after a failed operation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use the test library's bounded wait-for-state or wait-for-assertion facility around that outcome:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;await rendered.InvokeAsync(() =&amp;gt;
{
    rendered.Find("[data-action='save']").Click();
});

rendered.WaitForAssertion(() =&amp;gt;
{
    Assert.Contains("Saved", rendered.Markup);
});
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;A bounded wait says, "this state must eventually become true." An arbitrary sleep says only, "pause and hope the machine is fast enough." The first expresses a contract and can fail with useful evidence. The second tends to be both slow and fragile.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-off: more ceremony, clearer intent
&lt;/h2&gt;

&lt;p&gt;This pattern adds ceremony. Repeated interactions may need a small helper, and every test must identify a meaningful postcondition. That costs more than storing an element once and clicking it later.&lt;/p&gt;

&lt;p&gt;There is also a risk of over-correction. Wrapping broad portions of a test in the renderer dispatcher can over-serialise the scenario and hide useful concurrency signals. Dispatcher usage should not bypass a real readiness requirement either. If the button should appear only after data loads, first wait for that visible precondition; then find and dispatch against the current tree.&lt;/p&gt;

&lt;p&gt;The payoff is precision. The test says which boundary owns the interaction and which behaviour proves completion. Failures become less dependent on machine timing and more closely describe the user-facing contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the evidence does - and does not - show
&lt;/h2&gt;

&lt;p&gt;This lesson comes from reviewing a focused committed correction and the surrounding component flow. The change is consistent with a race between element acquisition, asynchronous rendering, and event dispatch.&lt;/p&gt;

&lt;p&gt;I did not run the full suite, repeat the test under stress, or prove that every intermittent component failure has this cause. A passing correction also does not prove the production component is free of concurrency defects. The evidence supports a boundary worth protecting, not a universal diagnosis.&lt;/p&gt;

&lt;p&gt;A practical review checklist is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Can the component re-render between lookup and dispatch?&lt;/li&gt;
&lt;li&gt;Are lookup and event dispatch one renderer-scheduled action?&lt;/li&gt;
&lt;li&gt;Does the assertion describe an observable outcome?&lt;/li&gt;
&lt;li&gt;Is waiting bounded and state-based rather than time-based?&lt;/li&gt;
&lt;li&gt;Has repeated execution been used before making a stability claim?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The smallest reliable component tests respect the renderer without making timing itself part of the specification. Where could one of your tests be holding an element across a render boundary?&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>blazor</category>
      <category>testing</category>
      <category>csharp</category>
    </item>
    <item>
      <title>A Green Scheduler Is Not Proof</title>
      <dc:creator>Ivan Rossouw</dc:creator>
      <pubDate>Mon, 03 Aug 2026 05:48:30 +0000</pubDate>
      <link>https://dev.to/iqtechsolutions/a-green-scheduler-is-not-proof-4ah5</link>
      <guid>https://dev.to/iqtechsolutions/a-green-scheduler-is-not-proof-4ah5</guid>
      <description>&lt;p&gt;A background-job dashboard usually answers a narrow question: did the scheduled method return, fail, or retry?&lt;/p&gt;

&lt;p&gt;That is useful, but it is not the same as knowing whether the intended work completed. The difference matters most when a job catches an internal failure deliberately. Perhaps one phase failed after another phase had already succeeded. Perhaps retrying the entire job would duplicate work or create a retry storm. Returning normally can be the responsible execution decision while still representing a failed business outcome.&lt;/p&gt;

&lt;p&gt;If the dashboard only sees the return value, it can be technically accurate and operationally misleading.&lt;/p&gt;

&lt;h2&gt;
  
  
  The green light that hid a failure
&lt;/h2&gt;

&lt;p&gt;In recent committed work I reviewed, recurring jobs were given their own durable outcome evidence. The change was motivated by a concrete failure shape: a terminal phase could be caught and logged, the method could return, and the scheduler could therefore record success.&lt;/p&gt;

&lt;p&gt;The lesson is not that the scheduler lied. It reported what it owned: execution state. The job owned a different fact: whether its useful work reached an acceptable outcome.&lt;/p&gt;

&lt;p&gt;Those are two separate contracts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Execution contract:&lt;/strong&gt; Was the job invoked? Did it return or throw? Was it retried?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outcome contract:&lt;/strong&gt; Did the intended work complete, fail, or deliberately skip? What safe evidence supports that answer?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Trying to squeeze both contracts into one green or red scheduler badge loses information.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use two independent witnesses
&lt;/h2&gt;

&lt;p&gt;The stronger design keeps scheduler state and job-owned evidence separate, then cross-checks them.&lt;/p&gt;

&lt;p&gt;At the last responsible boundary, the job writes a small durable record containing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a stable job key;&lt;/li&gt;
&lt;li&gt;start and completion times;&lt;/li&gt;
&lt;li&gt;a semantic outcome;&lt;/li&gt;
&lt;li&gt;a short detail suitable for operations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The monitor reads that record alongside the scheduler's latest state. Neither source gets trusted blindly. Agreement is useful evidence; disagreement is itself a result.&lt;/p&gt;

&lt;p&gt;That gives the dashboard three honest answers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Verified good&lt;/strong&gt; — fresh successful job evidence has no fresh scheduler failure contradicting it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verified bad&lt;/strong&gt; — evidence reports failure, is missing or stale, the job is not scheduled or running, or the two sources contradict.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unverified&lt;/strong&gt; — a monitoring or collection source is unavailable, so no trustworthy cross-check is possible.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The distinction matters. Missing evidence is a failed health check; an unavailable source is unverified. Neither may silently become success. Empty evidence is not positive evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put evidence around the whole outcome
&lt;/h2&gt;

&lt;p&gt;A generalised C# shape looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;startedAt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetUtcNow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;RunWorkAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Acceptable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryWriteFailureAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;startedAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SafeSummary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryWriteSuccessAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;startedAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SafeSummary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryWriteFailureAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;startedAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;SafeSummary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is deliberately broader than wrapping only thrown exceptions. A caught terminal failure still needs failed evidence even when returning is the correct retry policy. A deliberate no-op or skip also needs an explicit, explainable result rather than disappearing into silence.&lt;/p&gt;

&lt;p&gt;Keep the detail short and safe. It should help distinguish outcomes without copying personal data, secrets, request payloads, or sensitive identifiers into an operations table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evidence writes should be best-effort
&lt;/h2&gt;

&lt;p&gt;There is an uncomfortable trade-off here. If the evidence store is unavailable, should an otherwise healthy job fail and retry?&lt;/p&gt;

&lt;p&gt;Usually, no. Observability should not become an outage amplifier.&lt;/p&gt;

&lt;p&gt;The committed design I reviewed treated evidence writes as best-effort: log the write failure through an independent path, do not fail the useful work, and let the missing record surface as a failed health check during the next cross-check. Reads, however, must fail honestly. If the monitor cannot inspect its sources, it should say so rather than displaying green.&lt;/p&gt;

&lt;p&gt;This creates a useful asymmetry:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the job remains resilient when telemetry storage fails;&lt;/li&gt;
&lt;li&gt;the dashboard remains sceptical when proof is absent.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Freshness belongs to the contract
&lt;/h2&gt;

&lt;p&gt;Missing evidence only has meaning relative to cadence. An hourly job and a weekly job cannot share the same stale threshold.&lt;/p&gt;

&lt;p&gt;Derive an evidence window from the schedule, then add a small allowance for normal delay, restarts, and queueing. Store the timestamps in UTC. Make the threshold visible so operators know why a result is stale or failed.&lt;/p&gt;

&lt;p&gt;Without that rule, a months-old success row can keep a dead job looking healthy forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the disagreements, not only the happy path
&lt;/h2&gt;

&lt;p&gt;The focused tests in the reviewed change covered more than a successful write. That is the right instinct. At minimum, exercise:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;successful completion with fresh evidence;&lt;/li&gt;
&lt;li&gt;a deliberate skip with an explanatory detail;&lt;/li&gt;
&lt;li&gt;a caught failure that does not escape the job;&lt;/li&gt;
&lt;li&gt;an exception that is recorded and then rethrown;&lt;/li&gt;
&lt;li&gt;scheduler success with no fresh evidence;&lt;/li&gt;
&lt;li&gt;contradictory scheduler and job outcomes;&lt;/li&gt;
&lt;li&gt;an unavailable scheduler monitor;&lt;/li&gt;
&lt;li&gt;an evidence-store write failure that must not break useful work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These cases prove that the dashboard can produce good, bad, and unverified answers. A diagnostic that can only turn green is not a diagnostic.&lt;/p&gt;

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

&lt;p&gt;This pattern adds a table, retention, instrumentation, dependency registration, an evaluator, and tests. It is more ceremony than checking the scheduler dashboard.&lt;/p&gt;

&lt;p&gt;In return, it separates process health from work outcome, makes missing telemetry visible, and gives incident investigation a falsifiable trail. For high-value recurring work, that is usually a worthwhile exchange.&lt;/p&gt;

&lt;p&gt;Start with one important job. Define what success actually means, record it at the boundary, cross-check it against the scheduler, and refuse to colour unknown evidence green.&lt;/p&gt;

&lt;p&gt;Which background job in your system would look successful after a caught failure today?&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>observability</category>
      <category>sre</category>
    </item>
    <item>
      <title>One New State Means a Projection Audit</title>
      <dc:creator>Ivan Rossouw</dc:creator>
      <pubDate>Sun, 02 Aug 2026 10:08:44 +0000</pubDate>
      <link>https://dev.to/iqtechsolutions/one-new-state-means-a-projection-audit-2341</link>
      <guid>https://dev.to/iqtechsolutions/one-new-state-means-a-projection-audit-2341</guid>
      <description>&lt;p&gt;The migration was three columns. The behaviour change touched eleven files and introduced sixteen focused integration tests.&lt;/p&gt;

&lt;p&gt;That ratio is a useful engineering signal.&lt;/p&gt;

&lt;p&gt;A recent committed change added a soft cancellation state to a small operational workflow. Storing the state was straightforward. The real work was deciding what that state meant to every queue, count, aggregate, snapshot, export, transition, and recovery path that already interpreted the record.&lt;/p&gt;

&lt;p&gt;The broader lesson is simple: a workflow state is not merely data. It is a contract across every projection and adjacent transition.&lt;/p&gt;

&lt;h2&gt;
  
  
  A state changes meaning, not only storage
&lt;/h2&gt;

&lt;p&gt;Imagine an operational record that can be active, completed, or cancelled. Adding &lt;code&gt;CancelledAt&lt;/code&gt; answers one narrow question: how is cancellation represented?&lt;/p&gt;

&lt;p&gt;It does not answer the questions users and downstream systems actually ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Should cancelled work remain in the active queue?&lt;/li&gt;
&lt;li&gt;Should it count as outstanding work?&lt;/li&gt;
&lt;li&gt;Does it contribute to financial or operational totals?&lt;/li&gt;
&lt;li&gt;Should history retain it or hide it?&lt;/li&gt;
&lt;li&gt;What should an immutable end-of-period snapshot record?&lt;/li&gt;
&lt;li&gt;Should a detailed export preserve the row while a summary export excludes it?&lt;/li&gt;
&lt;li&gt;Can the state be restored?&lt;/li&gt;
&lt;li&gt;What happens if a success event arrives after cancellation?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each answer changes a read model or a transition. Leaving one answer implicit is how a locally correct filter becomes a system-wide contradiction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build a projection matrix before changing code
&lt;/h2&gt;

&lt;p&gt;A small state-impact matrix makes those decisions reviewable:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Consumer&lt;/th&gt;
&lt;th&gt;Cancelled record&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Active work queue&lt;/td&gt;
&lt;td&gt;Exclude&lt;/td&gt;
&lt;td&gt;Nobody should act on it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Follow-up count&lt;/td&gt;
&lt;td&gt;Exclude&lt;/td&gt;
&lt;td&gt;It is no longer outstanding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Review history&lt;/td&gt;
&lt;td&gt;Relocate&lt;/td&gt;
&lt;td&gt;Preserve an auditable explanation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live totals&lt;/td&gt;
&lt;td&gt;Exclude&lt;/td&gt;
&lt;td&gt;Do not treat it as completed activity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Closed snapshot&lt;/td&gt;
&lt;td&gt;Aggregate separately&lt;/td&gt;
&lt;td&gt;Preserve historical reconciliation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Detailed export&lt;/td&gt;
&lt;td&gt;Include and annotate&lt;/td&gt;
&lt;td&gt;Keep the event visible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Summary export&lt;/td&gt;
&lt;td&gt;Exclude&lt;/td&gt;
&lt;td&gt;Do not inflate completed quantities&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The exact answers will differ by domain. The useful pattern is the set of verbs: &lt;strong&gt;include, exclude, relocate, annotate, or aggregate separately&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Even “no change” should be an explicit decision. A projection may already exclude the new state because another invariant makes the two conditions mutually exclusive. That is still worth recording. Otherwise a future change can break the hidden relationship without touching the projection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Transitions belong in the same review
&lt;/h2&gt;

&lt;p&gt;Read models are only half the contract. The state machine also needs rules around entry, exit, and competing events.&lt;/p&gt;

&lt;p&gt;For a soft state, useful questions include:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Which states may enter cancellation?&lt;/li&gt;
&lt;li&gt;Is repeating the command harmless?&lt;/li&gt;
&lt;li&gt;Can an operator restore the record?&lt;/li&gt;
&lt;li&gt;Which later events must clear or supersede cancellation?&lt;/li&gt;
&lt;li&gt;What audit information survives every transition?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The reviewed work made cancellation idempotent, provided a restore path, rejected one invalid adjacent state, and declared that a late success event should take precedence. Those behaviours were backed by focused tests.&lt;/p&gt;

&lt;p&gt;There is an important limit, though. A sequential test—cancel, then process success—proves ordering in that sequence. It does not prove safety when two handlers read and write concurrently. If the race is plausible, enforce the precedence with optimistic concurrency, a conditional update, or another database-level invariant. A comment and a unit test cannot make overlapping writes atomic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-off: ceremony versus drift
&lt;/h2&gt;

&lt;p&gt;A state matrix, explicit transition rules, and cross-projection tests cost more than adding a Boolean and scattering a few filters. They also create more places that must evolve when the workflow changes again.&lt;/p&gt;

&lt;p&gt;That cost is real. Keep the matrix small, centralise shared predicates where the semantics truly match, and avoid inventing a heavyweight state-machine framework for a tiny workflow.&lt;/p&gt;

&lt;p&gt;The return is operational consistency. Without the ceremony, an active queue can exclude a record while its badge still counts it. A live total can exclude it while a frozen snapshot includes it. A detailed export can preserve it while a summary quietly treats it as completed. Every query may compile and still tell a different story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test crossings, not implementation details
&lt;/h2&gt;

&lt;p&gt;The strongest tests exercise the boundaries between a transition and its consumers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;cancelling removes the record from active and follow-up views;&lt;/li&gt;
&lt;li&gt;restoring returns it to the correct view;&lt;/li&gt;
&lt;li&gt;repeated cancellation is harmless;&lt;/li&gt;
&lt;li&gt;live counts match their corresponding lists;&lt;/li&gt;
&lt;li&gt;live totals and historical snapshots apply the same meaning;&lt;/li&gt;
&lt;li&gt;detailed and summary exports make their different policies visible;&lt;/li&gt;
&lt;li&gt;a later event follows the declared precedence rule.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These tests are more valuable than asserting that a property became non-null. They protect the semantics users rely on.&lt;/p&gt;

&lt;p&gt;Integration coverage still has limits. Tests against a lightweight database can validate real query shapes without proving the production migration, provider-specific behaviour, complete UI wiring, or true concurrency. Name those limits so a green suite is evidence, not theatre.&lt;/p&gt;

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

&lt;p&gt;When adding a workflow state:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Draw the allowed transitions.&lt;/li&gt;
&lt;li&gt;Inventory queues, counters, aggregates, snapshots, exports, notifications, and recovery jobs.&lt;/li&gt;
&lt;li&gt;Mark each consumer include, exclude, relocate, annotate, or separate.&lt;/li&gt;
&lt;li&gt;Define idempotency and race precedence.&lt;/li&gt;
&lt;li&gt;Test each transition-to-projection crossing.&lt;/li&gt;
&lt;li&gt;Add database protection where concurrency matters.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The write is usually the smallest part of a state change. Audit the reads, because that is where the system explains what the state means.&lt;/p&gt;

&lt;p&gt;Which projection in your system would be easiest to overlook?&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>architecture</category>
      <category>testing</category>
    </item>
    <item>
      <title>The Date Was Visible. The Model Was Null.</title>
      <dc:creator>Ivan Rossouw</dc:creator>
      <pubDate>Sat, 01 Aug 2026 06:53:43 +0000</pubDate>
      <link>https://dev.to/iqtechsolutions/the-date-was-visible-the-model-was-null-1jad</link>
      <guid>https://dev.to/iqtechsolutions/the-date-was-visible-the-model-was-null-1jad</guid>
      <description>&lt;h1&gt;
  
  
  The Date Was Visible. The Model Was Null.
&lt;/h1&gt;

&lt;p&gt;The form showed a selected date. Save said the date was required.&lt;/p&gt;

&lt;p&gt;That sounds like a validation bug, but the validator was reporting the truth: the nullable .NET property was still &lt;code&gt;null&lt;/code&gt;. The browser and the application had quietly diverged.&lt;/p&gt;

&lt;p&gt;A recent committed fix made the underlying lesson unusually clear. A native HTML date input and a component converter were both doing reasonable things, but they did not share the same formatting contract. The control displayed state that the model had never accepted.&lt;/p&gt;

&lt;p&gt;The lesson is broader than one component: visible UI state is not proof of bound application state.&lt;/p&gt;

&lt;h2&gt;
  
  
  One field can have three representations
&lt;/h2&gt;

&lt;p&gt;A date field often crosses three different representations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The human-facing value rendered by the browser.&lt;/li&gt;
&lt;li&gt;The wire value exchanged by the native input.&lt;/li&gt;
&lt;li&gt;The typed value stored in the .NET model.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For &lt;code&gt;&amp;lt;input type="date"&amp;gt;&lt;/code&gt;, the browser may display a familiar local format, but its value contract is an ISO-shaped calendar date such as &lt;code&gt;2026-08-01&lt;/code&gt;. The display can vary; the wire shape does not.&lt;/p&gt;

&lt;p&gt;A surrounding Blazor component may have a different default. Its converter can format and parse &lt;code&gt;DateTime?&lt;/code&gt; using the current culture's short-date pattern. Depending on culture, that pattern may use slashes, a different field order, or another separator.&lt;/p&gt;

&lt;p&gt;Both behaviours are defensible in isolation. Combining them without an explicit adapter creates an accidental protocol.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure works in both directions
&lt;/h2&gt;

&lt;p&gt;Consider the browser-to-model direction first.&lt;/p&gt;

&lt;p&gt;The user selects a date. The native control emits an ISO value. A culture-aware converter expects the current short-date format instead. Conversion fails, so the nullable property is not updated. The browser can still display the selection it owns, making the next validation message look absurd.&lt;/p&gt;

&lt;p&gt;Now reverse the flow.&lt;/p&gt;

&lt;p&gt;An edit form loads an existing date from the model. The converter produces a culture-formatted string. The native date input accepts only its ISO value shape, rejects the text, and renders an empty control. Saving from that state can turn a display problem into lost data if the application treats the empty field as an intentional clear.&lt;/p&gt;

&lt;p&gt;This is why testing only the validator, mapper, or service misses the defect. Those layers never exercise the disagreement between the browser contract and the component converter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give the boundary one owner
&lt;/h2&gt;

&lt;p&gt;The reviewed fix chose a date-picker component that owns both its picker UI and its typed binding contract. That was also the established convention in the surrounding application, which reduced novelty and made the correction smaller.&lt;/p&gt;

&lt;p&gt;That is one valid answer, not the only one. If a native date input is important, make the bridge explicit. A custom &lt;code&gt;InputBase&amp;lt;DateOnly?&amp;gt;&lt;/code&gt;, an explicit get/set binding adapter, or a converter that deliberately uses the native ISO shape can all work.&lt;/p&gt;

&lt;p&gt;The important design question is ownership:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Who formats model state for the control?&lt;/li&gt;
&lt;li&gt;Who parses the control value back into the model?&lt;/li&gt;
&lt;li&gt;Is the same contract used in both directions?&lt;/li&gt;
&lt;li&gt;Where does a conversion failure become visible?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For date-only business concepts, &lt;code&gt;DateOnly&lt;/code&gt; can also express intent more accurately than a midnight &lt;code&gt;DateTime&lt;/code&gt;. It does not remove the wire-format problem, but it prevents time zones and time-of-day semantics from entering a calendar-only workflow unnecessarily.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the binding, not just the strings
&lt;/h2&gt;

&lt;p&gt;A useful regression suite exercises the complete field boundary under representative cultures.&lt;/p&gt;

&lt;p&gt;Start with these cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A user selection updates the typed model property.&lt;/li&gt;
&lt;li&gt;An existing model value renders back into the control.&lt;/li&gt;
&lt;li&gt;Clearing an optional date produces &lt;code&gt;null&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A required date cannot look accepted while the model remains empty.&lt;/li&gt;
&lt;li&gt;Re-rendering preserves a valid value.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run both directions under several cultures, including one whose short-date format differs from the native ISO shape. A converter unit test is helpful, but a rendered component test is stronger because it includes binding events and component state. A small browser test is stronger again for behaviour owned by the native control.&lt;/p&gt;

&lt;p&gt;The invariant is simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;displayed date = wire date = model date
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The strings do not need to look identical to a human. They do need a deliberate, reversible mapping.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-off is worth naming
&lt;/h2&gt;

&lt;p&gt;Native controls are lightweight, familiar, accessible, and supported by the browser. A component-owned picker adds JavaScript, styling, dependency weight, and another UI abstraction.&lt;/p&gt;

&lt;p&gt;The answer is not to avoid native inputs. It is to price the conversion boundary honestly. If the surrounding component cannot speak the native control's value protocol reliably, the apparent simplicity is borrowed from future debugging time.&lt;/p&gt;

&lt;p&gt;Similarly, a culture matrix and rendered component tests cost more than a single unit test. Keep them focused on the boundary rather than duplicating every form scenario. A handful of bidirectional contract tests usually provides more confidence than many service tests that never touch the UI protocol.&lt;/p&gt;

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

&lt;p&gt;When reviewing a typed form control, ask:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What does the browser display?&lt;/li&gt;
&lt;li&gt;What exact value does the control exchange?&lt;/li&gt;
&lt;li&gt;What type and null semantics does the model require?&lt;/li&gt;
&lt;li&gt;Which component owns conversion in each direction?&lt;/li&gt;
&lt;li&gt;Which cultures are exercised?&lt;/li&gt;
&lt;li&gt;Can failed conversion leave convincing stale UI behind?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Dates make this boundary easy to see, but the same pattern appears with decimal separators, percentages, currencies, enums, and time zones.&lt;/p&gt;

&lt;p&gt;The screen is evidence of what the browser rendered. The model is evidence of what the application accepted. Good binding code—and good tests—prove that the two remain connected.&lt;/p&gt;

&lt;p&gt;Where in your UI could a value look valid without ever reaching the model?&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>blazor</category>
      <category>csharp</category>
      <category>testing</category>
    </item>
    <item>
      <title>A Hidden Button Is Not an Authorisation Boundary</title>
      <dc:creator>Ivan Rossouw</dc:creator>
      <pubDate>Fri, 31 Jul 2026 14:16:36 +0000</pubDate>
      <link>https://dev.to/iqtechsolutions/a-hidden-button-is-not-an-authorisation-boundary-3ie1</link>
      <guid>https://dev.to/iqtechsolutions/a-hidden-button-is-not-an-authorisation-boundary-3ie1</guid>
      <description>&lt;p&gt;The most convincing authorisation gap can be a page that looks perfectly locked down.&lt;/p&gt;

&lt;p&gt;An action disappears for callers who do not have the required permission. The screen feels safe, the happy path works, and a manual review confirms that the button is hidden. Yet the API behind that button may still accept any authenticated request.&lt;/p&gt;

&lt;p&gt;That is the central lesson: UI visibility is a usability rule. Server-side authorisation is the security boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authentication answers only the first question
&lt;/h2&gt;

&lt;p&gt;In ASP.NET Core, a controller-level &lt;code&gt;Authorize&lt;/code&gt; attribute is useful. It establishes that a request must come from an authenticated principal. It does not, by itself, answer whether that principal may perform every operation exposed by the controller.&lt;/p&gt;

&lt;p&gt;Those are different questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Authentication: who is calling?&lt;/li&gt;
&lt;li&gt;Authorisation: may this caller perform this operation on this subject and scope?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The distinction becomes important when an API mixes operations with different risk profiles. Consider a generalised controller that supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;reading my own record;&lt;/li&gt;
&lt;li&gt;reading one record I am permitted to see;&lt;/li&gt;
&lt;li&gt;listing an entire collection;&lt;/li&gt;
&lt;li&gt;changing a relationship;&lt;/li&gt;
&lt;li&gt;exporting a richer dataset.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All five actions may require authentication. They do not necessarily share one authorisation rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one controller-wide policy can be wrong
&lt;/h2&gt;

&lt;p&gt;A recent committed hardening change illustrated this mixed-purpose shape. Some actions were legitimate self-service. Other actions worked across a collection or produced a higher-sensitivity export.&lt;/p&gt;

&lt;p&gt;Moving one strict policy onto the entire controller would have been simple, but it would also have denied the self-service operations. Keeping only the baseline authentication rule would preserve those journeys, but leave the broader operations under-specified.&lt;/p&gt;

&lt;p&gt;The design therefore needed operation-level decisions.&lt;/p&gt;

&lt;p&gt;Here is a deliberately generic sketch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Authorize&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RecordsController&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ControllerBase&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"mine"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IActionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Mine&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="nf"&gt;ReadForCurrentCaller&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"all"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IActionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;All&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;access&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CanReadCollection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Forbid&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;All&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"export"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Records.Export"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IActionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Export&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="nf"&gt;ExportAuthorisedDataset&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The controller-level attribute still performs useful baseline authentication. The broader read uses a domain guard because legitimate access may be represented through more than one role or capability. The higher-sensitivity operation uses a focused policy that can evolve independently.&lt;/p&gt;

&lt;p&gt;This is not a universal template. It is a reminder to make each operation’s rule explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Match the guard to subject, scope, and sensitivity
&lt;/h2&gt;

&lt;p&gt;An authorisation review becomes easier when each action is classified along three dimensions.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Subject
&lt;/h3&gt;

&lt;p&gt;Is the caller acting on their own resource, a resource related to them, or somebody else’s resource?&lt;/p&gt;

&lt;p&gt;Self-service often needs object-level checks rather than a broad administrative permission.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Scope
&lt;/h3&gt;

&lt;p&gt;Does the action return one authorised object, a page of objects, or an entire collection?&lt;/p&gt;

&lt;p&gt;A caller who may read one related record does not automatically gain collection-wide visibility. Cardinality changes the boundary.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Sensitivity
&lt;/h3&gt;

&lt;p&gt;Does the operation expose a routine projection, change state, or create a richer export?&lt;/p&gt;

&lt;p&gt;Two actions over the same entity may deserve different policies when one combines more fields, covers a wider scope, or produces a portable artefact.&lt;/p&gt;

&lt;p&gt;This classification is more reliable than deriving API rules from whichever controls happen to be visible on a page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test denial and preserved access
&lt;/h2&gt;

&lt;p&gt;The obvious regression test asserts that an authenticated caller without the required entitlement receives a forbidden result. That is necessary, but incomplete.&lt;/p&gt;

&lt;p&gt;The same change should also prove that each legitimate access path still succeeds. In a mature system, entitlement may come from a role, a capability claim, object ownership, or a domain relationship. A “secure” change that silently blocks valid work is still a regression.&lt;/p&gt;

&lt;p&gt;For a mixed-purpose controller, useful tests include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the sensitive action carries the intended policy;&lt;/li&gt;
&lt;li&gt;an authenticated but unentitled caller is denied;&lt;/li&gt;
&lt;li&gt;legitimate role-based access still works;&lt;/li&gt;
&lt;li&gt;legitimate capability-based access still works;&lt;/li&gt;
&lt;li&gt;baseline authentication has not been removed;&lt;/li&gt;
&lt;li&gt;no action has accidentally acquired anonymous access.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Metadata and unit tests are fast and valuable, but they do not exercise the complete middleware pipeline. Add at least one integration test through a real test host for the highest-risk route. That verifies policy registration, authentication scheme selection, and middleware order together.&lt;/p&gt;

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

&lt;p&gt;Action-level authorisation adds attributes, guards, tests, and review surface. It can also become inconsistent if developers must remember the rule for every new action.&lt;/p&gt;

&lt;p&gt;A single controller-wide policy is easier to understand, but only when every action truly shares the same access semantics. Splitting self-service and privileged operations into separate controllers can make the boundary clearer, although it may fragment routing and add migration work.&lt;/p&gt;

&lt;p&gt;The right choice is the one that makes the security model visible. For an existing mixed-purpose controller, explicit action rules plus a small classification test can be a pragmatic step. For new APIs, grouping operations by authorisation semantics is often cleaner.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical takeaway
&lt;/h2&gt;

&lt;p&gt;During the next endpoint review, ignore the page for a moment. Inventory the API actions directly and label each one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;self;&lt;/li&gt;
&lt;li&gt;single authorised resource;&lt;/li&gt;
&lt;li&gt;bulk collection;&lt;/li&gt;
&lt;li&gt;mutation;&lt;/li&gt;
&lt;li&gt;export.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then ask what the server proves before executing each action. Finally, test one caller who must fail and every caller class that must still succeed.&lt;/p&gt;

&lt;p&gt;Hide or disable controls to create a clear experience. Put the actual decision where the data or state change leaves the server.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>aspnetcore</category>
      <category>security</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Shared UI Means Shared Contracts, Not Shared Hosts</title>
      <dc:creator>Ivan Rossouw</dc:creator>
      <pubDate>Thu, 30 Jul 2026 06:04:54 +0000</pubDate>
      <link>https://dev.to/iqtechsolutions/shared-ui-means-shared-contracts-not-shared-hosts-20i1</link>
      <guid>https://dev.to/iqtechsolutions/shared-ui-means-shared-contracts-not-shared-hosts-20i1</guid>
      <description>&lt;p&gt;A component can compile in a shared assembly and still be impossible to construct in one of the applications that renders it.&lt;/p&gt;

&lt;p&gt;That is easy to miss when browser and .NET MAUI hosts reuse the same Blazor pages. The feature appears to exist once, but every host still owns a dependency-injection container, startup path, and lifetime model.&lt;/p&gt;

&lt;p&gt;A recent committed fix made this concrete. A shared page gained a required workflow dependency. Native hosts registered it in their common bootstrap path. A browser host rendered the same page through another composition root, where the dependency was unknown. The page failed before it could render.&lt;/p&gt;

&lt;p&gt;The lasting lesson was not “remember one more registration.” It was that shared UI creates a contract for every host capable of rendering it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sharing an assembly does not share a container
&lt;/h2&gt;

&lt;p&gt;Compile-time reuse asks whether multiple projects can reference a component. Runtime composition asks whether each host can construct it and satisfy the required behaviour.&lt;/p&gt;

&lt;p&gt;A native host may call a wrapper bootstrap, while a browser host calls a web-oriented startup path. Both load the same Razor component without building the same service graph.&lt;/p&gt;

&lt;p&gt;This creates a useful review rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A required injection on a shared component is a required contract on every composition root that can render it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The host inventory matters. “All native apps use this bootstrap” is not enough if a browser, desktop, test, or preview host renders the page by another route.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not repair a missing host capability with optionality
&lt;/h2&gt;

&lt;p&gt;When one host cannot provide a dependency, making it nullable can feel pragmatic. That is reasonable for an optional enhancement such as haptic feedback. It is dangerous when the dependency enforces a workflow invariant.&lt;/p&gt;

&lt;p&gt;In the reviewed change, the workflow had to know whether the runtime context remained the same while an operation was in flight. Native applications could change that context at runtime. The browser host could not; its context was fixed for the lifetime of the application.&lt;/p&gt;

&lt;p&gt;Optionality would have turned “this host represents stability differently” into “this host may skip the safety check.” Those are not equivalent.&lt;/p&gt;

&lt;p&gt;The better question was: what is the smallest capability the workflow genuinely needs?&lt;/p&gt;

&lt;p&gt;It needed only three facts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a revision that identifies the current context;&lt;/li&gt;
&lt;li&gt;whether the context is ready for work;&lt;/li&gt;
&lt;li&gt;a signal that the context has changed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It did not need the native host’s full navigation, storage, or UI service. Once the smaller contract was explicit, both hosts could tell the truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixed and live adapters can satisfy one invariant
&lt;/h2&gt;

&lt;p&gt;The browser implementation could be fixed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;its revision never changes;&lt;/li&gt;
&lt;li&gt;it is ready once the application starts;&lt;/li&gt;
&lt;li&gt;its change signal never fires.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The native implementation could be live:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;its revision comes from the runtime context;&lt;/li&gt;
&lt;li&gt;readiness reflects whether a selection exists;&lt;/li&gt;
&lt;li&gt;its signal follows genuine context changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They behave differently while preserving the same promise: work that begins in one context must not silently commit after that context changes. The abstraction names the smallest invariant every platform can honour instead of erasing platform differences.&lt;/p&gt;

&lt;h2&gt;
  
  
  Register the contract where the hosts actually enter
&lt;/h2&gt;

&lt;p&gt;The original registration lived in a bootstrap shared by the native applications. It looked central because several hosts called it, but it was not central to every host rendering the page.&lt;/p&gt;

&lt;p&gt;The fix moved the shared workflow contract into the two actual composition paths:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the browser path registered the fixed adapter;&lt;/li&gt;
&lt;li&gt;the native path registered the live adapter;&lt;/li&gt;
&lt;li&gt;both registered the required shared workflow;&lt;/li&gt;
&lt;li&gt;the native-only wrapper stopped being the accidental authority.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The duplicated registrations document a real split: same workflow, different host truth. Common registration still suits identical services; host-specific adapters should stay visible where the host chooses them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test behaviour and composition
&lt;/h2&gt;

&lt;p&gt;Registration tests are useful because this failure happens before component behaviour can begin.&lt;/p&gt;

&lt;p&gt;A focused matrix can cover:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the browser composition root contains the shared workflow and fixed adapter;&lt;/li&gt;
&lt;li&gt;the native composition root contains the shared workflow and live adapter;&lt;/li&gt;
&lt;li&gt;the old platform-only bootstrap is not a hidden third authority;&lt;/li&gt;
&lt;li&gt;the workflow succeeds when a host has a genuinely fixed context;&lt;/li&gt;
&lt;li&gt;the workflow rejects or repairs a completion when a live context changes mid-operation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Descriptor checks catch missing registrations quickly. Behaviour tests prove the adapters preserve the invariant. Neither activates the complete production host. For high-value pages, add a smoke test that builds each host’s real provider and constructs the page boundary; this can expose lifetime or replacement mistakes that descriptor checks miss.&lt;/p&gt;

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

&lt;p&gt;The narrow-contract approach costs an interface, two adapters, explicit registrations, and a wider test matrix.&lt;/p&gt;

&lt;p&gt;The alternatives are cheaper only locally:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a broad native service leaks platform concerns into shared workflow code;&lt;/li&gt;
&lt;li&gt;an optional dependency can silently disable a safety invariant;&lt;/li&gt;
&lt;li&gt;one oversized bootstrap hides which host owns which behaviour;&lt;/li&gt;
&lt;li&gt;ad hoc checks drift without a named contract.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I prefer small, intentional duplication at composition boundaries over implicit behavioural differences inside the workflow.&lt;/p&gt;

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

&lt;p&gt;When a shared Blazor component gains a required dependency, ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which browser, native, desktop, test, and preview hosts can render it?&lt;/li&gt;
&lt;li&gt;Through which composition root does each host enter?&lt;/li&gt;
&lt;li&gt;Is the dependency a capability the workflow needs, or a large platform service it happened to receive?&lt;/li&gt;
&lt;li&gt;Can every host provide a truthful, non-optional implementation?&lt;/li&gt;
&lt;li&gt;Are host differences visible in adapters rather than null branches?&lt;/li&gt;
&lt;li&gt;Do tests exercise registration and behaviour for every root?&lt;/li&gt;
&lt;li&gt;Would a full-provider smoke test catch lifetime or replacement errors?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Shared UI is not evidence of shared runtime topology. Treat every required dependency as a cross-host design decision, and the composition roots become part of the component’s real API.&lt;/p&gt;

&lt;p&gt;Which shared page in your system is quietly assuming it has only one host?&lt;/p&gt;

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