<?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: fenixkit</title>
    <description>The latest articles on DEV Community by fenixkit (@fenixkit).</description>
    <link>https://dev.to/fenixkit</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3922223%2Fdcdf66d8-ca01-478b-9a25-512b4967676f.png</url>
      <title>DEV Community: fenixkit</title>
      <link>https://dev.to/fenixkit</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fenixkit"/>
    <language>en</language>
    <item>
      <title>Three ways to serve files from S3 — and when to use each</title>
      <dc:creator>fenixkit</dc:creator>
      <pubDate>Thu, 28 May 2026 10:43:10 +0000</pubDate>
      <link>https://dev.to/fenixkit/three-ways-to-serve-files-from-s3-and-when-to-use-each-5205</link>
      <guid>https://dev.to/fenixkit/three-ways-to-serve-files-from-s3-and-when-to-use-each-5205</guid>
      <description>&lt;p&gt;When you add file storage to an API, the first decision is: how do you get those files to the client?&lt;/p&gt;

&lt;p&gt;There are three fundamentally different strategies, each with different security properties, infrastructure requirements, and tradeoffs. Most tutorials pick one without explaining why. This article covers all three so you can choose the right one per use case — because in most real applications you'll need more than one.&lt;/p&gt;




&lt;h2&gt;
  
  
  The three strategies
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Public URL
&lt;/h3&gt;

&lt;p&gt;The file is stored in a public bucket. Anyone with the URL can access it — no authentication, no expiry, no server involvement after upload.&lt;/p&gt;

&lt;p&gt;Client → CDN/S3 URL → File&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it makes sense:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Static assets that are genuinely public: logos, marketing images, open documentation&lt;/li&gt;
&lt;li&gt;Files you'd serve from a CDN anyway&lt;/li&gt;
&lt;li&gt;High-traffic read scenarios where you don't want your API in the critical path&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Limitations:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Once the URL is out, it's out. You cannot revoke access to a specific file without deleting or moving it. If a user shares the URL, anyone can use it indefinitely. This rules it out for anything tied to permissions or subscriptions.&lt;/p&gt;

&lt;p&gt;Also worth noting: if you later need to privatise a file, you have to migrate it to a different bucket with a different access policy. Plan your bucket layout with this in mind.&lt;/p&gt;




&lt;h3&gt;
  
  
  2. Presigned URL
&lt;/h3&gt;

&lt;p&gt;The file lives in a private bucket. Your API generates a time-limited signed URL and returns it to the client. The client uses that URL directly to fetch the file — your server is not in the data path.&lt;/p&gt;

&lt;p&gt;Client → API (auth check) → generates signed URL → Client → S3 URL (expires in N seconds)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it makes sense:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Files that belong to a specific user or entity&lt;/li&gt;
&lt;li&gt;Content that should expire (paid downloads, temporary access, trial periods)&lt;/li&gt;
&lt;li&gt;Large files where you don't want to stream through your server&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The key parameter:&lt;/strong&gt; expiry. Too short and clients get errors mid-session. Too long and you've effectively made it public.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cache problem — easy to miss:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you cache the API response that contains the presigned URL, and that cache entry outlives the URL's expiry, clients will receive a cached response with a URL that no longer works. The fix is to align your cache TTL with the presigned URL expiry. If the URL expires in 1 hour, the cache entry must expire in at most 1 hour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One infrastructure note:&lt;/strong&gt; if your API runs in Docker and connects to a self-hosted S3 backend via an internal network address, presigned URLs need special handling. The signature is computed against the URL host, so if you sign with an internal Docker hostname, the resulting URL will contain that internal hostname — which the client cannot reach. You need to sign with the public-facing hostname.&lt;/p&gt;




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

&lt;p&gt;The file stays in a private bucket. The client never gets a storage URL at all — it requests the file through your API, your server fetches it from S3, and streams it back to the client.&lt;/p&gt;

&lt;p&gt;Client → API (auth check) → S3 (internal) → streams back to Client&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it makes sense:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Files that require strict access control checked on every request&lt;/li&gt;
&lt;li&gt;Scenarios where you need to log every access&lt;/li&gt;
&lt;li&gt;Files where the storage URL must never leak (contracts, medical records, invoices)&lt;/li&gt;
&lt;li&gt;Cases where you need to transform the file on the way out (watermarks, transcoding)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The cost:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every download passes through your server. For large files or high traffic this adds bandwidth and compute costs, and your server becomes the bottleneck.&lt;/p&gt;

&lt;p&gt;Use this mode when security requirements genuinely demand it, not as a default.&lt;/p&gt;




&lt;h2&gt;
  
  
  Comparison at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Public URL&lt;/th&gt;
&lt;th&gt;Presigned URL&lt;/th&gt;
&lt;th&gt;Proxy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Storage bucket&lt;/td&gt;
&lt;td&gt;Public&lt;/td&gt;
&lt;td&gt;Private&lt;/td&gt;
&lt;td&gt;Private&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth check on download&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;At generation time&lt;/td&gt;
&lt;td&gt;On every request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;URL expires&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (configurable)&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server in download path&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can revoke access&lt;/td&gt;
&lt;td&gt;Only by deleting&lt;/td&gt;
&lt;td&gt;Naturally (expiry)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CDN-friendly&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Static assets&lt;/td&gt;
&lt;td&gt;User content&lt;/td&gt;
&lt;td&gt;Sensitive files&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Per-bucket configuration
&lt;/h2&gt;

&lt;p&gt;In most real applications you'll have multiple buckets, each warranting a different strategy. A typical setup might look like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;public-assets&lt;/code&gt; — logos, marketing images → &lt;strong&gt;Public&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;user-uploads&lt;/code&gt; — profile pictures, product photos → &lt;strong&gt;PresignedUrl&lt;/strong&gt; (7-day expiry)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;private-documents&lt;/code&gt; — contracts, invoices → &lt;strong&gt;Proxy&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cleanest approach is to make the access mode a property of the bucket configuration rather than hardcoding it in business logic. Your file-handling code then reads the bucket config to decide how to resolve a download URL — no conditionals scattered through your services.&lt;/p&gt;

&lt;p&gt;This also means changing a bucket's access mode is a config change, not a code change.&lt;/p&gt;




&lt;h2&gt;
  
  
  Choosing
&lt;/h2&gt;

&lt;p&gt;A rough decision tree:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is the file genuinely public and static? → &lt;strong&gt;Public URL&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Does the file belong to a user but doesn't need per-request access checks? → &lt;strong&gt;Presigned URL&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Does the file require access checked on every request, or must the URL never leak? → &lt;strong&gt;Proxy&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When in doubt, start with Presigned URL — it gives you access control at generation time, natural expiry, and keeps your server out of the download path. Upgrade to Proxy only when you genuinely need per-request enforcement.&lt;/p&gt;




&lt;h2&gt;
  
  
  A note on provider choice
&lt;/h2&gt;

&lt;p&gt;Everything above applies equally to AWS S3, Garage or any S3-compatible backend. The access mode strategy is independent of the provider — only the endpoint and credentials change. If you're self-hosting, Garage is worth a look: it's lightweight, runs in Docker, and is S3-compatible.&lt;/p&gt;




&lt;p&gt;I implemented all three modes in &lt;a href="https://fenixkit.dev/kits/mongodb-keycloak-redis-garage/" rel="noopener noreferrer"&gt;FenixKit&lt;/a&gt; — a .NET Minimal API kit that comes with MongoDB, Keycloak, Redis, and S3 file storage pre-wired. Each bucket is configured independently with its own access mode. If you're building a .NET API and don't want to wire all this up from scratch, it might save you a few days. Public docs on &lt;a href="https://github.com/fenixkitdev/FenixKit-MongoDB-Keycloak-Redis-Garage" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>garage</category>
      <category>s3</category>
      <category>api</category>
    </item>
    <item>
      <title>Why your .NET 8 API needs a cache layer — and how to build it right with Redis/Valkey and tag invalidation</title>
      <dc:creator>fenixkit</dc:creator>
      <pubDate>Sun, 17 May 2026 18:18:44 +0000</pubDate>
      <link>https://dev.to/fenixkit/why-your-net-8-api-needs-a-cache-layer-and-how-to-build-it-right-with-redisvalkey-and-tag-53am</link>
      <guid>https://dev.to/fenixkit/why-your-net-8-api-needs-a-cache-layer-and-how-to-build-it-right-with-redisvalkey-and-tag-53am</guid>
      <description>&lt;p&gt;Caching is one of those things that sounds optional until your database starts getting hammered at scale, your response times creep up, and you realise you've been querying the same data hundreds of times per minute. This article covers why a cache layer matters, how to implement cache-aside properly with tag-based invalidation in .NET 8, how to handle Redis outages gracefully, and why Valkey is worth knowing about.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why bother with cache at all?
&lt;/h2&gt;

&lt;p&gt;The short answer: your database doesn't need to answer the same question twice.&lt;/p&gt;

&lt;p&gt;A typical read-heavy API hits the database for the same product list, the same user profile, the same category results — on every request. Each one is a network round trip, a query execution, and serialisation overhead. At low traffic it's fine. At scale it isn't.&lt;/p&gt;

&lt;p&gt;A cache layer puts the answer in Redis the first time, and returns it directly on every subsequent request — milliseconds, no database involved.&lt;/p&gt;

&lt;p&gt;The reasons people avoid it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;"It adds complexity"&lt;/em&gt; — only if you build it badly&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"Cache invalidation is hard"&lt;/em&gt; — it is, but it doesn't have to be unpredictable&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"Redis going down takes my API down"&lt;/em&gt; — only if you don't handle it properly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three are solvable.&lt;/p&gt;




&lt;h2&gt;
  
  
  The cache-aside pattern
&lt;/h2&gt;

&lt;p&gt;Cache-aside is the simplest correct approach:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;On read&lt;/strong&gt; — check Redis first. Hit → return. Miss → query the database, populate Redis, return.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On write&lt;/strong&gt; — invalidate the relevant cache entries, then write to the database.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /api/products/abc123

  1. Check Redis  ──▶  HIT  ──▶  return cached JSON ✓
               └──▶  MISS ──▶  query database
                              └──▶  populate Redis ──▶  return ✓

PUT /api/products/abc123

  → invalidate cache entries for this product
  → write to database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple in theory. The problem is step 2 — &lt;em&gt;which&lt;/em&gt; cache entries do you invalidate?&lt;/p&gt;




&lt;h2&gt;
  
  
  The invalidation problem
&lt;/h2&gt;

&lt;p&gt;If you cache by key only (&lt;code&gt;product:abc123&lt;/code&gt;), that's easy — delete that key on update. But most APIs cache more than that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Paged lists — &lt;code&gt;product:paged:p1:s20&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Cursor pages — &lt;code&gt;product:cursor:start:20:fwd&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Filtered results — &lt;code&gt;product:category:Gaming&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you update a product, all of those &lt;em&gt;might&lt;/em&gt; be stale. You can't just delete one key.&lt;/p&gt;

&lt;p&gt;The naive solution is to expire everything with a short TTL. It works, but it means serving stale data for up to N minutes after every write, and it doesn't scale — at high write rates your cache is constantly cold.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tag-based invalidation
&lt;/h2&gt;

&lt;p&gt;A better approach: every cached entry is registered under one or more &lt;em&gt;tags&lt;/em&gt;. When you write, you invalidate by tag — wiping all entries associated with that tag at once.&lt;/p&gt;

&lt;p&gt;In Redis, a tag is a Set that holds the keys registered under it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;product&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;abc123              STRING   cached product JSON          TTL 5 min&lt;/span&gt;
&lt;span class="py"&gt;product&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;paged:p1:s20        STRING   cached page JSON             TTL 5 min&lt;/span&gt;
&lt;span class="py"&gt;product&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;category:Gaming     STRING   cached category list         TTL 5 min&lt;/span&gt;

&lt;span class="py"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;product                 SET      { paged keys, cursor keys }    no TTL&lt;/span&gt;
&lt;span class="py"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;product:abc123          SET      { "product:abc123" }            no TTL&lt;/span&gt;
&lt;span class="py"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;product:category:Gaming SET      { "product:category:..." }      no TTL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tag sets have no TTL — they are deleted when &lt;code&gt;InvalidateByTagAsync&lt;/code&gt; runs, leaving no orphaned entries.&lt;/p&gt;

&lt;p&gt;On every write, the repository wipes all matching tags.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The update case&lt;/strong&gt; is worth calling out: when a product moves from &lt;code&gt;Electronics&lt;/code&gt; to &lt;code&gt;Gaming&lt;/code&gt;, you need to invalidate &lt;em&gt;both&lt;/em&gt; the old and new category cache. The solution is to union the tags from the original and the updated entity before invalidating — both category caches get wiped, no extra logic needed in your handler.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three levels of control
&lt;/h2&gt;

&lt;p&gt;Not everything needs automatic invalidation. A well-designed cache layer gives you three levels:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;Use for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Automatic&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Base repository calls &lt;code&gt;GetInvalidationTags&lt;/code&gt; on every write&lt;/td&gt;
&lt;td&gt;Standard CRUD — always on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tag-based&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;_cache.InvalidateByTagAsync("product:category:Gaming")&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Custom domain queries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Manual&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;_cache.InvalidateAsync("product:abc123")&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Surgical single-key removal&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You pick the right level per operation. Most of the time the automatic level handles everything.&lt;/p&gt;




&lt;h2&gt;
  
  
  Handling Redis outages — FailOpen vs FailClosed
&lt;/h2&gt;

&lt;p&gt;This is where most cache implementations go wrong. If Redis throws an exception and you let it propagate, your API returns 500s whenever the cache is unavailable — even though your data is perfectly fine in the database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FailOpen&lt;/strong&gt; (recommended default): treat any Redis error as a cache miss. The request falls through to the database, succeeds, and returns normally. Redis being down is a performance degradation, not an outage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FailClosed&lt;/strong&gt;: return an error when Redis is unavailable. Use this only when cache correctness is a hard requirement.&lt;/p&gt;

&lt;p&gt;For most APIs, FailOpen is the right default. Redis is a performance layer, not a source of truth.&lt;/p&gt;




&lt;h2&gt;
  
  
  Making cache optional
&lt;/h2&gt;

&lt;p&gt;There are scenarios where you want to run without Redis entirely — local development or environments where you haven't provisioned a cache server yet.&lt;/p&gt;

&lt;p&gt;The clean solution is a no-op implementation of your cache interface that can be swapped in via config:&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="c1"&gt;// appsettings.json / .env&lt;/span&gt;
&lt;span class="n"&gt;Cache__Enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="k"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When disabled: the cache interface resolves to a no-op, &lt;code&gt;IConnectionMultiplexer&lt;/code&gt; is never registered, and the Redis health check is omitted automatically. No code changes required anywhere else.&lt;/p&gt;




&lt;h2&gt;
  
  
  Valkey — the Redis fork worth knowing about
&lt;/h2&gt;

&lt;p&gt;In 2024, Redis changed its licence from BSD, no longer open-source. In response, the Linux Foundation forked Redis at version 7.2 and created &lt;a href="https://valkey.io" rel="noopener noreferrer"&gt;Valkey&lt;/a&gt; — an open-source, community-maintained drop-in replacement.&lt;/p&gt;

&lt;p&gt;Valkey is wire-protocol compatible with Redis. &lt;code&gt;StackExchange.Redis&lt;/code&gt; connects to it transparently — no client changes, no code changes needed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.valkey.yml&lt;/span&gt;
&lt;span class="na"&gt;valkey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;valkey/valkey:7.2-alpine&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;valkey-server --requirepass ${CACHE_PASSWORD}&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;6379:6379"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;valkey:6379,password=yourpassword,protocol=2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're happy with Redis 8, nothing changes. If you prefer a fully open-source stack, Valkey 7.2 is a transparent swap.&lt;/p&gt;




&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;The full pattern in a .NET 8 Minimal API:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read&lt;/strong&gt; — check Redis, miss falls through to the database, result populates Redis on return&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write&lt;/strong&gt; — union tags from old + new entity, invalidate, write to database&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FailOpen&lt;/strong&gt; by default — Redis errors never surface as 500s&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optional&lt;/strong&gt; — disable via config, no-op swaps in automatically&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you'd rather not wire all of this from scratch, I've packaged the full implementation into &lt;strong&gt;FenixKit&lt;/strong&gt; — .NET 8 Minimal API starter kits with the cache layer, tag invalidation, FailOpen, Valkey support, and health checks all included and pre-configured.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;📦 &lt;a href="https://github.com/fenixkitdev/FenixKit-MongoDB-Redis" rel="noopener noreferrer"&gt;FenixKit-MongoDB-Redis&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📦 &lt;a href="https://github.com/fenixkitdev/FenixKit-MongoDB-Keycloak-Redis" rel="noopener noreferrer"&gt;FenixKit-MongoDB-Keycloak-Redis&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🌐 &lt;a href="https://fenixkit.dev" rel="noopener noreferrer"&gt;fenixkit.dev&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>redis</category>
      <category>api</category>
    </item>
    <item>
      <title>Why I stopped rolling my own auth and switched to Keycloak</title>
      <dc:creator>fenixkit</dc:creator>
      <pubDate>Wed, 13 May 2026 12:49:06 +0000</pubDate>
      <link>https://dev.to/fenixkit/why-i-stopped-rolling-my-own-auth-and-switched-to-keycloak-3g2e</link>
      <guid>https://dev.to/fenixkit/why-i-stopped-rolling-my-own-auth-and-switched-to-keycloak-3g2e</guid>
      <description>&lt;p&gt;Every developer has built it at least once. A &lt;code&gt;UsersController&lt;/code&gt;, a &lt;code&gt;POST /auth/login&lt;/code&gt; endpoint, a &lt;code&gt;PasswordHasher&lt;/code&gt;, a &lt;code&gt;JwtService&lt;/code&gt; that generates tokens. It feels like the natural thing to do — auth is just another feature, right?&lt;/p&gt;

&lt;p&gt;It isn't. And I learned that the hard way.&lt;/p&gt;




&lt;h2&gt;
  
  
  What "rolling your own JWT auth" actually means
&lt;/h2&gt;

&lt;p&gt;On the surface it looks simple:&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;token&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;JwtSecurityToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"myapp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;expires&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddHours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;signingCredentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But that's just the token. The moment you decide to own your auth stack, you're signing up for all of this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Password storage&lt;/strong&gt; — hashing, salting, choosing the right algorithm (bcrypt? Argon2? PBKDF2?), migrating if you get it wrong&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh token rotation&lt;/strong&gt; — storing refresh tokens, invalidating old ones, handling concurrent requests that race to refresh&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token revocation&lt;/strong&gt; — JWTs are stateless, so revoking one before expiry means a blacklist, which means a database lookup on every request&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Brute force protection&lt;/strong&gt; — rate limiting login attempts, lockout logic, alerting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgot password flow&lt;/strong&gt; — secure token generation, expiry, one-time use enforcement&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email verification&lt;/strong&gt; — same problem&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MFA&lt;/strong&gt; — TOTP, backup codes, recovery flows&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session management&lt;/strong&gt; — single sign-out, concurrent session limits&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security patches&lt;/strong&gt; — when a vulnerability is found in your approach, you fix it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Together they are a significant, ongoing maintenance burden — and they have nothing to do with your actual product.&lt;/p&gt;




&lt;h2&gt;
  
  
  The moment I realised I was building an identity provider
&lt;/h2&gt;

&lt;p&gt;I was three days into a project without doing a single line of domain code. &lt;/p&gt;

&lt;p&gt;That's when it clicked. I wasn't building a feature. I was building an identity provider — badly, from scratch, under time pressure. Companies spend years hardening this stuff.&lt;/p&gt;

&lt;p&gt;The question isn't &lt;em&gt;"can I build this?"&lt;/em&gt; — of course you can. The question is &lt;em&gt;"should I?"&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What Keycloak actually is
&lt;/h2&gt;

&lt;p&gt;Keycloak is an open-source identity and access management solution. It handles everything in the list above — and more — out of the box. You run it as a container, configure a realm, and your application stops caring about any of it.&lt;/p&gt;

&lt;p&gt;Your API's only job becomes: &lt;strong&gt;validate the token&lt;/strong&gt;.&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="c1"&gt;// This is the entire auth setup in your API&lt;/span&gt;
&lt;span class="n"&gt;builder&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="n"&gt;JwtBearerDefaults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuthenticationScheme&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddJwtBearer&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;=&amp;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="n"&gt;Authority&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"http://localhost:8080/realms/myrealm"&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="n"&gt;Audience&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"my-api-client"&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;That's it. ASP.NET Core fetches the OIDC discovery document from Keycloak (&lt;code&gt;/.well-known/openid-configuration&lt;/code&gt;), and validates every incoming token. No database lookup per request. No code to maintain.&lt;/p&gt;




&lt;h2&gt;
  
  
  But what about "JWT normal"?
&lt;/h2&gt;

&lt;p&gt;When people say "just use JWT" they usually mean: generate tokens yourself, validate them yourself, store user data yourself. This is fine for a toy project or a quick internal tool.&lt;/p&gt;

&lt;p&gt;The problem is that JWT is a &lt;strong&gt;token format&lt;/strong&gt;, not an auth system. It tells you how to structure and sign a token. It tells you nothing about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How to manage users&lt;/li&gt;
&lt;li&gt;How to handle token revocation&lt;/li&gt;
&lt;li&gt;How to implement refresh flows securely&lt;/li&gt;
&lt;li&gt;How to add MFA later without rewriting everything&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keycloak uses JWT — it just handles all the surrounding complexity so you don't have to.&lt;/p&gt;




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

&lt;p&gt;Keycloak is not the right answer for every situation.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Roll your own&lt;/th&gt;
&lt;th&gt;Keycloak&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup time&lt;/td&gt;
&lt;td&gt;Fast initially&lt;/td&gt;
&lt;td&gt;Slower initially&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintenance&lt;/td&gt;
&lt;td&gt;You own everything&lt;/td&gt;
&lt;td&gt;Keycloak team maintains it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flexibility&lt;/td&gt;
&lt;td&gt;Total control&lt;/td&gt;
&lt;td&gt;Configurable but opinionated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resource usage&lt;/td&gt;
&lt;td&gt;Minimal&lt;/td&gt;
&lt;td&gt;Needs a container&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MFA, SSO, social login&lt;/td&gt;
&lt;td&gt;Build it yourself&lt;/td&gt;
&lt;td&gt;Already there&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security patches&lt;/td&gt;
&lt;td&gt;Your problem&lt;/td&gt;
&lt;td&gt;Keycloak's problem&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a &lt;strong&gt;side project with no users yet&lt;/strong&gt; — rolling your own might be fine. For anything with real users, a team, compliance requirements, or plans to grow — Keycloak wins on every axis that matters.&lt;/p&gt;




&lt;h2&gt;
  
  
  The .NET integration gotcha nobody warns you about
&lt;/h2&gt;

&lt;p&gt;If you switch from rolling your own JWT to Keycloak in ASP.NET Core, there's one thing that will silently break: &lt;strong&gt;role claims&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;ASP.NET Core remaps JWT claim names by default. &lt;code&gt;sub&lt;/code&gt; becomes &lt;code&gt;ClaimTypes.NameIdentifier&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt; becomes a long URN string — and &lt;code&gt;roles&lt;/code&gt; gets ignored entirely because Keycloak puts realm roles in a non-standard location.&lt;/p&gt;

&lt;p&gt;The fix is two lines, but you have to know to add them:&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="c1"&gt;// Before builder.Services.AddAuthentication()&lt;/span&gt;
&lt;span class="n"&gt;JwtSecurityTokenHandler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultInboundClaimTypeMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Clear&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;JsonWebTokenHandler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultInboundClaimTypeMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Clear&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this, &lt;code&gt;User.IsInRole("admin")&lt;/code&gt; always returns false and you spend an afternoon debugging a problem that isn't in your code.&lt;/p&gt;

&lt;p&gt;You also need to tell the JWT Bearer middleware where Keycloak puts roles (in my case):&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;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TokenValidationParameters&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;RoleClaimType&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"roles"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;NameClaimType&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"preferred_username"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With these in place, &lt;code&gt;[Authorize(Roles = "admin")]&lt;/code&gt; and &lt;code&gt;User.IsInRole("admin")&lt;/code&gt; work exactly as expected.&lt;/p&gt;




&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Once it's wired up, protecting any endpoint is a single line:&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;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapGet&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;GetAll&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;RequireAuthorization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Authenticated"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapDelete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/{id}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Delete&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;RequireAuthorization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"AdminOnly"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forgot password, MFA, social login, token revocation, brute force protection — all handled. You never wrote a &lt;code&gt;PasswordHasher&lt;/code&gt;. You never debugged a refresh token rotation race condition. You just built your product.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where to go from here
&lt;/h2&gt;

&lt;p&gt;If you want to try Keycloak with .NET 8, the official docs are a reasonable starting point.&lt;/p&gt;

&lt;p&gt;I spent time getting all of this right and packaged it into a starter kit — &lt;strong&gt;FenixKit MongoDB + Keycloak Edition&lt;/strong&gt; — a .NET 8 Minimal API template with Keycloak pre-configured, a pre-built realm that imports at startup, and two test users ready to go. If you want to skip the setup and get straight to building, it's at &lt;a href="https://fenixkit.dev" rel="noopener noreferrer"&gt;fenixkit.dev&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you want to find out more on how I built it, go to &lt;a href="https://github.com/fenixkit" rel="noopener noreferrer"&gt;FenixKit GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>keycloak</category>
      <category>dotnet</category>
      <category>authentication</category>
      <category>api</category>
    </item>
    <item>
      <title>Why I stopped using exceptions for control flow in my .NET 8 APIs</title>
      <dc:creator>fenixkit</dc:creator>
      <pubDate>Sat, 09 May 2026 17:36:59 +0000</pubDate>
      <link>https://dev.to/fenixkit/why-i-stopped-using-exceptions-for-control-flow-in-my-net-8-apis-29j2</link>
      <guid>https://dev.to/fenixkit/why-i-stopped-using-exceptions-for-control-flow-in-my-net-8-apis-29j2</guid>
      <description>&lt;h1&gt;
  
  
  Why I stopped using exceptions for control flow in my .NET 8 APIs
&lt;/h1&gt;

&lt;p&gt;For a long time, my .NET APIs looked 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;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;Product&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetByIdAsync&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;id&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;product&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;_collection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;FirstOrDefaultAsync&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;product&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;KeyNotFoundException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"Product with id '&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;' was not found."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;product&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;And in the endpoint:&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/products/{id}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IProductRepository&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;)&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;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;product&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;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetByIdAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&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;KeyNotFoundException&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NotFound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&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;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Problem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&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;It worked. But every endpoint had a try/catch. Every service method threw a different exception type. The caller had to know which exceptions to catch. Business logic and error routing were tangled together.&lt;/p&gt;

&lt;p&gt;Then I came across the &lt;strong&gt;ErrorOr&lt;/strong&gt; library by Amantinband, and it changed how I think about error handling entirely.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem with exceptions for control flow
&lt;/h2&gt;

&lt;p&gt;Exceptions in .NET are designed for &lt;em&gt;exceptional&lt;/em&gt; situations — things that should not happen and cannot be recovered from gracefully. Using them to signal "product not found" or "name already exists" is semantically wrong and has real costs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Performance&lt;/strong&gt; — throwing and catching exceptions is significantly more expensive than returning a value. The CLR unwinds the call stack, captures a stack trace, and allocates an exception object every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hidden contracts&lt;/strong&gt; — nothing in the method signature tells the caller what can go wrong. You find out at runtime, not compile time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scattered try/catch&lt;/strong&gt; — every caller has to know which exceptions to catch, and if they forget one, it bubbles up as an unhandled 500.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard to test&lt;/strong&gt; — asserting on thrown exceptions is more awkward than asserting on return values.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The alternative is to make errors part of the return type. This is not a new idea — it is how languages like Rust (&lt;code&gt;Result&amp;lt;T, E&amp;gt;&lt;/code&gt;) and Go (multiple return values) handle it. In .NET, the &lt;strong&gt;ErrorOr&lt;/strong&gt; library brings the same pattern with a clean API.&lt;/p&gt;




&lt;h2&gt;
  
  
  What ErrorOr looks like
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ErrorOr&amp;lt;T&amp;gt;&lt;/code&gt; is a discriminated union that holds either a success value or one or more errors. Every operation either succeeds or fails — and the caller is forced to handle both cases.&lt;/p&gt;

&lt;p&gt;Here is what my domain error definitions look like in FenixKit:&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;static&lt;/span&gt; &lt;span class="k"&gt;partial&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductErrors&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;static&lt;/span&gt; &lt;span class="n"&gt;Error&lt;/span&gt; &lt;span class="nf"&gt;NotFound&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;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NotFound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Product.NotFound"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$"Product with id '&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;' was not found."&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;static&lt;/span&gt; &lt;span class="n"&gt;Error&lt;/span&gt; &lt;span class="nf"&gt;NameConflict&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;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Conflict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Product.NameConflict"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$"A product with name '&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;' already exists."&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;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;Error&lt;/span&gt; &lt;span class="n"&gt;InvalidPrice&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Validation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Product.InvalidPrice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Price must be greater than zero."&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;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;Error&lt;/span&gt; &lt;span class="n"&gt;InvalidCategory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Validation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Product.InvalidCategory"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Category cannot be empty."&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;Each error has a type (&lt;code&gt;NotFound&lt;/code&gt;, &lt;code&gt;Conflict&lt;/code&gt;, &lt;code&gt;Validation&lt;/code&gt;), a machine-readable code, and a human-readable description. No exception classes, no inheritance hierarchy, no throwing.&lt;/p&gt;




&lt;h2&gt;
  
  
  How it flows through the stack
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The repository
&lt;/h3&gt;

&lt;p&gt;Validation runs before the database is touched. If anything fails, we return an error and the operation stops:&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;override&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;ErrorOr&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Success&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;OnValidateCreateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ProductCreateRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;errors&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Price&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProductErrors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InvalidPrice&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Category&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProductErrors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InvalidCategory&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Return all validation errors at once — no need to make the&lt;/span&gt;
    &lt;span class="c1"&gt;// client fix one problem, resubmit, and discover the next one.&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;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Only hit the database for uniqueness check if format validation passed.&lt;/span&gt;
    &lt;span class="c1"&gt;// This avoids a round-trip when the request is obviously invalid.&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;exists&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;ExistsByNameAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&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;exists&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errors&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;exists&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;ProductErrors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NameConflict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;)&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;Success&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;h3&gt;
  
  
  The hook chain
&lt;/h3&gt;

&lt;p&gt;Every hook in &lt;code&gt;BaseRepository&lt;/code&gt; returns &lt;code&gt;ErrorOr&amp;lt;T&amp;gt;&lt;/code&gt;, so a failure at any stage aborts the operation cleanly before the database is touched:&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;virtual&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;ErrorOr&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TDetailResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;CreateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;TCreateRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 1. Validate — aborts if invalid&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;validation&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;OnValidateCreateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&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;validation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;validation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errors&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. Map and enrich — aborts if enrichment fails&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToDBEntity&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;enriched&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;OnMapCreateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&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;enriched&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;enriched&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errors&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// 3. Persist — aborts if the DB call fails&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;created&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;_Repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enriched&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;ct&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;created&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errors&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// 4. Project to response — aborts if projection fails&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;detail&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;OnMapToDetailAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created&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;ct&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;detail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errors&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;detail&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No try/catch anywhere. Each step either produces a value or returns errors — and errors short-circuit the chain.&lt;/p&gt;

&lt;h3&gt;
  
  
  The endpoint
&lt;/h3&gt;

&lt;p&gt;At the endpoint layer, &lt;code&gt;Match&lt;/code&gt; forces you to handle both cases. There is no way to accidentally ignore the error 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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&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;IResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ProductCreateRequest&lt;/span&gt; &lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;FromServices&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;IProductRepository&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&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;result&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;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Created&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"/api/products/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&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;created&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;errors&lt;/span&gt;  &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToResponse&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;&lt;code&gt;errors.ToResponse()&lt;/code&gt; maps each ErrorOr error type to the correct HTTP status:&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;static&lt;/span&gt; &lt;span class="n"&gt;IResult&lt;/span&gt; &lt;span class="nf"&gt;ToResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// All validation errors → 422 with all field errors grouped&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;errors&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="n"&gt;e&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ErrorType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Validation&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ValidationProblem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToDictionary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Description&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;422&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;first&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;First&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;ErrorType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NotFound&lt;/span&gt;     &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NotFound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;ToProblem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;404&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;ErrorType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Conflict&lt;/span&gt;     &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Conflict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;ToProblem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;409&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;ErrorType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unauthorized&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Unauthorized&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;ErrorType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Forbidden&lt;/span&gt;    &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Forbid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt;                      &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Problem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;ToProblem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;500&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;Detail&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 mapping is defined once, used everywhere. No endpoint needs to know about HTTP status codes — it just calls &lt;code&gt;ToResponse()&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the response looks like
&lt;/h2&gt;

&lt;p&gt;A validation failure returns a proper RFC 7807 response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://tools.ietf.org/html/rfc7807"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"errors"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Product.InvalidPrice"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Price must be greater than zero."&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Product.InvalidCategory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Category cannot be empty."&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All errors are returned at once — not one at a time. The client fixes everything in one round-trip.&lt;/p&gt;

&lt;p&gt;A not-found error returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Product.NotFound"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Product with id '6638f1a2b3c4d5e6f7a8b9c0' was not found."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Machine-readable code, human-readable description, correct HTTP status. No exceptions needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  The global exception handler as a safety net
&lt;/h2&gt;

&lt;p&gt;ErrorOr handles expected errors — things your domain knows can go wrong. But bugs happen. For everything truly unexpected, a global exception handler catches it and converts it to a ProblemDetails response before it reaches the client:&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;async&lt;/span&gt; &lt;span class="n"&gt;ValueTask&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;TryHandleAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;HttpContext&lt;/span&gt; &lt;span class="n"&gt;context&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="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;KeyNotFoundException&lt;/span&gt;      &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Resource not found"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;UnauthorizedAccessException&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Unauthorized"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;ArgumentException&lt;/span&gt;         &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Bad request"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;InvalidOperationException&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;409&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Conflict"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt;                         &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Internal server error"&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;problem&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;ProblemDetails&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Status&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Title&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Detail&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="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Instance&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="c1"&gt;// TraceId for correlation — useful when the client reports a bug&lt;/span&gt;
    &lt;span class="n"&gt;problem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Extensions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"traceId"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TraceIdentifier&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ContentType&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"application/problem+json"&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;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteAsJsonAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;problem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&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;true&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;No stack traces leak to the client. No 500 with a raw exception message. The &lt;code&gt;traceId&lt;/code&gt; lets you correlate a client report with your logs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Switching to ErrorOr changed three things in my APIs:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Errors are explicit.&lt;/strong&gt; The return type tells you what can go wrong. You do not discover error cases at runtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No scattered try/catch.&lt;/strong&gt; The only try/catch in the whole codebase is the global exception handler — which exists for genuine bugs, not business logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HTTP mapping is centralised.&lt;/strong&gt; &lt;code&gt;ToResponse()&lt;/code&gt; is defined once. Every endpoint uses it. Adding a new error type means updating one switch, not every endpoint that could encounter it.&lt;/p&gt;

&lt;p&gt;The pattern does require a small mindset shift — you stop thinking of errors as exceptional events and start treating them as valid return values. Once that clicks, it is hard to go back.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;All code in this article is taken directly from &lt;a href="https://fenixkit.dev" rel="noopener noreferrer"&gt;FenixKit&lt;/a&gt; — a ready .NET 8 Minimal API starter kit with MongoDB that I built and packaged to stop rewriting the same foundation on every project.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ErrorOr library&lt;/strong&gt; by Amantinband — &lt;a href="https://github.com/amantinband/error-or" rel="noopener noreferrer"&gt;github.com/amantinband/error-or&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RFC 7807&lt;/strong&gt; — Problem Details for HTTP APIs — &lt;a href="https://tools.ietf.org/html/rfc7807" rel="noopener noreferrer"&gt;tools.ietf.org/html/rfc7807&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Microsoft Docs&lt;/strong&gt; — Exception handling in .NET — &lt;a href="https://learn.microsoft.com/en-us/dotnet/standard/exceptions/" rel="noopener noreferrer"&gt;learn.microsoft.com/dotnet/standard/exceptions&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Microsoft Docs&lt;/strong&gt; — IExceptionHandler in ASP.NET Core — &lt;a href="https://learn.microsoft.com/en-us/aspnet/core/fundamentals/error-handling" rel="noopener noreferrer"&gt;learn.microsoft.com/aspnet/core/fundamentals/error-handling&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Microsoft Docs&lt;/strong&gt; — Minimal APIs in ASP.NET Core — &lt;a href="https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/overview" rel="noopener noreferrer"&gt;learn.microsoft.com/aspnet/core/fundamentals/minimal-apis&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>developer</category>
      <category>api</category>
    </item>
  </channel>
</rss>
