<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Blogs on Jeppe Spanggaard - Software Developer | .NET, Azure &amp; Microsoft 365</title><link>https://jeppe-spanggaard.dk/blogs/</link><description>Recent content in Blogs on Jeppe Spanggaard - Software Developer | .NET, Azure &amp; Microsoft 365</description><generator>Hugo</generator><language>en-US</language><lastBuildDate>Sun, 05 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jeppe-spanggaard.dk/blogs/index.xml" rel="self" type="application/rss+xml"/><item><title>Stop Re-Downloading Unchanged Blobs: Let Azure Answer 304 for You</title><link>https://jeppe-spanggaard.dk/blogs/azure-blob-storage-etag-304/</link><pubDate>Sun, 05 Jul 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/azure-blob-storage-etag-304/</guid><description>Learn how to forward the browser's If-None-Match header to Azure Blob Storage with BlobRequestConditions, so unchanged files never leave storage at all.</description><content:encoded><![CDATA[<p>I have an Azure Functions backend whose job, among other things, is serving a web app&rsquo;s static files out of Blob Storage: JS bundles, fonts, icons, an <code>index.html</code>. Same files, same users, many times a day.</p>
<p>And for a while, every single request did the same dumb thing: download the blob from storage, push the bytes to the browser. The file hadn&rsquo;t changed since five seconds ago. The user&rsquo;s browser literally had an identical copy already. Didn&rsquo;t matter - full download from storage, full response to the client, every time. I was paying latency and moving bytes just to deliver files nobody actually needed re-delivered.</p>
<p>The fix was already sitting in every storage response I&rsquo;d been ignoring: the ETag.</p>
<h2 id="tldr">TL;DR</h2>
<p>Azure Blob Storage maintains an ETag for every blob - a version fingerprint that changes on every write. Take the <code>If-None-Match</code> header the browser sends you, forward it into the SDK call as a <code>BlobRequestConditions</code>, and Azure itself answers 304 when nothing changed. No file body from storage to your function, no file body from your function to the browser, no server-side cache to maintain. Your function becomes a pipe that forwards one header.</p>
<h2 id="thirty-seconds-on-etags">Thirty Seconds on ETags</h2>
<p>Every blob response (and every properties call) includes an <code>ETag</code> header, something like <code>&quot;0x8DC5F3A2B1E4D70&quot;</code>. Storage changes it whenever the blob&rsquo;s content or metadata changes. It costs nothing, it&rsquo;s always there.</p>
<p>On the HTTP side, the handshake is old and boring and great: the server sends <code>ETag</code> with a response; the browser saves it; next time the browser asks for the same URL it includes <code>If-None-Match: &lt;that etag&gt;</code>; if the server still has the same version, it answers <code>304 Not Modified</code> with no body, and the browser uses its cached copy.</p>
<p>Boring. Reliable. Built into every browser since forever.</p>
<h2 id="the-naive-version">The Naive Version</h2>
<p>My first pass was the obvious one:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> response = <span style="color:#66d9ef">await</span> blob.DownloadStreamingAsync(cancellationToken: ct);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> FileStreamResult(response.Value.Content, contentType);
</span></span></code></pre></div><p>Works fine. But look at what the function actually <em>is</em> in this setup: a photocopier standing between storage and the browser, dutifully copying files that both sides already agree on. The browser has the file. Storage knows the file hasn&rsquo;t changed. And my function in the middle is the only one who never asked.</p>
<h2 id="the-trick-be-a-pipe-not-a-cache">The Trick: Be a Pipe, Not a Cache</h2>
<p>The Azure SDK supports conditional requests natively. So instead of checking anything myself, I just hand the browser&rsquo;s ETag straight to storage:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">async</span> Task&lt;AssetResponse?&gt; GetAsync(<span style="color:#66d9ef">string</span> path, <span style="color:#66d9ef">string?</span> ifNoneMatch, CancellationToken ct)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> blob = _container.GetBlobClient(path);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> options = <span style="color:#66d9ef">new</span> BlobDownloadOptions();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Pass the client&#39;s ETag to the storage service as a conditional request.</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Azure Blob Storage will short-circuit at the network level and return a 304,</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// meaning we never transfer the file body when the client is up to date.</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (!<span style="color:#66d9ef">string</span>.IsNullOrEmpty(ifNoneMatch))
</span></span><span style="display:flex;"><span>        options.Conditions = <span style="color:#66d9ef">new</span> BlobRequestConditions { IfNoneMatch = <span style="color:#66d9ef">new</span> ETag(ifNoneMatch) };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> response = <span style="color:#66d9ef">await</span> blob.DownloadStreamingAsync(options, ct);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (response.GetRawResponse().Status == <span style="color:#ae81ff">304</span>)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Client&#39;s copy is current - signal it without a body.</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> AssetResponse { ETag = ifNoneMatch!, IsNotModified = <span style="color:#66d9ef">true</span> };
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> details = response.Value.Details;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> AssetResponse
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            ETag = details.ETag.ToString(),
</span></span><span style="display:flex;"><span>            Content = response.Value.Content,
</span></span><span style="display:flex;"><span>            ContentType = details.ContentType,
</span></span><span style="display:flex;"><span>        };
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">catch</span> (RequestFailedException ex) when (ex.Status == <span style="color:#ae81ff">404</span>)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li>The browser&rsquo;s <code>If-None-Match</code> value goes into <code>BlobRequestConditions.IfNoneMatch</code>. That turns the download into a conditional request, the same ETag handshake, just one hop deeper.</li>
<li>The comparison happens <strong>inside Azure Storage</strong>, not in my code. On a match, storage answers 304 and the response has no body. The file bytes never even reach my function.</li>
<li>On a miss (file changed, or first visit), it&rsquo;s a normal download, and <code>DownloadStreamingAsync</code> gives me a live stream I pass straight through without buffering the whole file in memory.</li>
<li>Missing blob → the <code>RequestFailedException</code> filter turns a 404 into a <code>null</code>, which the endpoint maps to a proper NotFound.</li>
</ol>
<p>There is no dictionary of ETags, no memory cache, no invalidation logic. I didn&rsquo;t build a cache, I <em>connected two caches that already existed</em>: the browser&rsquo;s and storage&rsquo;s own knowledge of its blobs.</p>
<h2 id="finishing-the-loop-toward-the-browser">Finishing the Loop Toward the Browser</h2>
<p>The function endpoint relays the result:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span>req.HttpContext.Response.Headers[HeaderNames.ETag] = asset.ETag;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (asset.IsNotModified)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> StatusCodeResult(StatusCodes.Status304NotModified);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>req.HttpContext.Response.Headers[HeaderNames.CacheControl] = asset.IsImmutable
</span></span><span style="display:flex;"><span>    ? <span style="color:#e6db74">&#34;public, max-age=31536000, immutable&#34;</span>
</span></span><span style="display:flex;"><span>    : <span style="color:#e6db74">&#34;no-cache&#34;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> FileStreamResult(asset.Content!, asset.ContentType!);
</span></span></code></pre></div><p>Two details worth pausing on:</p>
<ul>
<li><strong><code>no-cache</code> doesn&rsquo;t mean &ldquo;don&rsquo;t cache&rdquo;.</strong> It means &ldquo;cache it, but revalidate before using it&rdquo;. That revalidation is exactly the <code>If-None-Match</code> round trip, which the pass-through just made nearly free - a header-only 304 instead of a file download. This is what <code>index.html</code> gets.</li>
<li><strong>Content-hashed files skip the conversation entirely.</strong> My build tool outputs filenames like <code>app.ByJ3R0Az.js</code> - the hash <em>is</em> the version. Those get <code>max-age=31536000, immutable</code>, so the browser never revalidates them at all. A new deploy produces a new filename, which is simply a different URL. The ETag dance is only for files whose names stay stable while their content changes.</li>
</ul>
<h2 id="why-i-like-this-better-than-a-memory-cache">Why I Like This Better Than a Memory Cache</h2>
<p>My first instinct was an <code>IMemoryCache</code> of blob contents in the function. I&rsquo;m glad I resisted:</p>
<ul>
<li><strong>Nothing to size.</strong> No &ldquo;how many MB of blobs do I keep in memory&rdquo; question.</li>
<li><strong>Nothing to invalidate.</strong> The ETag comparison is against live storage, so a deploy is visible on the very next request. Stale-cache bugs can&rsquo;t exist because there&rsquo;s no cache to go stale.</li>
<li><strong>Scale-out safe.</strong> Ten function instances behave identically because none of them hold state. A per-instance memory cache would give ten different answers for 60 seconds after every deploy.</li>
</ul>
<p>The thing that owns the data does the validation. Everyone else just forwards headers. 📮</p>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong>Round-trip the ETag string untouched.</strong> The quotes are part of the value. Trim them, &ldquo;clean them up&rdquo;, or re-wrap them and the comparison silently never matches again - everything still works, you just download every file every time and never notice.</li>
<li><strong>No ETag out, no <code>If-None-Match</code> back.</strong> Browsers only revalidate if your response included the <code>ETag</code> header in the first place. Forget it on one branch (error paths are a classic) and that file is a full download forever.</li>
<li><strong><code>no-cache</code> still costs one round trip per file per load.</strong> That&rsquo;s the deal: a header-only 304 instead of a body. For files that never change, don&rsquo;t negotiate - content-hash the filename and go <code>immutable</code>.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>The cheapest download is the one that never happens. Blob Storage already fingerprints every blob and already speaks conditional requests - most backends just never pass the browser&rsquo;s question along. Forward <code>If-None-Match</code>, relay the 304, and let the two parties who actually know the answer talk to each other.</p>
<h2 id="references-">References 📚</h2>
<ul>
<li><a href="https://learn.microsoft.com/en-us/rest/api/storageservices/specifying-conditional-headers-for-blob-service-operations">Specify conditional headers for Blob Storage operations</a></li>
<li><a href="https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blob-properties-metadata">Manage properties and metadata for a blob with .NET</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag">ETag (MDN)</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control">Cache-Control (MDN)</a></li>
</ul>
]]></content:encoded></item><item><title>One Endpoint, Whole Website: Serving a Static Site Through an Azure Function</title><link>https://jeppe-spanggaard.dk/blogs/azure-function-blob-storage-static-site-proxy/</link><pubDate>Thu, 25 Jun 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/azure-function-blob-storage-static-site-proxy/</guid><description>Learn how a single Azure Function with a catch-all route can proxy an entire static website out of a private Blob Storage container, with auth in front of every file.</description><content:encoded><![CDATA[<p>I needed to host an internal handbook. Nothing fancy: a static site full of onboarding guides and how-tos, built with a static site generator into a folder of HTML, CSS, JS, and images. The one hard requirement: <strong>only signed-in employees get to see it.</strong></p>
<p>And that requirement quietly kills all the easy hosting options. Blob Storage&rsquo;s static website feature? Public. A plain CDN? Public. The moment &ldquo;who is asking&rdquo; matters for every single file - not just the pages, the images and search index too - the files have to live somewhere private, and <em>something</em> with auth has to sit in front and hand them out.</p>
<p>My something is one Azure Function. One endpoint. It serves the entire site.</p>
<h2 id="tldr">TL;DR</h2>
<p>Put the static build in a <strong>private</strong> blob container. Write one HTTP-triggered function with a catch-all route (<code>{*path}</code>). For every request: check the session, map the route to a blob path, stream the blob back. <code>/</code>, <code>/guides/onboarding/</code>, <code>/assets/main.css</code> - same function, same twenty lines, whole website.</p>
<h2 id="the-blob-container-is-the-filesystem">The Blob Container Is the Filesystem</h2>
<p>There&rsquo;s no clever mapping layer. I upload the build output into the container exactly as the generator produced it:</p>
<pre tabindex="0"><code>index.html
guides/onboarding/index.html
guides/expenses/index.html
assets/main.css
assets/search.js
images/office-map.png
</code></pre><p>The container layout <em>is</em> the URL space. Deploying a new version of the site is one CLI command:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-powershell" data-lang="powershell"><span style="display:flex;"><span>az storage blob upload-batch --source ./public --destination site-content --overwrite
</span></span></code></pre></div><h2 id="the-catch-all-function">The Catch-All Function</h2>
<p>Here&rsquo;s the whole thing, trimmed to its skeleton:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#a6e22e">[Function(&#34;Site&#34;)]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">async</span> Task&lt;IActionResult&gt; Run(
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">    [HttpTrigger(AuthorizationLevel.Anonymous, &#34;get&#34;, Route = &#34;{*path}&#34;)]</span> HttpRequest req,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string?</span> path,
</span></span><span style="display:flex;"><span>    CancellationToken ct)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Every file goes through this gate - pages, scripts, images, all of it.</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (!<span style="color:#66d9ef">await</span> _auth.IsSignedInAsync(req))
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> UnauthorizedResult();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> blobPath = MapToBlobPath(path);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> blob = _container.GetBlobClient(blobPath);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> response = <span style="color:#66d9ef">await</span> blob.DownloadStreamingAsync(cancellationToken: ct);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> contentType = response.Value.Details.ContentType ?? ContentTypeFor(blobPath);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> FileStreamResult(response.Value.Content, contentType);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">catch</span> (RequestFailedException ex) when (ex.Status == <span style="color:#ae81ff">404</span>)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> NotFoundResult();
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">string</span> MapToBlobPath(<span style="color:#66d9ef">string?</span> path)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">string</span>.IsNullOrEmpty(path))
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#e6db74">&#34;index.html&#34;</span>;                    <span style="color:#75715e">// &#34;/&#34; → the front page</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (path.EndsWith(<span style="color:#e6db74">&#39;/&#39;</span>) || !Path.HasExtension(path))
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#e6db74">$&#34;{path.TrimEnd(&#39;/&#39;)}/index.html&#34;</span>; <span style="color:#75715e">// &#34;/guides/onboarding/&#34; → its index</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> path;                                 <span style="color:#75715e">// &#34;/assets/main.css&#34; → as-is</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li><code>Route = &quot;{*path}&quot;</code> is the whole trick. The <code>*</code> makes it a catch-all: one route binding matches <code>/</code>, <code>/guides/onboarding/</code>, <code>/assets/main.css</code>, anything. One function, every file on the site.</li>
<li>The auth guard runs before anything touches storage. That&rsquo;s the entire reason this setup exists - a static host can protect a <em>site</em>, this protects every <em>byte</em>. (How <code>IsSignedInAsync</code> works - cookies, Entra ID, whatever fits - is its own topic; the point is it&rsquo;s one <code>if</code> at the top.)</li>
<li><code>MapToBlobPath</code> does the job a web server normally does silently: default documents. <code>/</code> becomes <code>index.html</code>, extension-less routes like <code>/guides/onboarding</code> become <code>guides/onboarding/index.html</code>. Forget this and your front page is a 404.</li>
<li><code>DownloadStreamingAsync</code> returns a live stream, and <code>FileStreamResult</code> pipes it straight to the response. The function never buffers a whole file in memory - a 4 MB image flows through, it doesn&rsquo;t <em>land</em> here.</li>
<li>A missing blob throws <code>RequestFailedException</code> with status 404; the exception filter turns that into a clean <code>NotFoundResult</code> instead of a pre-flight existence check (which would just be a second storage call).</li>
</ol>
<h2 id="content-types-the-unglamorous-part-that-breaks-everything">Content Types: The Unglamorous Part That Breaks Everything</h2>
<p>If you serve HTML with the wrong <code>Content-Type</code>, the browser doesn&rsquo;t render your page, it <em>downloads</em> it. Ask me how I know.</p>
<p>Best option: set correct content types on the blobs at upload time (<code>upload-batch</code> infers most of them). But blobs uploaded by hand or by older scripts often end up as <code>application/octet-stream</code>, so I keep a fallback map:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">string</span> ContentTypeFor(<span style="color:#66d9ef">string</span> path) =&gt; Path.GetExtension(path).ToLowerInvariant() <span style="color:#66d9ef">switch</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;.html&#34;</span> =&gt; <span style="color:#e6db74">&#34;text/html&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;.css&#34;</span>  =&gt; <span style="color:#e6db74">&#34;text/css&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;.js&#34;</span>   =&gt; <span style="color:#e6db74">&#34;text/javascript&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;.json&#34;</span> =&gt; <span style="color:#e6db74">&#34;application/json&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;.svg&#34;</span>  =&gt; <span style="color:#e6db74">&#34;image/svg+xml&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;.png&#34;</span>  =&gt; <span style="color:#e6db74">&#34;image/png&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;.woff2&#34;</span> =&gt; <span style="color:#e6db74">&#34;font/woff2&#34;</span>,
</span></span><span style="display:flex;"><span>    _ =&gt; <span style="color:#e6db74">&#34;application/octet-stream&#34;</span>,
</span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><h2 id="what-you-get-for-free">What You Get for Free</h2>
<ul>
<li><strong>Auth on every file.</strong> Not just page-level protection - the org chart PNG and the search index JSON are exactly as protected as the pages.</li>
<li><strong>One deploy target.</strong> Build the site, <code>upload-batch</code> the folder, done. No web server to configure, nothing to restart.</li>
<li><strong>Generator-agnostic.</strong> The function doesn&rsquo;t know or care if the folder came from Hugo, Astro, Docusaurus, or hand-written HTML.</li>
<li><strong>Cheap environments.</strong> Staging is just a second container and one config value. Rollback is re-uploading yesterday&rsquo;s build folder.</li>
</ul>
<h2 id="one-more-trick-stop-re-downloading-unchanged-files">One More Trick: Stop Re-Downloading Unchanged Files</h2>
<p>There&rsquo;s an elephant in the version above: every request downloads the full file from storage and pushes it to the browser - even when the file hasn&rsquo;t changed in weeks and the browser has a perfect copy from five minutes ago.</p>
<p>The fix is already sitting in Blob Storage: every blob has an <strong>ETag</strong>, a version fingerprint that changes on every write. Browsers already know the game - once they&rsquo;ve seen an <code>ETag</code> header, they send it back as <code>If-None-Match</code> on the next request. All the function has to do is forward that header into the SDK call:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> options = <span style="color:#66d9ef">new</span> BlobDownloadOptions();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> ifNoneMatch = req.Headers.IfNoneMatch.ToString();
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (!<span style="color:#66d9ef">string</span>.IsNullOrEmpty(ifNoneMatch))
</span></span><span style="display:flex;"><span>    options.Conditions = <span style="color:#66d9ef">new</span> BlobRequestConditions { IfNoneMatch = <span style="color:#66d9ef">new</span> ETag(ifNoneMatch) };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> response = <span style="color:#66d9ef">await</span> blob.DownloadStreamingAsync(options, ct);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (response.GetRawResponse().Status == <span style="color:#ae81ff">304</span>)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Browser&#39;s copy is current - Azure never sent us the body at all.</span>
</span></span><span style="display:flex;"><span>    req.HttpContext.Response.Headers[HeaderNames.ETag] = ifNoneMatch;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> StatusCodeResult(StatusCodes.Status304NotModified);
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>req.HttpContext.Response.Headers[HeaderNames.ETag] = response.Value.Details.ETag.ToString();
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> contentType = response.Value.Details.ContentType ?? ContentTypeFor(blobPath);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> FileStreamResult(response.Value.Content, contentType);
</span></span></code></pre></div><p>The beautiful part: the ETag comparison happens <strong>inside Azure Storage</strong>, not in your code. On a match, storage answers 304 with no body - the file bytes never reach the function, and the function relays a bare 304 to the browser. No server-side cache, nothing to invalidate, and it works identically across scaled-out instances because nobody holds any state. The browser is the cache, Azure is the validator, and the function stays what it was: a pipe.</p>
<p>Two rules to make it stick: always set the <code>ETag</code> response header (no ETag out means the browser never asks conditionally again), and pass the value through untouched - the quotes are part of it, and &ldquo;cleaning them up&rdquo; silently breaks the match forever.</p>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong>You are the web server now.</strong> Default documents, trailing slashes, 404 pages - all the invisible things a real web server does are your job. <code>MapToBlobPath</code> above is the minimum, not the maximum.</li>
<li><strong>SPA? Then unknown routes need a fallback.</strong> For a client-side-routed app, a route with no matching blob should serve <code>index.html</code> (200, not 404) and let the router sort it out. For a docs site like mine, a real 404 is correct.</li>
<li><strong>Cold starts sit in front of your CSS.</strong> The function is in the path of <em>every byte</em>, so a consumption-plan cold start delays the whole page, not just an API call. For an internal tool I can live with it; know your tolerance.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>One catch-all route, a private container, and a twenty-line function: a whole authenticated website with no web server to run. Add the <code>If-None-Match</code> pass-through and unchanged files stop moving entirely. The container is the filesystem, the function is the doorman. 🚪</p>
<h2 id="references-">References 📚</h2>
<ul>
<li><a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-http-webhook-trigger">Azure Functions HTTP trigger - route parameters</a></li>
<li><a href="https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blob-download">Download a blob with .NET</a></li>
<li><a href="https://learn.microsoft.com/en-us/cli/azure/storage/blob#az-storage-blob-upload-batch">az storage blob upload-batch</a></li>
<li><a href="https://learn.microsoft.com/en-us/rest/api/storageservices/specifying-conditional-headers-for-blob-service-operations">Specify conditional headers for Blob Storage operations</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag">ETag (MDN)</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type">Content-Type (MDN)</a></li>
</ul>
]]></content:encoded></item><item><title>Your Add-in Gets a Free Folder in Everyone's OneDrive (Use It)</title><link>https://jeppe-spanggaard.dk/blogs/outlook-addin-onedrive-approot-preferences/</link><pubDate>Mon, 15 Jun 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/outlook-addin-onedrive-approot-preferences/</guid><description>Learn how to store Outlook add-in user preferences in the OneDrive App Folder via the Graph approot endpoint, so settings roam across every device.</description><content:encoded><![CDATA[<p>My Outlook add-in has a handful of user preferences: which tab to open by default, language, a &ldquo;don&rsquo;t show this tip again&rdquo; flag. Small stuff. A single JSON blob.</p>
<p>So where do you put it? <code>localStorage</code> is per-device, per-browser, and mobile WebViews evict it whenever they feel like it. A backend with a database is a lot of infrastructure for one JSON file per user. And then it hit me: every single one of my users already <em>has</em> cloud storage my add-in can reach. Their OneDrive.</p>
<h2 id="tldr">TL;DR</h2>
<p>Microsoft Graph gives every app its own private folder in the user&rsquo;s OneDrive: the <strong>App Folder</strong>, reachable at <code>/me/drive/special/approot</code>. Drop a <code>user-settings.json</code> in there. Preferences now follow the user to desktop, web, and mobile, and with the <code>Files.ReadWrite.AppFolder</code> permission your app can&rsquo;t touch anything else in their drive.</p>
<h2 id="what-the-app-folder-actually-is">What the App Folder Actually Is</h2>
<p>First time your app touches <code>approot</code>, OneDrive creates a folder under <code>Apps/&lt;your app's name&gt;</code> (the name comes from your Entra app registration). It&rsquo;s the user&rsquo;s storage, visible to them in OneDrive, but sandboxed for you: request <code>Files.ReadWrite.AppFolder</code> and that folder is <em>all</em> your app can see. No scary &ldquo;this app can read all your files&rdquo; consent screen.</p>
<p>One endpoint to remember:</p>
<pre tabindex="0"><code>/me/drive/special/approot
</code></pre><h2 id="saving-settings">Saving Settings</h2>
<p>I use PnPjs (<code>@pnp/graph</code>), so a save is three lines:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">SpecialFolder</span> } <span style="color:#66d9ef">from</span> <span style="color:#e6db74">&#34;@pnp/graph/files&#34;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">saveUserSettings</span>(<span style="color:#a6e22e">settings</span>: <span style="color:#66d9ef">UserDefinedSettings</span>)<span style="color:#f92672">:</span> <span style="color:#a6e22e">Promise</span>&lt;<span style="color:#f92672">void</span>&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">appRoot</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">graphFI</span>.<span style="color:#a6e22e">me</span>.<span style="color:#a6e22e">drive</span>.<span style="color:#a6e22e">special</span>(<span style="color:#a6e22e">SpecialFolder</span>.<span style="color:#a6e22e">AppRoot</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">appRoot</span>.<span style="color:#a6e22e">upload</span>({
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">content</span>: <span style="color:#66d9ef">JSON.stringify</span>(<span style="color:#a6e22e">settings</span>),
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">filePathName</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;user-settings.json&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">contentType</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;application/json&#34;</span>,
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>No PnPjs? The raw Graph equivalent is a single PUT: <code>PUT /me/drive/special/approot:/user-settings.json:/content</code>.</p>
<h2 id="reading-settings-and-surviving-the-first-run">Reading Settings (and Surviving the First Run)</h2>
<p>Reading has one twist: the very first time a user opens your add-in, the file doesn&rsquo;t exist yet. That&rsquo;s not an error, that&rsquo;s a new user. Plan for it:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">defaultSettings</span>: <span style="color:#66d9ef">UserDefinedSettings</span> <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">openPreviewInNewTab</span>: <span style="color:#66d9ef">false</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">returnToHomeAfterArchive</span>: <span style="color:#66d9ef">false</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">hasSeenPinTip</span>: <span style="color:#66d9ef">false</span>,
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getUserSettings</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Promise</span>&lt;<span style="color:#f92672">UserDefinedSettings</span>&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">appRoot</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">graphFI</span>.<span style="color:#a6e22e">me</span>.<span style="color:#a6e22e">drive</span>.<span style="color:#a6e22e">special</span>(<span style="color:#a6e22e">SpecialFolder</span>.<span style="color:#a6e22e">AppRoot</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">children</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">appRoot</span>.<span style="color:#a6e22e">children</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">fileItem</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">children</span>.<span style="color:#a6e22e">find</span>((<span style="color:#a6e22e">item</span>) <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">item</span>.<span style="color:#a6e22e">name</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#34;user-settings.json&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">fileItem</span>) {
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">defaultSettings</span>; <span style="color:#75715e">// first run - no file yet
</span></span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">blob</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">graphFI</span>.<span style="color:#a6e22e">me</span>.<span style="color:#a6e22e">drive</span>.<span style="color:#a6e22e">getItemById</span>(<span style="color:#a6e22e">fileItem</span>.<span style="color:#a6e22e">id</span><span style="color:#f92672">!</span>).<span style="color:#a6e22e">getContent</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">json</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">JSON</span>.<span style="color:#a6e22e">parse</span>(<span style="color:#66d9ef">await</span> <span style="color:#a6e22e">blob</span>.<span style="color:#a6e22e">text</span>());
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> {
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">openPreviewInNewTab</span>: <span style="color:#66d9ef">json.openPreviewInNewTab</span> <span style="color:#f92672">??</span> <span style="color:#a6e22e">defaultSettings</span>.<span style="color:#a6e22e">openPreviewInNewTab</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">returnToHomeAfterArchive</span>: <span style="color:#66d9ef">json.returnToHomeAfterArchive</span> <span style="color:#f92672">??</span> <span style="color:#a6e22e">defaultSettings</span>.<span style="color:#a6e22e">returnToHomeAfterArchive</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">hasSeenPinTip</span>: <span style="color:#66d9ef">json.hasSeenPinTip</span> <span style="color:#f92672">??</span> <span style="color:#a6e22e">defaultSettings</span>.<span style="color:#a6e22e">hasSeenPinTip</span>,
</span></span><span style="display:flex;"><span>    };
</span></span><span style="display:flex;"><span>  } <span style="color:#66d9ef">catch</span> (<span style="color:#a6e22e">error</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">console</span>.<span style="color:#a6e22e">error</span>(<span style="color:#e6db74">&#34;Error getting user settings:&#34;</span>, <span style="color:#a6e22e">error</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">defaultSettings</span>; <span style="color:#75715e">// any failure - the add-in still works
</span></span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li>Missing file → defaults, not an exception. New users get a working add-in, not an error toast.</li>
<li>Every field is parsed with a <code>??</code> fallback. When I ship a new setting next month, users with an old <code>user-settings.json</code> don&rsquo;t break, the new field just gets its default.</li>
<li>The whole thing is wrapped in try/catch that returns defaults. Settings are a nice-to-have; they should never take the add-in down with them.</li>
</ol>
<p>Two practical habits: load the settings once at boot and cache them (don&rsquo;t re-read OneDrive on every render), and save optimistically - update your local state immediately, fire the upload without awaiting it. And the best part comes for free: change a setting on desktop, open the phone, it&rsquo;s there. 🎁</p>
<h2 id="not-just-outlook-add-ins-spfx-too">Not Just Outlook Add-ins: SPFx Too</h2>
<p>This pattern isn&rsquo;t tied to Outlook at all, it just needs a user context and a Graph token. That makes it a great fit for <strong>SPFx web parts</strong> as well.</p>
<p>Web part properties in SPFx are per-instance and per-page, and usually something an editor configures, not the end user. If you want <em>user</em>-level preferences - a collapsed/expanded state, a preferred view, a dismissed banner - that follow the user across every page and site where your web part lives, the App Folder solves it with zero extra infrastructure. Grab <code>MSGraphClientV3</code> from the SPFx context and hit the same <code>/me/drive/special/approot:/user-settings.json:/content</code> endpoint. Same folder, same JSON file, same permission model.</p>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong>Ask for <code>Files.ReadWrite.AppFolder</code>, not <code>Files.ReadWrite</code>.</strong> If the app folder is all you need, the scoped permission gets you a much friendlier consent prompt and a much smaller blast radius.</li>
<li><strong>The folder is named after your app registration&rsquo;s display name.</strong> Rename the registration and users get a <em>new</em> empty folder, your settings file stays behind in the old one.</li>
<li><strong>Users can see (and delete) the folder.</strong> It&rsquo;s their OneDrive. Treat a missing file as a normal state, always, not just on first run.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>User preferences don&rsquo;t need a database, and they deserve better than <code>localStorage</code>. The OneDrive App Folder is the middle ground that&rsquo;s easy to miss: zero infrastructure on your side, real cloud persistence on theirs, and a permission model that only exposes what your app actually needs. Outlook add-in, SPFx web part, anything with a Graph token - same trick everywhere.</p>
<h2 id="references-">References 📚</h2>
<ul>
<li><a href="https://learn.microsoft.com/en-us/onedrive/developer/rest-api/concepts/special-folders-appfolder">Using an App Folder to store user content without access to all files</a></li>
<li><a href="https://learn.microsoft.com/en-us/graph/api/drive-get-specialfolder">Get a special folder by name (Microsoft Graph)</a></li>
<li><a href="https://learn.microsoft.com/en-us/graph/api/driveitem-put-content">Upload or replace the contents of a DriveItem</a></li>
</ul>
]]></content:encoded></item><item><title>Same Email, Different Source: Falling Back to Microsoft Graph on Outlook Mobile</title><link>https://jeppe-spanggaard.dk/blogs/outlook-addin-graph-fallback-mobile/</link><pubDate>Fri, 05 Jun 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/outlook-addin-graph-fallback-mobile/</guid><description>Learn how to fall back to the Microsoft Graph MIME endpoint when getAsFileAsync isn't available in Outlook on mobile, so your add-in keeps working everywhere.</description><content:encoded><![CDATA[<p>In <a href="https://jeppe-spanggaard.dk/blogs/outlook-addin-manifest-requirement-sets-mobile/">my last post</a> I showed how declaring a low Mailbox requirement set in the manifest got my Outlook add-in to <em>show up</em> on mobile. Great. Button&rsquo;s there, task pane opens, everything looks alive.</p>
<p>Then the user taps &ldquo;Archive to SharePoint&rdquo; and the whole feature stands on one API: <code>getAsFileAsync()</code>, which hands you the entire email as a file. That API lives in Mailbox 1.14. Outlook mobile doesn&rsquo;t have it.</p>
<p>So now I had the opposite problem from last time: instead of an invisible add-in, I had a visible add-in with a dead button. Honestly, that&rsquo;s worse.</p>
<p>Visible ≠ functional. This post is about the second half of the mobile story: getting the same email bytes through a different door.</p>
<h2 id="tldr">TL;DR</h2>
<p>When <code>getAsFileAsync</code> isn&rsquo;t available, download the exact same MIME content from Microsoft Graph instead: <code>GET /me/messages/{id}/$value</code> returns the raw RFC 822 message. Wrap both paths so they return the same <code>Blob</code>, put the Graph one in a <code>catch</code>, and everything downstream of &ldquo;give me the email as a file&rdquo; never knows the difference.</p>
<h2 id="the-desktop-path-just-ask-outlook">The Desktop Path: Just Ask Outlook</h2>
<p>On desktop and web, Office.js does all the work. You ask the host for the current message as a file, and it hands you the EML as base64. No network call, no token, no permissions dance, Outlook already <em>has</em> the email.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getMessageAsBlob</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Promise</span><span style="color:#f92672">&lt;</span>{ <span style="color:#a6e22e">bytes</span>: <span style="color:#66d9ef">Uint8Array</span>; <span style="color:#a6e22e">blob</span>: <span style="color:#66d9ef">Blob</span> }<span style="color:#f92672">&gt;</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Promise</span>((<span style="color:#a6e22e">resolve</span>, <span style="color:#a6e22e">reject</span>) <span style="color:#f92672">=&gt;</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">requirements</span>.<span style="color:#a6e22e">isSetSupported</span>(<span style="color:#e6db74">&#34;Mailbox&#34;</span>, <span style="color:#e6db74">&#34;1.14&#34;</span>)) {
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">reject</span>(<span style="color:#66d9ef">new</span> Error(<span style="color:#e6db74">&#34;This client does not support Mailbox 1.14 (getAsFileAsync).&#34;</span>));
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">item</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">mailbox</span>.<span style="color:#a6e22e">item</span> <span style="color:#66d9ef">as</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">MessageRead</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">item</span>.<span style="color:#a6e22e">getAsFileAsync</span>((<span style="color:#a6e22e">asyncResult</span>) <span style="color:#f92672">=&gt;</span> {
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">asyncResult</span>.<span style="color:#a6e22e">status</span> <span style="color:#f92672">===</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">AsyncResultStatus</span>.<span style="color:#a6e22e">Failed</span>) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">reject</span>(<span style="color:#a6e22e">asyncResult</span>.<span style="color:#a6e22e">error</span>);
</span></span><span style="display:flex;"><span>      }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#75715e">// getAsFileAsync returns the EML as base64
</span></span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">bytes</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">Uint8Array</span>.<span style="color:#66d9ef">from</span>(<span style="color:#a6e22e">atob</span>(<span style="color:#a6e22e">asyncResult</span>.<span style="color:#a6e22e">value</span>), (<span style="color:#a6e22e">c</span>) <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">charCodeAt</span>(<span style="color:#ae81ff">0</span>));
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">blob</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Blob</span>([<span style="color:#a6e22e">bytes</span>], { <span style="color:#66d9ef">type</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;message/rfc822&#34;</span> });
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">resolve</span>({ <span style="color:#a6e22e">bytes</span>, <span style="color:#a6e22e">blob</span> });
</span></span><span style="display:flex;"><span>    });
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Notice the first thing this function does: check <code>isSetSupported(&quot;Mailbox&quot;, &quot;1.14&quot;)</code> and reject if it&rsquo;s not there. That reject is not an error case, it&rsquo;s a <em>signal</em>. Remember it, it becomes important in a minute.</p>
<h2 id="the-graph-path-ask-exchange-instead">The Graph Path: Ask Exchange Instead</h2>
<p>Here&rsquo;s the realization that saved the mobile experience: <strong>Outlook doesn&rsquo;t own your email, Exchange does.</strong> The host app is just one way to get at it. Microsoft Graph is another, and Graph has an endpoint that returns the raw MIME content of any message:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getMessageAsBlobViaGraph</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Promise</span><span style="color:#f92672">&lt;</span>{ <span style="color:#a6e22e">bytes</span>: <span style="color:#66d9ef">Uint8Array</span>; <span style="color:#a6e22e">blob</span>: <span style="color:#66d9ef">Blob</span> }<span style="color:#f92672">&gt;</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">item</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">mailbox</span>.<span style="color:#a6e22e">item</span> <span style="color:#66d9ef">as</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">MessageRead</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">item</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">itemId</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Error(<span style="color:#e6db74">&#34;No item ID available for Graph-based archive.&#34;</span>);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">token</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">getToken</span>([<span style="color:#e6db74">&#34;Mail.ReadWrite.Shared&#34;</span>]);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">mimeUrl</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`https://graph.microsoft.com/v1.0/me/messages/</span><span style="color:#e6db74">${</span>encodeURIComponent(<span style="color:#a6e22e">item</span>.<span style="color:#a6e22e">itemId</span>)<span style="color:#e6db74">}</span><span style="color:#e6db74">/$value`</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">resp</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">fetch</span>(<span style="color:#a6e22e">mimeUrl</span>, {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">method</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;GET&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">headers</span><span style="color:#f92672">:</span> { <span style="color:#a6e22e">Authorization</span><span style="color:#f92672">:</span> <span style="color:#e6db74">`Bearer </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">token</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</span> },
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">resp</span>.<span style="color:#a6e22e">ok</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Error(<span style="color:#e6db74">`Error </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">resp</span>.<span style="color:#a6e22e">status</span><span style="color:#e6db74">}</span><span style="color:#e6db74">: </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">resp</span>.<span style="color:#a6e22e">statusText</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">buffer</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">resp</span>.<span style="color:#a6e22e">arrayBuffer</span>();
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">bytes</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Uint8Array</span>(<span style="color:#a6e22e">buffer</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">blob</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Blob</span>([<span style="color:#a6e22e">buffer</span>], { <span style="color:#66d9ef">type</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;message/rfc822&#34;</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> { <span style="color:#a6e22e">bytes</span>, <span style="color:#a6e22e">blob</span> };
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li><code>item.itemId</code> is all we need from Office.js, and reading it only requires <strong>Mailbox 1.1</strong>. That&rsquo;s the beautiful part: the manifest floor from the last post promised almost nothing, and this fallback only <em>needs</em> almost nothing.</li>
<li>The <code>$value</code> segment on <code>/me/messages/{id}</code> tells Graph to skip the JSON representation and return the raw RFC 822 message, the same EML you&rsquo;d get from <code>getAsFileAsync</code>.</li>
<li>We wrap it in a <code>Blob</code> with <code>message/rfc822</code>, the exact same shape the desktop path produces. Same email, different source.</li>
<li>The token comes from MSAL with the <code>Mail.ReadWrite.Shared</code> scope. And yes, getting a token <em>inside Outlook mobile</em> is its own adventure, the magic words are Nested App Auth (NAA), where sign-in goes through the Microsoft Authenticator app instead of a browser popup. That one deserves its own post someday.</li>
</ol>
<p>One honest caveat: I pass <code>item.itemId</code> straight to Graph without converting it. That works because modern hosts (including mobile) hand out REST-format IDs. If your add-in also runs in older Outlook clients that still produce EWS-format IDs, run the ID through <code>Office.context.mailbox.convertToRestId()</code> first, or Graph will give you a very confusing 404.</p>
<h2 id="dont-branch-fall-back">Don&rsquo;t Branch, Fall Back</h2>
<p>So we have two functions. The tempting way to pick between them is <code>if (isMobile) { ... } else { ... }</code>. I did it differently, and I&rsquo;m glad I did:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">let</span> <span style="color:#a6e22e">messageAsBlob</span><span style="color:#f92672">:</span> { <span style="color:#a6e22e">bytes</span>: <span style="color:#66d9ef">Uint8Array</span>; <span style="color:#a6e22e">blob</span>: <span style="color:#66d9ef">Blob</span> };
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">messageAsBlob</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">getMessageAsBlob</span>();
</span></span><span style="display:flex;"><span>} <span style="color:#66d9ef">catch</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#75715e">// Office.js getAsFileAsync unavailable (e.g. mobile) - fall back to Graph
</span></span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">getMessageAsBlobViaGraph</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">messageAsBlob</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">getMessageAsBlobViaGraph</span>();
</span></span><span style="display:flex;"><span>  } <span style="color:#66d9ef">else</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Error(<span style="color:#e6db74">&#34;Email export is not available on this platform.&#34;</span>);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Always try Office.js first, and let the failure route you to Graph.</strong> Remember that reject on the 1.14 check? On mobile it fires every time, and the catch block quietly takes the Graph road instead.</p>
<p>Why is this better than platform-branching? Because it&rsquo;s <em>capability</em>-based, not <em>platform</em>-based. If some desktop client out there is running an older Outlook without 1.14, it gets the fallback for free. I never had to predict which platforms are broken, the code just asks &ldquo;did the good path work?&rdquo; and moves on.</p>
<p>I do add one belt-and-suspenders detail: the Graph function is only injected into this flow when platform detection says we&rsquo;re on mobile. On desktop it&rsquo;s <code>undefined</code>, so a genuinely broken desktop fails loudly with a clear message instead of silently making Graph calls I didn&rsquo;t expect.</p>
<h2 id="same-blob-same-pipeline">Same Blob, Same Pipeline</h2>
<p>This is the part I want you to steal. Both functions return the same thing:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span>{ <span style="color:#a6e22e">bytes</span>: <span style="color:#66d9ef">Uint8Array</span>; <span style="color:#a6e22e">blob</span>: <span style="color:#66d9ef">Blob</span> }  <span style="color:#75715e">// type: &#34;message/rfc822&#34;
</span></span></span></code></pre></div><p>Everything after this point - naming the file, uploading it to SharePoint, progress reporting, conflict handling - is <strong>one code path</strong>. The upload logic has no idea whether the bytes came from Office.js or from a Graph call. There&rsquo;s no <code>if (isMobile)</code> sprinkled through the upload code, no duplicated pipeline, nothing.</p>
<p>That&rsquo;s the whole trick, really. A good fallback isn&rsquo;t a second feature, it&rsquo;s a second <em>source</em> feeding the same feature. The moment your fallback needs its own downstream handling, you&rsquo;ve built two features and doubled your bugs.</p>
<h2 id="gotchas-i-hit-along-the-way">Gotchas I Hit Along the Way</h2>
<ul>
<li><strong>ID formats will bite you.</strong> REST IDs and EWS IDs look similar enough (long base64-ish strings) that you won&rsquo;t spot the difference by eye. If Graph returns 404 for a message you&rsquo;re literally looking at, check the ID format before questioning your sanity. <code>convertToRestId()</code> is the fix.</li>
<li><strong>Graph only knows what the server knows.</strong> <code>getAsFileAsync</code> reads from the host, Graph reads from Exchange. For a message that <em>just</em> arrived, the server side can lag a beat behind what Outlook is already showing you. Rare, but real.</li>
<li><strong>No retry for free.</strong> Office.js calls fail locally and instantly. The Graph call is a network request that can hit throttling or transient errors, and a bare <code>fetch</code> won&rsquo;t retry anything. Mine throws on non-OK responses and lets the surrounding archive flow surface the error; depending on your feature, a retry with backoff might be worth it.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>The <a href="https://jeppe-spanggaard.dk/blogs/outlook-addin-manifest-requirement-sets-mobile/">manifest post</a> got the add-in through the door on mobile. This post made it actually earn its place there: use Office.js when the host can deliver, fall back to Graph when it can&rsquo;t, and make both paths hand over identical bytes so the rest of your app never has to care.</p>
<p>Outlook is just one door to the mailbox. When it&rsquo;s locked, Graph is around the back. 🚪</p>
<h2 id="references-">References 📚</h2>
<ul>
<li><a href="https://learn.microsoft.com/en-us/graph/outlook-get-mime-message">Get MIME content of a message (Microsoft Graph)</a></li>
<li><a href="https://learn.microsoft.com/en-us/javascript/api/outlook/office.messageread">Office.MessageRead.getAsFileAsync()</a></li>
<li><a href="https://learn.microsoft.com/en-us/javascript/api/outlook/office.mailbox">Office.context.mailbox.convertToRestId()</a></li>
<li><a href="https://learn.microsoft.com/en-us/office/dev/add-ins/outlook/outlook-mobile-apis">Outlook JavaScript APIs supported in Outlook on mobile devices</a></li>
<li><a href="https://learn.microsoft.com/en-us/office/dev/add-ins/develop/enable-nested-app-authentication-in-your-add-in">Enable SSO in an Office Add-in with Nested App Authentication</a></li>
</ul>
]]></content:encoded></item><item><title>There's No Popup on a Phone: Nested App Auth for Outlook Add-ins</title><link>https://jeppe-spanggaard.dk/blogs/outlook-addin-nested-app-auth-mobile/</link><pubDate>Thu, 28 May 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/outlook-addin-nested-app-auth-mobile/</guid><description>Learn how to get MSAL tokens inside Outlook on mobile with Nested App Auth (NAA), from the brk-multihub redirect URI to acquireTokenSilent with a loginHint.</description><content:encoded><![CDATA[<p>This is the third post in what accidentally became a trilogy about getting my Outlook add-in to work on mobile. <a href="https://jeppe-spanggaard.dk/blogs/outlook-addin-manifest-requirement-sets-mobile/">Post one</a> made the add-in <em>visible</em> on the phone. <a href="https://jeppe-spanggaard.dk/blogs/outlook-addin-graph-fallback-mobile/">Post two</a> made it <em>functional</em> by falling back to a Microsoft Graph call when <code>getAsFileAsync</code> wasn&rsquo;t there.</p>
<p>And in that second post I wrote one very casual line: <em>&ldquo;The token comes from MSAL.&rdquo;</em></p>
<p>Yeah. About that.</p>
<p>On desktop, MSAL gets a token by opening a browser popup where the user signs in. On mobile there is no browser. Your add-in runs in a WebView <em>inside</em> the Outlook app. There&rsquo;s no window to pop, no tab to redirect, nowhere for the classic OAuth dance to happen. My first attempt at calling Graph from the phone died right there, not on the API call, on the sign-in before it.</p>
<p>The fix has a name: <strong>Nested App Auth (NAA)</strong>.</p>
<h2 id="tldr">TL;DR</h2>
<p>Your add-in is an app nested inside another app (Outlook), so let the <em>outer</em> app handle authentication. NAA makes Outlook act as an auth broker for your add-in: you set <code>supportsNestedAppAuth: true</code> in the MSAL config, create the client with <code>createNestablePublicClientApplication</code>, and add one strange-looking redirect URI (<code>brk-multihub://your-domain</code>) to your Azure app registration. When interaction is needed on mobile, sign-in goes through the Microsoft Authenticator app instead of a browser popup.</p>
<h2 id="why-normal-msal-dies-on-mobile">Why Normal MSAL Dies on Mobile</h2>
<p>The standard MSAL flows (<code>loginPopup</code>, <code>loginRedirect</code>) both assume your code lives in a browser. A popup needs <code>window.open</code>. A redirect needs the page to navigate away to login.microsoftonline.com and come back with your app still knowing what it was doing.</p>
<p>Inside Outlook on iOS or Android, neither is true. You&rsquo;re in a WKWebView (or the Android equivalent) that Outlook controls. <code>window.open</code> goes nowhere useful, and a redirect would navigate the task pane itself into oblivion.</p>
<p>But think about what&rsquo;s <em>around</em> your WebView: the Outlook app. A fully signed-in, Microsoft-identity-aware native app that already knows exactly who the user is. NAA is the mechanism that lets your nested add-in say &ldquo;hey host, you handle the identity stuff&rdquo; and get tokens brokered through Outlook itself. When silent brokering isn&rsquo;t enough, the host hands off to Microsoft Authenticator for the interactive part, a real native auth experience instead of a popup that can&rsquo;t exist.</p>
<h2 id="the-azure-part-one-weird-redirect-uri">The Azure Part: One Weird Redirect URI</h2>
<p>Before any code, your Entra app registration needs to opt in to being brokered. That&rsquo;s done with a redirect URI of type <strong>Single-page application</strong> in this format:</p>
<pre tabindex="0"><code>brk-multihub://your-add-in-domain.com
</code></pre><p>Origin only, no subpaths:</p>
<ul>
<li>✅ <code>brk-multihub://contoso.com</code></li>
<li>✅ <code>brk-multihub://localhost:3000</code> (for dev)</li>
<li>❌ <code>brk-multihub://contoso.com/taskpane</code></li>
</ul>
<p>This URI is you telling the Microsoft identity platform: <em>&ldquo;I consent to having my auth brokered by trusted Microsoft 365 hosts.&rdquo;</em> The <code>multihub</code> part means it&rsquo;s not just Outlook, the same registration works when your add-in runs brokered inside Word, Excel, PowerPoint, or Teams.</p>
<p>One extra note if your add-in also runs in Word/Excel/PowerPoint <strong>on the web</strong>: those need an additional plain SPA redirect URI pointing at the actual page that requests tokens (your taskpane HTML), because the browser there still uses a standard flow.</p>
<h2 id="the-msal-part-one-config-two-worlds">The MSAL Part: One Config, Two Worlds</h2>
<p>Here&rsquo;s the neat thing about how NAA lands in code: you don&rsquo;t write a mobile version and a desktop version. You write one init function and let it fall back.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">import</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">createNestablePublicClientApplication</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">PublicClientApplication</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">type</span> <span style="color:#a6e22e">IPublicClientApplication</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">type</span> <span style="color:#a6e22e">Configuration</span>,
</span></span><span style="display:flex;"><span>} <span style="color:#66d9ef">from</span> <span style="color:#e6db74">&#34;@azure/msal-browser&#34;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> <span style="color:#a6e22e">isNAAMode</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">false</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">initMsal</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Promise</span>&lt;<span style="color:#f92672">IPublicClientApplication</span>&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">msalConfig</span>: <span style="color:#66d9ef">Configuration</span> <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">auth</span><span style="color:#f92672">:</span> {
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">authority</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;https://login.microsoftonline.com/common&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">clientId</span>: <span style="color:#66d9ef">clientId</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">supportsNestedAppAuth</span>: <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">app</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">createNestablePublicClientApplication</span>(<span style="color:#a6e22e">msalConfig</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">isNAAMode</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">true</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">app</span>;
</span></span><span style="display:flex;"><span>  } <span style="color:#66d9ef">catch</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// NAA unavailable - fall back to standard MSAL
</span></span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">isNAAMode</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">false</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">app</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">PublicClientApplication</span>(<span style="color:#a6e22e">msalConfig</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">app</span>.<span style="color:#a6e22e">initialize</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">app</span>;
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li><code>supportsNestedAppAuth: true</code> flags the config as NAA-capable. Without it, nothing brokers.</li>
<li><code>createNestablePublicClientApplication</code> tries to hook into the host&rsquo;s broker. Inside Outlook mobile, it succeeds. In a plain browser tab or an older host, it throws.</li>
<li>The <code>catch</code> <em>is</em> the desktop path. Same fallback philosophy as the Graph post: don&rsquo;t ask &ldquo;am I on mobile?&rdquo;, try the capability and let the failure route you. One codebase, two auth worlds, zero platform sniffing.</li>
<li>Small but important: initialize this lazily, after <code>Office.onReady()</code> has resolved. The broker hookup needs the Office context to actually be there.</li>
</ol>
<h2 id="getting-a-token-let-the-host-do-the-remembering">Getting a Token: Let the Host Do the Remembering</h2>
<p>In classic MSAL you manage accounts yourself: call <code>getAllAccounts()</code>, pick one, pass it to <code>acquireTokenSilent</code>. In NAA mode you don&rsquo;t have to, the broker owns the accounts. You just give it a hint about who you expect:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getLoginHint</span>()<span style="color:#f92672">:</span> <span style="color:#66d9ef">string</span> <span style="color:#f92672">|</span> <span style="color:#66d9ef">undefined</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">mailbox</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">userProfile</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">emailAddress</span> <span style="color:#f92672">??</span> <span style="color:#66d9ef">undefined</span>;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getToken</span>(<span style="color:#a6e22e">scopes</span>: <span style="color:#66d9ef">string</span>[])<span style="color:#f92672">:</span> <span style="color:#a6e22e">Promise</span>&lt;<span style="color:#f92672">string</span>&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">app</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">getMsalInstance</span>();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">isNAAMode</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">result</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">app</span>.<span style="color:#a6e22e">acquireTokenSilent</span>({
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">scopes</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">loginHint</span>: <span style="color:#66d9ef">getLoginHint</span>(),
</span></span><span style="display:flex;"><span>      });
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">result</span>.<span style="color:#a6e22e">accessToken</span>;
</span></span><span style="display:flex;"><span>    } <span style="color:#66d9ef">catch</span> {
</span></span><span style="display:flex;"><span>      <span style="color:#75715e">// Silent failed - interactive fallback
</span></span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// On mobile this doesn&#39;t open a browser popup -
</span></span></span><span style="display:flex;"><span>    <span style="color:#75715e">// the host hands off to the Microsoft Authenticator app
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">result</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">app</span>.<span style="color:#a6e22e">acquireTokenPopup</span>({ <span style="color:#a6e22e">scopes</span> });
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">result</span>.<span style="color:#a6e22e">accessToken</span>;
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#75715e">// Non-NAA (desktop/web): the classic dance -
</span></span></span><span style="display:flex;"><span>  <span style="color:#75715e">// getAllAccounts() → acquireTokenSilent(account) → popup on interaction_required
</span></span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">getTokenClassic</span>(<span style="color:#a6e22e">app</span>, <span style="color:#a6e22e">scopes</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li>The <code>loginHint</code> is just the signed-in mailbox owner&rsquo;s email, straight from <code>Office.context.mailbox.userProfile</code>. The broker uses it to resolve the right account, which is exactly the account the user is already signed into Outlook with. No account picker, no cache management.</li>
<li><code>acquireTokenSilent</code> with that hint succeeds almost every time, because of course it does, the user <em>is</em> signed in, that&rsquo;s how they&rsquo;re reading their email.</li>
<li>When interaction is genuinely needed (first consent, revoked session), <code>acquireTokenPopup</code> doesn&rsquo;t open a popup on mobile despite its name. The host routes it to Microsoft Authenticator, and the user gets a native sign-in experience that actually works on a phone.</li>
<li>The non-NAA branch keeps doing what MSAL apps have always done on desktop. Two branches in one function, and the caller just says <code>getToken(scopes)</code> and never knows which world it&rsquo;s in.</li>
</ol>
<p>The first time I watched the Authenticator app slide up over Outlook, approve, and slide away with my Graph call succeeding behind it, it felt like cheating. In a good way. 🎉</p>
<h2 id="gotchas-i-hit-along-the-way">Gotchas I Hit Along the Way</h2>
<ul>
<li><strong>The redirect URI must be SPA type.</strong> Adding <code>brk-multihub://...</code> under &ldquo;Web&rdquo; or &ldquo;Mobile and desktop applications&rdquo; in the app registration silently doesn&rsquo;t work. It goes under Single-page application, even though nothing about it looks like a page.</li>
<li><strong>Origin only.</strong> <code>brk-multihub://contoso.com/taskpane</code> fails validation. Trim it to the origin.</li>
<li><strong>MSAL.js v3+.</strong> <code>createNestablePublicClientApplication</code> doesn&rsquo;t exist in v2. Check your <code>@azure/msal-browser</code> version before you spend an hour on confusing import errors.</li>
<li><strong>No B2C.</strong> NAA supports Microsoft accounts and Entra ID work/school accounts. If you&rsquo;re on Azure AD B2C, this door is closed.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>That&rsquo;s the trilogy done. Three posts, one theme: on mobile you don&rsquo;t get to demand things, you get to <em>ask and adapt</em>.</p>
<ul>
<li>The <a href="https://jeppe-spanggaard.dk/blogs/outlook-addin-manifest-requirement-sets-mobile/">manifest post</a> got us in the door: declare a low requirement floor so mobile shows the add-in at all.</li>
<li>The <a href="https://jeppe-spanggaard.dk/blogs/outlook-addin-graph-fallback-mobile/">Graph fallback post</a> got us the bytes: when the Office.js API isn&rsquo;t there, fetch the same email from Graph.</li>
<li>This post got us the token: when there&rsquo;s no browser to pop, let Outlook broker the auth and Authenticator handle the interaction.</li>
</ul>
<p>Same shape every time: try the capability, catch the failure, take the other road. Mobile support isn&rsquo;t one big feature, it&rsquo;s a stack of small fallbacks that each pretend nothing happened.</p>
<h2 id="references-">References 📚</h2>
<ul>
<li><a href="https://learn.microsoft.com/en-us/office/dev/add-ins/develop/enable-nested-app-authentication-in-your-add-in">Enable SSO in an Office Add-in with nested app authentication</a></li>
<li><a href="https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/authentication/nested-authentication">Nested app authentication (Microsoft 365 platform concepts)</a></li>
<li><a href="https://learn.microsoft.com/en-us/javascript/api/requirement-sets/common/nested-app-auth-requirement-sets">Nested app auth requirement sets</a></li>
<li><a href="https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/lib/msal-browser">MSAL.js (@azure/msal-browser)</a></li>
</ul>
]]></content:encoded></item><item><title>The SharePoint CSOM Performance Playbook: Stop Paying for Wasted API Calls</title><link>https://jeppe-spanggaard.dk/blogs/sharepoint-csom-performance-playbook/</link><pubDate>Wed, 20 May 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/sharepoint-csom-performance-playbook/</guid><description>Learn how to speed up SharePoint CSOM with five proven techniques - batching, CAML joins, change detection, server-side exception handling, and fast taxonomy loading.</description><content:encoded><![CDATA[<p>I&rsquo;ve spent years writing CSOM code, and I keep seeing the same performance sins in every codebase I review. Including my own older code, which is the humbling part.</p>
<p>For a long time, slow CSOM was just annoying. Users waited, someone made coffee, life went on. Then I sat down with a client to calculate the cost of <a href="https://learn.microsoft.com/en-us/sharepoint/sharepoint-prioritization">Service Prioritization in SharePoint</a>, where every CSOM call has an actual price tag: USD $1.00 per 1,000 calls. Suddenly all those wasted calls weren&rsquo;t just slow. They were an invoice.</p>
<p>That changed how I write CSOM. I count round trips now, the way you&rsquo;d count database queries in a hot loop. Over the past year I&rsquo;ve written several posts about the specific techniques that came out of that habit, and this post ties them together into one playbook.</p>
<h2 id="tldr">TL;DR</h2>
<p>Five rules cover most CSOM performance problems. Batch up to 100 operations before each <code>ExecuteQueryAsync()</code> instead of executing one by one. Query related lists with CAML joins instead of one query per list. Compare values before updating, because 60-80% of &ldquo;save&rdquo; clicks change nothing. Move try-catch logic to the server with <code>ExceptionHandlingScope</code> so a fallback doesn&rsquo;t cost an extra round trip. And keep taxonomy fields out of your ViewFields, loading them separately via <code>FieldValuesForEdit</code>.</p>
<p>Each rule has its own detailed post with full code. This one gives you the map.</p>
<h2 id="every-executequery-is-a-road-trip">Every ExecuteQuery Is a Road Trip</h2>
<p>Here&rsquo;s the mental model that makes all four techniques click: every <code>ExecuteQueryAsync()</code> is a road trip to the SharePoint server. Doesn&rsquo;t matter if you&rsquo;re delivering one package or a hundred, the drive takes the same time. Network latency, authentication, server processing - the overhead is per trip, not per operation.</p>
<p>So the whole playbook boils down to four questions:</p>
<ol>
<li>Can I deliver more packages per trip? (batching)</li>
<li>Can one trip cover several destinations? (CAML joins)</li>
<li>Is this trip even necessary? (change detection)</li>
<li>Can I avoid a second trip when something goes wrong? (ExceptionHandlingScope, and the taxonomy trick)</li>
</ol>
<p>Let&rsquo;s take them one at a time.</p>
<h2 id="rule-1-batch-or-suffer">Rule 1: Batch or Suffer</h2>
<p>The most common sin. A loop that loads items one by one:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// ❌ 50 items = 50 round trips = 3-5 seconds</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> id <span style="color:#66d9ef">in</span> itemIds)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> item = list.GetItemById(id);
</span></span><span style="display:flex;"><span>    context.Load(item);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> context.ExecuteQueryAsync();
</span></span><span style="display:flex;"><span>    ProcessItem(item);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>CSOM happily queues up operations until you call <code>ExecuteQueryAsync()</code>. Around 100 operations per batch is the reliable sweet spot:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// ✅ 50 items = 1 round trip = ~200-500ms</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> items = chunk.Select(id =&gt; {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> item = list.GetItemById(id);
</span></span><span style="display:flex;"><span>    list.Context.Load(item);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> item;
</span></span><span style="display:flex;"><span>}).ToList();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">await</span> context.ExecuteQueryAsync();
</span></span></code></pre></div><p>In my testing, that&rsquo;s a 10x+ improvement for the price of restructuring a loop. It works for reads, writes, deletes, all of it.</p>
<p>Full post with the reusable <code>ProcessInChunks</code> helper: <a href="https://jeppe-spanggaard.dk/blogs/csom-performance-optimization-chunking/">CSOM Performance Optimization: Why You Should Batch Your SharePoint Operations</a>.</p>
<h2 id="rule-2-join-lists-dont-query-them-one-by-one">Rule 2: Join Lists, Don&rsquo;t Query Them One by One</h2>
<p>Related data across multiple lists is where round trips multiply quietly. Customers in one list, orders in another, order items in a third, so you write three queries and merge the results in C#. Every multi-item query costs <a href="https://learn.microsoft.com/en-us/sharepoint/dev/general-development/how-to-avoid-getting-throttled-or-blocked-in-sharepoint-online">2 resource units</a> toward your throttling budget, so three lists cost 6 units plus three round trips plus the merging code nobody wants to maintain.</p>
<p>CAML supports joins, even though the SharePoint UI never hints at it. One query, one trip, 2 resource units, and the server does the merging. I use <a href="https://github.com/sadomovalex/camlex">CAMLEX</a> instead of raw CAML XML because lambda expressions are readable and the XML it generates is not:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> query = Camlex.Query()
</span></span><span style="display:flex;"><span>    .Where(x =&gt; (<span style="color:#66d9ef">string</span>)x[<span style="color:#e6db74">&#34;CustomerName&#34;</span>] == <span style="color:#e6db74">&#34;Contoso&#34;</span>) <span style="color:#75715e">// filter on a field 2 lists away!</span>
</span></span><span style="display:flex;"><span>    .LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;OrderTaskLookUp&#34;</span>].ForeignList(ListGuidOrderTasks))
</span></span><span style="display:flex;"><span>    .LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;OrderDetailLookUp&#34;</span>].PrimaryList(ListGuidOrderTasks).ForeignList(ListGuidOrders))
</span></span><span style="display:flex;"><span>    .LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;CustomerLookUp&#34;</span>].PrimaryList(ListGuidOrders).ForeignList(ListGuidCustomers))
</span></span><span style="display:flex;"><span>    .ProjectedField(x =&gt; x[<span style="color:#e6db74">&#34;CustomerName&#34;</span>].List(ListGuidCustomers).ShowField(<span style="color:#e6db74">&#34;CustomerName&#34;</span>));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> camlQuery = <span style="color:#66d9ef">new</span> CamlQuery { ViewXml = query.ToString() };
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> items = ordersList.GetItems(camlQuery);
</span></span></code></pre></div><p>That&rsquo;s 66% fewer resource units than the three-query version, and you can even filter on a value several lists away. The lists must be connected via lookup columns, and ProjectedFields only supports simple field types (text, number, date - not user, choice, or managed metadata fields).</p>
<p>Full post with the raw CAML comparison and the field type list: <a href="https://jeppe-spanggaard.dk/blogs/joining-multiple-lists-csom-caml/">Efficient Multi-List Queries in CSOM: Using CAML Joins with CAMLEX</a>.</p>
<h2 id="rule-3-did-anything-actually-change">Rule 3: Did Anything Actually Change?</h2>
<p>Users are save-happy. They open a form, change nothing, and click save anyway. Automated syncs are worse. When I dug into a typical day of API logs on one project, <strong>roughly 70% of our SharePoint update calls weren&rsquo;t changing anything</strong>. SharePoint accepts identical data with a smile and bills you for the privilege.</p>
<p>The fix is change detection before the update:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> differences = GetDifferences(listItem, newValues, treatEmptyStringAsNull: <span style="color:#66d9ef">true</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (differences.Any())
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> (fieldName, newValue) <span style="color:#66d9ef">in</span> differences)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        listItem[fieldName] = newValue;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    listItem.Update();
</span></span><span style="display:flex;"><span>    clientContext.ExecuteQuery();
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Nothing changed? Do absolutely nothing.</span>
</span></span></code></pre></div><p>The hard part is the comparison itself. SharePoint field types fight you: user fields where only <code>LookupId</code> matters, multi-choice fields that come back in random order, taxonomy values, dates with kind mismatches. Naive <code>oldValue == newValue</code> doesn&rsquo;t survive contact with any of them, and JSON serialization comparison turned out 3-5x slower than a proper normalizing comparer when I benchmarked both.</p>
<p>Full post with the complete comparison engine: <a href="https://jeppe-spanggaard.dk/blogs/sharepoint-csom-prevent-unnecessary-updates/">SharePoint CSOM: Prevent Unnecessary Updates and API Calls</a>.</p>
<h2 id="rule-4-let-the-server-do-the-catching">Rule 4: Let the Server Do the Catching</h2>
<p>This one came straight out of that Service Prioritization cost analysis. The client&rsquo;s solution made about 90,000 CSOM calls per month, and <strong>88% of them were <code>EnsureUser</code> calls for users who were already on the site</strong>. The classic defensive pattern - always ensure the user before setting a user field - was two round trips per assignment, and the first one was almost always pointless.</p>
<p><code>ExceptionHandlingScope</code> lets you ship try-catch logic to the server and resolve it in one round trip:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> scope = <span style="color:#66d9ef">new</span> ExceptionHandlingScope(clientContext);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">using</span> (scope.StartScope())
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">using</span> (scope.StartTry())
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Optimistic: works for users already known to the site</span>
</span></span><span style="display:flex;"><span>        listItem[<span style="color:#e6db74">&#34;AssignedTo&#34;</span>] = FieldUserValue.FromUser(<span style="color:#e6db74">&#34;user@company.com&#34;</span>);
</span></span><span style="display:flex;"><span>        listItem.Update();
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">using</span> (scope.StartCatch())
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Fallback: only runs server-side if the try failed</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> user = clientContext.Web.EnsureUser(<span style="color:#e6db74">&#34;user@company.com&#34;</span>);
</span></span><span style="display:flex;"><span>        listItem[<span style="color:#e6db74">&#34;AssignedTo&#34;</span>] = FieldUserValue.FromUser(<span style="color:#e6db74">&#34;user@company.com&#34;</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>clientContext.ExecuteQuery(); <span style="color:#75715e">// one trip</span>
</span></span></code></pre></div><p>Result for that client: a 75-85% reduction in monthly calls. One honest caveat, and it&rsquo;s important: the scope reduces <em>round trips</em>, not <em>server requests</em>. Each operation inside still counts toward throttling. The call reduction came from the optimistic-first logic, and ExceptionHandlingScope is what made that logic affordable.</p>
<p>Full post with the cost breakdown: <a href="https://jeppe-spanggaard.dk/blogs/sharepoint-exceptionhandlingscope-csom-ensureuser/">SharePoint&rsquo;s Server-Side Try-Catch: ExceptionHandlingScope</a>.</p>
<h2 id="rule-5-keep-taxonomy-out-of-your-viewfields">Rule 5: Keep Taxonomy Out of Your ViewFields</h2>
<p>Taxonomy fields are the slowest thing you can put in a CAML query. On a list where items carried 5 to 50 terms across multiple managed metadata fields, loading a few hundred items took 90-120 seconds. Users literally walked away from their computers.</p>
<p>The fix has two parts. First, query the list <em>without</em> the taxonomy fields in ViewFields, which is where most of the cost hides. Then load the taxonomy data separately through <code>FieldValuesForEdit</code>, where SharePoint stores it as a raw string you can parse directly:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// Raw format in FieldValuesForEdit: &#34;Label1|GUID1;Label2|GUID2&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> (ListItem[] chunk <span style="color:#66d9ef">in</span> items.Cast&lt;ListItem&gt;().Chunk(<span style="color:#ae81ff">100</span>))
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> item <span style="color:#66d9ef">in</span> chunk)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        clientContext.Load(item, i =&gt; i.FieldValuesForEdit);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> clientContext.ExecuteQueryAsync();
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// parse Label|GUID pairs per item</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>That took the same load from 90-120 seconds down to 25-35 seconds, a 70-75% improvement. Fair warning: this parses an internal, undocumented format. It&rsquo;s been reliable for me, but test it in your environment and keep the traditional approach as a fallback.</p>
<p>Full post with the parser: <a href="https://jeppe-spanggaard.dk/blogs/csom-taxonomy-performance-optimization/">CSOM Performance: Fast Taxonomy Loading for SharePoint List Items with Many Terms</a>.</p>
<h2 id="which-one-first">Which One First?</h2>
<p>If your workload is write-heavy (forms, syncs, integrations), start with change detection. It&rsquo;s the only technique that eliminates entire operations instead of making them cheaper, and it&rsquo;s invisible to users.</p>
<p>If your workload is read-heavy, start with batching. It&rsquo;s the smallest code change for the biggest win, and the chunking helper is reusable everywhere. And if those reads span related lists, add joins next: they cut resource units, not just latency, which stretches your throttling budget further.</p>
<p>ExceptionHandlingScope is for when a specific fallback pattern (like <code>EnsureUser</code>) dominates your call logs. Check your logs first - I only found the 88% figure because I went looking. And the taxonomy trick is a targeted weapon: only reach for it when managed metadata is measurably your bottleneck, because it carries the undocumented-format risk.</p>
<p>The good news is they stack. Batched updates that skip unchanged items, with server-side fallbacks, is exactly how my current projects run.</p>
<h2 id="gotchas">Gotchas</h2>
<ul>
<li><strong>100 operations per batch is the sweet spot.</strong> CSOM reliably handles around that many. Bigger batches mean bigger payloads and less predictable behavior across environments. Ask me how I know.</li>
<li><strong>ExceptionHandlingScope doesn&rsquo;t reduce throttling pressure by itself.</strong> Round trips go down, but every operation inside the scope still counts as a request. The savings come from the optimistic-first logic it enables.</li>
<li><strong>Your numbers will differ from mine.</strong> The 70%, 75-85%, and 70-75% figures are from real projects, but latency, item complexity, and tenant load all move them. Measure before and after in your own environment.</li>
<li><strong>Test under throttling before you trust any of it.</strong> <a href="https://jeppe-spanggaard.dk/blogs/devproxy-throttling-testing/">Dev Proxy</a> can simulate 429 responses locally so you find out how your batches behave under pressure before production does.</li>
<li><strong>The taxonomy trick gives you label and GUID, nothing more.</strong> If you need full term paths or custom properties, you&rsquo;re back to the taxonomy API for those items.</li>
<li><strong>Joins need lookup columns and GUIDs.</strong> CAML joins only work across lists connected by lookup columns, and referencing lists by GUID instead of title saves you from &ldquo;list does not exist&rdquo; surprises.</li>
</ul>
<p>If you&rsquo;re also downloading files in the same solution, the round-trip mindset applies there too - I&rsquo;ve covered that in <a href="https://jeppe-spanggaard.dk/blogs/download-multiple-files-from-sharepoint/">downloading multiple files from SharePoint</a>.</p>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>Count your round trips before SharePoint counts them for you. That&rsquo;s the whole playbook in one sentence: fewer trips (batching), combined trips (CAML joins), no pointless trips (change detection), no second trips (ExceptionHandlingScope), and lighter trips (taxonomy loading).</p>
<p>Each technique stands on its own, so grab the one that matches your bottleneck:</p>
<ul>
<li><a href="https://jeppe-spanggaard.dk/blogs/csom-performance-optimization-chunking/">Batch your SharePoint operations</a></li>
<li><a href="https://jeppe-spanggaard.dk/blogs/joining-multiple-lists-csom-caml/">Join multiple lists in one CAML query</a></li>
<li><a href="https://jeppe-spanggaard.dk/blogs/sharepoint-csom-prevent-unnecessary-updates/">Prevent unnecessary updates</a></li>
<li><a href="https://jeppe-spanggaard.dk/blogs/sharepoint-exceptionhandlingscope-csom-ensureuser/">Server-side try-catch with ExceptionHandlingScope</a></li>
<li><a href="https://jeppe-spanggaard.dk/blogs/csom-taxonomy-performance-optimization/">Fast taxonomy loading</a></li>
</ul>
<h2 id="references-">References 📚</h2>
<ul>
<li><a href="https://learn.microsoft.com/en-us/sharepoint/dev/sp-add-ins/complete-basic-operations-using-sharepoint-client-library-code">Complete basic operations using SharePoint client library code</a></li>
<li><a href="https://learn.microsoft.com/en-us/sharepoint/dev/general-development/how-to-avoid-getting-throttled-or-blocked-in-sharepoint-online">Avoid getting throttled or blocked in SharePoint Online</a></li>
<li><a href="https://learn.microsoft.com/en-us/sharepoint/sharepoint-prioritization">Service Prioritization in SharePoint</a></li>
<li><a href="https://github.com/sadomovalex/camlex">CAMLEX on GitHub</a></li>
</ul>
]]></content:encoded></item><item><title>Why My Outlook Add-in Lies About Its Version (And Yours Should Too)</title><link>https://jeppe-spanggaard.dk/blogs/outlook-addin-manifest-requirement-sets-mobile/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/outlook-addin-manifest-requirement-sets-mobile/</guid><description>Learn why declaring a low Mailbox requirement set in your Outlook add-in manifest is the key to mobile support, and how to feature-detect newer APIs at runtime.</description><content:encoded><![CDATA[<p>I recently built an Outlook add-in. It archives emails and attachments to SharePoint, lets you attach cloud files while composing, the works. It ran great on desktop, great in Outlook on the web. Then I opened Outlook on my phone to test it there.</p>
<p>The add-in was gone.</p>
<p>Not broken. Not throwing errors. Just&hellip; not there. No button, no task pane, nothing. Outlook mobile acted like my add-in didn&rsquo;t exist.</p>
<h2 id="tldr">TL;DR</h2>
<p>Your manifest&rsquo;s <code>MinVersion</code> is a <strong>filter</strong>, not documentation. If you declare the requirement set your <em>best</em> feature needs, every host that doesn&rsquo;t support that version will hide your add-in completely. Declare the lowest version your <em>minimum viable experience</em> needs instead (for my add-in that&rsquo;s Mailbox 1.5), then use <code>Office.context.requirements.isSetSupported()</code> in your code to unlock the newer stuff where it&rsquo;s actually available.</p>
<h2 id="the-mistake-that-feels-like-the-right-thing">The Mistake That Feels Like the Right Thing</h2>
<p>Here&rsquo;s the trap. My add-in uses <code>getAsFileAsync()</code> to grab the full email as a file before uploading it to SharePoint. That API lives in the <strong>Mailbox 1.14</strong> requirement set. So the obvious, honest, &ldquo;do the right thing&rdquo; move is to declare that in the manifest:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span><span style="color:#f92672">&lt;Requirements&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;Sets&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;Set</span> <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;Mailbox&#34;</span> <span style="color:#a6e22e">MinVersion=</span><span style="color:#e6db74">&#34;1.14&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;/Sets&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/Requirements&gt;</span>
</span></span></code></pre></div><p>Makes sense, right? The manifest should declare what the code needs. That&rsquo;s what a manifest is <em>for</em>.</p>
<p>Wrong.</p>
<p>Outlook reads that <code>MinVersion</code> and treats it as a door policy: <em>&ldquo;You need Mailbox 1.14 to see this add-in at all.&rdquo;</em> Outlook on iOS and Android supports Mailbox 1.5 (plus a handful of newer APIs cherry-picked on top). 1.5 is less than 1.14, so mobile doesn&rsquo;t fail gracefully, it doesn&rsquo;t show a warning, it just never lets your add-in through the door. That&rsquo;s why my add-in vanished on my phone.</p>
<p>The manifest version isn&rsquo;t describing your code. It&rsquo;s deciding who gets to see your add-in.</p>
<h2 id="the-fix-degrade-the-manifest-upgrade-at-runtime">The Fix: Degrade the Manifest, Upgrade at Runtime</h2>
<p>The fix felt dirty the first time I did it: I &ldquo;degraded&rdquo; the manifest down to Mailbox 1.5, even though my code is full of 1.13 and 1.14 APIs.</p>
<p>This is what my add-in actually ships with:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span><span style="color:#f92672">&lt;Requirements&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;Sets&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;Set</span> <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;Mailbox&#34;</span> <span style="color:#a6e22e">MinVersion=</span><span style="color:#e6db74">&#34;1.5&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;/Sets&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/Requirements&gt;</span>
</span></span></code></pre></div><p>And the same low floor inside <code>VersionOverrides</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span><span style="color:#f92672">&lt;VersionOverrides</span> <span style="color:#a6e22e">xmlns=</span><span style="color:#e6db74">&#34;http://schemas.microsoft.com/office/mailappversionoverrides/1.1&#34;</span> <span style="color:#a6e22e">xsi:type=</span><span style="color:#e6db74">&#34;VersionOverridesV1_1&#34;</span><span style="color:#f92672">&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;Requirements&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;bt:Sets</span> <span style="color:#a6e22e">DefaultMinVersion=</span><span style="color:#e6db74">&#34;1.5&#34;</span><span style="color:#f92672">&gt;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&lt;bt:Set</span> <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;Mailbox&#34;</span><span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;/bt:Sets&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;/Requirements&gt;</span>
</span></span><span style="display:flex;"><span>  ...
</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/VersionOverrides&gt;</span>
</span></span></code></pre></div><p>Why 1.5? Because that&rsquo;s what the <em>minimum viable version</em> of my add-in needs: open a task pane, read the current message. Everything above that is a bonus feature, and bonus features shouldn&rsquo;t decide whether the add-in exists.</p>
<p>One more thing mobile needs, and this one is easy to miss: mobile only renders what you explicitly declare in a <code>MobileFormFactor</code>. Desktop extension points don&rsquo;t carry over.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span><span style="color:#f92672">&lt;MobileFormFactor&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;ExtensionPoint</span> <span style="color:#a6e22e">xsi:type=</span><span style="color:#e6db74">&#34;MobileMessageReadCommandSurface&#34;</span><span style="color:#f92672">&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;Group</span> <span style="color:#a6e22e">id=</span><span style="color:#e6db74">&#34;mobileReadGroup&#34;</span><span style="color:#f92672">&gt;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&lt;Label</span> <span style="color:#a6e22e">resid=</span><span style="color:#e6db74">&#34;groupLabel&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&lt;Control</span> <span style="color:#a6e22e">xsi:type=</span><span style="color:#e6db74">&#34;MobileButton&#34;</span> <span style="color:#a6e22e">id=</span><span style="color:#e6db74">&#34;mobileReadOpenPaneButton&#34;</span><span style="color:#f92672">&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;Label</span> <span style="color:#a6e22e">resid=</span><span style="color:#e6db74">&#34;taskPaneButtonLabel&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;Icon</span> <span style="color:#a6e22e">xsi:type=</span><span style="color:#e6db74">&#34;bt:MobileIconList&#34;</span><span style="color:#f92672">&gt;</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">&lt;bt:Image</span> <span style="color:#a6e22e">size=</span><span style="color:#e6db74">&#34;25&#34;</span> <span style="color:#a6e22e">scale=</span><span style="color:#e6db74">&#34;1&#34;</span> <span style="color:#a6e22e">resid=</span><span style="color:#e6db74">&#34;icon25&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>          <span style="color:#75715e">&lt;!-- ...more sizes and scales... --&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;/Icon&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;Action</span> <span style="color:#a6e22e">xsi:type=</span><span style="color:#e6db74">&#34;ShowTaskpane&#34;</span><span style="color:#f92672">&gt;</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">&lt;SourceLocation</span> <span style="color:#a6e22e">resid=</span><span style="color:#e6db74">&#34;messageReadTaskPaneUrl&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;/Action&gt;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&lt;/Control&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;/Group&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;/ExtensionPoint&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/MobileFormFactor&gt;</span>
</span></span></code></pre></div><p>Low floor + <code>MobileFormFactor</code>, and suddenly the add-in shows up on my phone. 🎉</p>
<h2 id="now-your-code-has-to-keep-the-promise">Now Your Code Has to Keep the Promise</h2>
<p>Here&rsquo;s the deal you just made: the manifest promises Outlook almost nothing, so your code can&rsquo;t <em>assume</em> anything. Every API newer than your floor needs a runtime check before you touch it.</p>
<p>Office.js has exactly the tool for this: <code>isSetSupported()</code>. I keep all of mine in one file, <code>platformDetection.ts</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">isMobilePlatform</span>()<span style="color:#f92672">:</span> <span style="color:#66d9ef">boolean</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">typeof</span> <span style="color:#a6e22e">Office</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;undefined&#39;</span> <span style="color:#f92672">||</span> <span style="color:#f92672">!</span><span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">diagnostics</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">platform</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">false</span>;
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">hostInfo</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">diagnostics</span>.<span style="color:#a6e22e">platform</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">hostInfo</span> <span style="color:#f92672">===</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">PlatformType</span>.<span style="color:#a6e22e">iOS</span> <span style="color:#f92672">||</span>
</span></span><span style="display:flex;"><span>         <span style="color:#a6e22e">hostInfo</span> <span style="color:#f92672">===</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">PlatformType</span>.<span style="color:#a6e22e">Android</span> <span style="color:#f92672">||</span>
</span></span><span style="display:flex;"><span>         <span style="color:#a6e22e">hostInfo</span> <span style="color:#f92672">===</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">PlatformType</span>.<span style="color:#a6e22e">Universal</span>; <span style="color:#75715e">// Includes mobile web
</span></span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">supportsArchive</span>()<span style="color:#f92672">:</span> <span style="color:#66d9ef">boolean</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">typeof</span> <span style="color:#a6e22e">Office</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;undefined&#39;</span> <span style="color:#f92672">||</span> <span style="color:#f92672">!</span><span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">requirements</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">false</span>;
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#75715e">// getAsFileAsync lives in Mailbox 1.14
</span></span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">requirements</span>.<span style="color:#a6e22e">isSetSupported</span>(<span style="color:#e6db74">&#34;Mailbox&#34;</span>, <span style="color:#e6db74">&#34;1.14&#34;</span>);
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">supportsMultiSelect</span>()<span style="color:#f92672">:</span> <span style="color:#66d9ef">boolean</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">typeof</span> <span style="color:#a6e22e">Office</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;undefined&#39;</span> <span style="color:#f92672">||</span> <span style="color:#f92672">!</span><span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">requirements</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">false</span>;
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">Office</span>.<span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">requirements</span>.<span style="color:#a6e22e">isSetSupported</span>(<span style="color:#e6db74">&#34;Mailbox&#34;</span>, <span style="color:#e6db74">&#34;1.13&#34;</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong></p>
<ol>
<li><code>isMobilePlatform()</code> asks the host directly what platform it&rsquo;s running on via <code>Office.context.diagnostics.platform</code>. Note that <code>Universal</code> covers mobile web too, that one surprised me.</li>
<li><code>supportsArchive()</code> checks for Mailbox 1.14 before the code anywhere near <code>getAsFileAsync()</code> runs. On mobile this returns <code>false</code>, and the UI adapts instead of crashing.</li>
<li><code>supportsMultiSelect()</code> does the same for multi-select (Mailbox 1.13). Desktop users get &ldquo;archive 20 emails at once&rdquo;, mobile users get one at a time, and nobody gets an error.</li>
<li>Every function starts with a <code>typeof Office === 'undefined'</code> guard, so the same code doesn&rsquo;t explode if it runs outside Outlook (looking at you, local dev in a plain browser tab).</li>
</ol>
<p>Then the components just ask:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">supportsArchive</span>()) {
</span></span><span style="display:flex;"><span>  <span style="color:#75715e">// Full experience: grab the email with getAsFileAsync and upload it
</span></span></span><span style="display:flex;"><span>} <span style="color:#66d9ef">else</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#75715e">// Degraded experience: hide the feature, or take another route
</span></span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The pattern scales nicely too. In my add-in, when <code>getAsFileAsync</code> isn&rsquo;t available on mobile, I don&rsquo;t hide the archive button, I fall back to fetching the message through Microsoft Graph instead. I wrote a follow-up post about exactly that: <a href="https://jeppe-spanggaard.dk/blogs/outlook-addin-graph-fallback-mobile/">Same Email, Different Source: Falling Back to Microsoft Graph on Outlook Mobile</a>.</p>
<h2 id="the-contract-write-this-on-a-sticky-note">The Contract (Write This on a Sticky Note)</h2>
<blockquote>
<p><strong>Manifest floor = the lowest host you still want to appear in.</strong>
<strong>Requirement sets in code = runtime capability checks, not install-time gates.</strong></p>
</blockquote>
<p>The manifest decides <em>where you exist</em>. The code decides <em>what you can do once you&rsquo;re there</em>. Mixing those two up is how add-ins silently disappear from half your users&rsquo; devices.</p>
<h2 id="gotchas-i-hit-along-the-way">Gotchas I Hit Along the Way</h2>
<ul>
<li><strong>Your manifest can quietly use newer sets than it declares.</strong> My manifest declares 1.5 but wires up an <code>OnMessageCompose</code> launch event, which needs Mailbox 1.12. That&rsquo;s fine, hosts simply ignore extension points they don&rsquo;t understand. But it means the manifest validator won&rsquo;t save you here; <em>you</em> have to know which parts light up where.</li>
<li><strong>Mobile only gets what <code>MobileFormFactor</code> explicitly exposes.</strong> No <code>MobileFormFactor</code>, no mobile button, even with a perfect 1.5 floor. And as of writing, mobile read mode is basically the only surface you get.</li>
<li><strong>Test on an actual phone.</strong> The desktop &ldquo;mobile preview&rdquo; and your imagination will both lie to you. Sideload it, open a real email on a real device, and watch what happens.</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>Declaring <code>MinVersion=&quot;1.14&quot;</code> because your code uses a 1.14 API feels correct, and it&rsquo;s exactly how you make your add-in invisible on mobile. Degrade the manifest to the lowest version your core experience needs, add a <code>MobileFormFactor</code>, and let <code>isSetSupported()</code> sort out the rest at runtime.</p>
<p>It felt like lying to the manifest at first. It&rsquo;s not, it&rsquo;s just answering the question the manifest is actually asking: <em>&ldquo;What&rsquo;s the least you need to be useful?&rdquo;</em></p>
<h2 id="references-">References 📚</h2>
<ul>
<li><a href="https://learn.microsoft.com/en-us/office/dev/add-ins/outlook/apis">Outlook add-in APIs and requirement sets</a></li>
<li><a href="https://learn.microsoft.com/en-us/javascript/api/requirement-sets/outlook/outlook-api-requirement-sets">Requirement sets supported by Exchange servers and Outlook clients</a></li>
<li><a href="https://learn.microsoft.com/en-us/javascript/api/office/office.requirementsetsupport">Office.context.requirements.isSetSupported()</a></li>
<li><a href="https://learn.microsoft.com/en-us/office/dev/add-ins/outlook/add-mobile-support">Add mobile support to an Outlook add-in</a></li>
<li><a href="https://learn.microsoft.com/en-us/office/dev/add-ins/outlook/outlook-add-ins-overview">Outlook add-ins overview</a></li>
</ul>
]]></content:encoded></item><item><title>One Big PnP Template or Many Small Ones?</title><link>https://jeppe-spanggaard.dk/blogs/pnp-template-sizing-resilience/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/pnp-template-sizing-resilience/</guid><description>After testing with DevProxy, I found that monolithic PnP templates silently destroy your retry strategy. Here's what I learned.</description><content:encoded><![CDATA[<p>Every time I sit down to build a SharePoint provisioning engine, I hit the same dilemma: do I put everything into one big PnP template, or do I split it into many smaller ones — one per feature?</p>
<p>Both approaches work. Both have real trade-offs. And for a long time I did not have a strong opinion either way. Then I started testing with <a href="https://learn.microsoft.com/en-us/microsoft-cloud/dev/dev-proxy/overview">Dev Proxy</a> and suddenly the answer became very clear to me.</p>
<h2 id="tldr">TL;DR</h2>
<ul>
<li>PnP Framework already handles 429 throttling internally — converting to <code>PnPClientContext</code> is a simple way to raise the retry count above the default of 10.</li>
<li>When throttling is so sustained that PnP exhausts all its retries, a <strong>monolithic</strong> template restarts from scratch — wasting all progress made before the failure.</li>
<li><strong>Modular</strong> templates (one per feature) mean that when PnP finally gives up on one, your outer retry skips everything that already succeeded and only re-runs what failed.</li>
<li>I would <strong>never</strong> have discovered this distinction without simulating throttling with DevProxy.</li>
</ul>
<h2 id="what-is-a-pnp-provisioning-template">What Is a PnP Provisioning Template?</h2>
<p>If you have not worked with PnP provisioning before: a PnP Provisioning Template is an XML (or JSON) file that describes the structure of a SharePoint site. It can contain:</p>
<ul>
<li><strong>Lists and libraries</strong> — custom columns, views, and settings</li>
<li><strong>Content types</strong> — site or list content types with their fields</li>
<li><strong>Pages and web parts</strong> — modern pages, hero sections, text blocks</li>
<li><strong>Navigation and branding</strong> — site navigation structure, themes, logos</li>
</ul>
<p>You apply a template to a site using the <a href="https://github.com/pnp/pnpframework">PnP Framework</a> library for .NET, which reads the file and provisions everything described in it. The library handles the order of operations, dependencies between objects, and a lot of edge cases you would rather not think about.</p>
<p>The typical entry point looks like this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span>web.ApplyProvisioningTemplate(template, applyInfo);
</span></span></code></pre></div><p>One call. One template. PnP handles the rest — until throttling enters the picture.</p>
<h2 id="the-monolith-approach">The Monolith Approach</h2>
<p>The simplest way to structure your provisioning is one template that contains everything. All lists, all content types, all pages, all navigation — in a single file.</p>
<p><strong>Pros:</strong></p>
<ul>
<li>✅ Simple to manage — one file, one version, one deployment artifact</li>
<li>✅ Easy to apply — a single <code>ApplyProvisioningTemplate</code> call</li>
<li>✅ PnP handles dependencies between objects internally</li>
</ul>
<p><strong>Cons:</strong></p>
<ul>
<li>❌ All-or-nothing retry — if anything fails, you restart from the beginning</li>
<li>❌ Long templates take a long time to apply, which means long retries too</li>
<li>❌ Hard to reason about what failed and where</li>
</ul>
<p>The all-or-nothing behavior is the killer. SharePoint throttles aggressively under load, and <code>ApplyProvisioningTemplate</code> does not pick up where it left off. Under a sustained throttle — the kind where even 15 retries at the CSOM call level are not enough to get through — the template application eventually throws a <code>MaximumRetryAttemptedException</code> — a PnP-specific exception signalling that all retry attempts were used up. At that point your outer retry logic has no choice but to start the whole template over from the very first list.</p>
<h2 id="the-modular-approach">The Modular Approach</h2>
<p>The alternative is splitting your template into multiple smaller files, each representing a logical feature or concern. For example:</p>
<ul>
<li><code>01-lists-and-libraries.xml</code></li>
<li><code>02-content-types.xml</code></li>
<li><code>03-pages.xml</code></li>
<li><code>04-navigation.xml</code></li>
</ul>
<p>You apply them in sequence, and after each one completes successfully you record that fact in memory. If a template fails, you retry only from the failed template — not from the beginning.</p>
<p><strong>Pros:</strong></p>
<ul>
<li>✅ Granular retry — only the failed feature is re-applied</li>
<li>✅ Faster recovery from throttling</li>
<li>✅ Easier to reason about failures (&ldquo;pages failed, lists are fine&rdquo;)</li>
<li>✅ Easier to test individual features in isolation</li>
</ul>
<p><strong>Cons:</strong></p>
<ul>
<li>❌ More files to manage and version</li>
<li>❌ Ordering matters — you need to think about dependencies</li>
<li>❌ Slightly more orchestration code on your end</li>
</ul>
<p>The trade-off is real. Modular means more moving parts. But for resilience, the payoff is significant.</p>
<h2 id="testing-with-devproxy--where-it-got-interesting">Testing With DevProxy — Where It Got Interesting</h2>
<p>I wanted to put both approaches through their paces under realistic throttling conditions. For that I used Dev Proxy with two plugins:</p>
<ul>
<li><strong>GenericRandomErrorPlugin</strong> — injects random 429 responses to simulate SharePoint throttling unpredictably</li>
<li><strong>RateLimitingPlugin</strong> — caps requests per second to trigger organic throttling at volume</li>
</ul>
<p>With these configured, I ran my provisioning engine against both template structures.</p>
<p>The result with the <strong>monolithic</strong> template: <code>PnPClientContext</code> did its job — it absorbed a lot of 429s silently and kept going. But under sustained throttling, the retries ran out, PnP threw a <code>MaximumRetryAttemptedException</code>, and my outer retry had to restart it from scratch. From list number one. Even though we were deep into page provisioning when it failed.</p>
<p>The result with <strong>modular</strong> templates: the same CSOM-level retry absorbed the same throttling. But when a sustained burst finally exhausted PnP&rsquo;s retries on the pages template, my retry loop skipped straight to <code>03-pages.xml</code> — lists and content types had already been marked done in memory and were left alone.</p>
<blockquote>
<p>This is exactly the kind of thing that looks fine in a happy-path test, but silently destroys your provisioning SLA in production.</p>
</blockquote>
<h2 id="pnpclientcontext-bump-the-retry-count-for-provisioning">PnPClientContext: Bump the Retry Count for Provisioning</h2>
<p>The PnP Framework&rsquo;s internal CSOM calls already use <code>ExecuteQueryRetry()</code> — an extension method that handles 429 throttling with exponential backoff, respecting the <code>Retry-After</code> header SharePoint returns. You get this for free with a plain <code>ClientContext</code>. The default is 10 retries.</p>
<p>If you expect heavier throttling during provisioning — and a large site with lots of lists, content types, and pages is a reasonable place to expect it — you can easily raise that limit by converting to a <code>PnPClientContext</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">using</span> var siteCtx = CSOMClientFactory.Create(provCtx.NewSiteUrl, credential);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> pnpCtx = PnPClientContext.ConvertFrom(siteCtx, retryCount: <span style="color:#ae81ff">15</span>);
</span></span></code></pre></div><p>That is the entire change. The provisioning code picks up the higher retry count automatically. Your outer retry logic only comes into play if throttling is so sustained that even 15 attempts on a single CSOM call are not enough — at which point PnP throws a <code>MaximumRetryAttemptedException</code>.</p>
<h2 id="idempotency-strategy">Idempotency Strategy</h2>
<p>The key property that makes modular templates safe to retry is idempotency — applying a template that has already been applied should not cause errors or duplicate data. PnP handles most of this for you: before creating a list, content type, or page, it checks whether it already exists.</p>
<p>A very simple example of how this looks in practice:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> source <span style="color:#66d9ef">in</span> config.Templates)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> template = LoadTemplate(source);
</span></span><span style="display:flex;"><span>    pnpCtx.Web.ApplyProvisioningTemplate(template, applyInfo);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>If this loop is re-invoked after a failure (for example by an Azure Function retry policy), templates that already completed will be re-applied — but because each template is idempotent, that is safe. The already-provisioned lists and pages are left untouched. The only cost is the time it takes to re-apply them.</p>
<p>That wasted time is the whole point. With a monolithic template, a retry means re-applying everything from scratch. With modular templates, it means re-applying a small number of already-done features before reaching the one that actually failed.</p>
<h2 id="comparison">Comparison</h2>
<table>
  <thead>
      <tr>
          <th></th>
          <th>One Big Template</th>
          <th>Many Small Templates</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Retry granularity</strong></td>
          <td>Entire template restarts</td>
          <td>Only failed feature retries</td>
      </tr>
      <tr>
          <td><strong>Restart cost</strong></td>
          <td>High — full re-apply</td>
          <td>Low — one feature re-applies</td>
      </tr>
      <tr>
          <td><strong>Complexity</strong></td>
          <td>Low — one file, one call</td>
          <td>Medium — ordering, orchestration</td>
      </tr>
      <tr>
          <td><strong>Idempotency needed</strong></td>
          <td>Once per template</td>
          <td>Once per template (still needed)</td>
      </tr>
      <tr>
          <td><strong>DevProxy testability</strong></td>
          <td>Easy to test the whole flow</td>
          <td>Easier to isolate a specific feature</td>
      </tr>
      <tr>
          <td><strong>Best for</strong></td>
          <td>Simple sites, low throttle risk</td>
          <td>Complex sites, production resilience</td>
      </tr>
  </tbody>
</table>
<h2 id="verdict">Verdict</h2>
<p>Neither approach is universally correct. If you are provisioning simple sites with low template complexity and you are not worried about throttling, a monolithic template is perfectly fine and a lot less work to maintain.</p>
<p>But if you are building a production provisioning engine that needs to be resilient — where throttling is a real risk, where retries need to be efficient, and where you care about how long a failure takes to recover from — modular templates win clearly.</p>
<p>The combination that works best for me is: <strong>modular templates</strong> + <strong>PnPClientContext</strong> with conservative retry settings. PnPClientContext handles the low-level CSOM throttling so most 429s never surface at all. When they do surface (and eventually they will), modular templates ensure your retry loop does the minimum amount of work to get back on track.</p>
<h2 id="conclusion">Conclusion</h2>
<p>The biggest takeaway for me is not even the monolith-vs-modular debate. It is that <strong>I never would have found this out without DevProxy</strong>. Happy-path testing showed no difference between the two approaches. It was only when I introduced realistic throttling that the behavior diverged in a way that actually matters.</p>
<p>If you are building any kind of SharePoint automation that makes CSOM or Graph calls under load, DevProxy should be a standard part of your testing setup. It is free, it integrates with your local development environment, and it reveals exactly the kind of subtle failure modes that only appear in production.</p>
<hr>
<h3 id="references">References</h3>
<ul>
<li><a href="https://github.com/pnp/pnpframework">PnP Framework on GitHub</a></li>
<li><a href="https://learn.microsoft.com/en-us/microsoft-cloud/dev/dev-proxy/overview">Dev Proxy overview</a></li>
<li><a href="https://learn.microsoft.com/en-us/sharepoint/dev/general-development/how-to-avoid-getting-throttled-or-blocked-in-sharepoint-online">SharePoint throttling and retry guidance</a></li>
<li><a href="https://pnp.github.io/pnpframework/">PnPClientContext — ExecuteQueryRetry docs</a></li>
</ul>
]]></content:encoded></item><item><title>SharePoint News Links via Graph SDK: Filling in the Gaps the Docs Left Behind</title><link>https://jeppe-spanggaard.dk/blogs/graph-beta-sdk-news-link-with-banner-image/</link><pubDate>Tue, 03 Mar 2026 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/graph-beta-sdk-news-link-with-banner-image/</guid><description>Learn how to create SharePoint News Link pages with a banner image using the Microsoft Graph Beta SDK in C# — including the multipart upload the official documentation leaves completely undocumented.</description><content:encoded><![CDATA[<h2 id="tldr">TL;DR</h2>
<p>News Link pages are beta-only, so you need the <code>Microsoft.Graph.Beta</code> NuGet package. For banner image uploads, you also need <code>Microsoft.Kiota.Serialization.Multipart</code>. The tricky part is that the multipart case has no official C# snippet — Microsoft&rsquo;s documentation just says &ldquo;Snippet not available.&rdquo;</p>
<p>Here&rsquo;s the short version:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> page = <span style="color:#66d9ef">new</span> NewsLinkPage
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    OdataType = <span style="color:#e6db74">&#34;#microsoft.graph.newsLinkPage&#34;</span>,
</span></span><span style="display:flex;"><span>    Title = title,
</span></span><span style="display:flex;"><span>    NewsWebUrl = url,
</span></span><span style="display:flex;"><span>    AdditionalData = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">object</span>&gt;
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">        [&#34;@microsoft.graph.bannerImageWebUrlContent&#34;]</span> = <span style="color:#e6db74">&#34;name:content&#34;</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> multipartBody = <span style="color:#66d9ef">new</span> MultipartBody();
</span></span><span style="display:flex;"><span>multipartBody.AddOrReplacePart(<span style="color:#e6db74">&#34;request&#34;</span>, <span style="color:#e6db74">&#34;application/json&#34;</span>, page);
</span></span><span style="display:flex;"><span>multipartBody.AddOrReplacePart(<span style="color:#e6db74">&#34;content&#34;</span>, <span style="color:#e6db74">&#34;image/jpeg&#34;</span>, bannerImageContent, fileName: <span style="color:#e6db74">&#34;banner.jpg&#34;</span>);
</span></span></code></pre></div><p>Want the full working implementation? Read on. 👇</p>
<h2 id="the-problem-with-the-documentation">The Problem With the Documentation</h2>
<p>News Link pages in SharePoint — those cards that link to external news articles — are only available through the Graph API&rsquo;s beta endpoint. That&rsquo;s fine, beta APIs are part of life.</p>
<p>What&rsquo;s <em>not</em> fine is that Microsoft&rsquo;s <a href="https://learn.microsoft.com/en-us/graph/api/newslinkpage-create?view=graph-rest-beta">official documentation</a> covers the simple case well, but the moment you want to add a banner image, the C# code snippet disappears and is replaced with:</p>
<p><strong>&ldquo;Snippet not available.&rdquo;</strong></p>
<p>Great. Thanks.</p>
<p>The banner image upload requires a multipart request. Figuring out how to do that with the Graph Beta SDK means piecing together documentation about <code>MultipartBody</code>, some Kiota internals, and a few quirks you&rsquo;ll only discover by actually trying it.</p>
<p>This post covers the full working solution.</p>
<h2 id="prerequisites">Prerequisites</h2>
<p>Install these two NuGet packages:</p>
<pre tabindex="0"><code>Microsoft.Graph.Beta
Microsoft.Kiota.Serialization.Multipart
</code></pre><p><code>Microsoft.Graph.Beta</code> is the Beta SDK — it contains <code>NewsLinkPage</code> and all the beta models. <code>Microsoft.Kiota.Serialization.Multipart</code> provides the <code>MultipartBody</code> class needed to construct multipart requests. Without it, you don&rsquo;t have the types to send binary data alongside the JSON payload.</p>
<h2 id="the-simple-case-no-banner-image">The Simple Case: No Banner Image</h2>
<p>If you just want a News Link without a banner image, the Beta SDK fluent API handles it cleanly:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> page = <span style="color:#66d9ef">new</span> NewsLinkPage
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    OdataType = <span style="color:#e6db74">&#34;#microsoft.graph.newsLinkPage&#34;</span>,
</span></span><span style="display:flex;"><span>    Title = <span style="color:#e6db74">&#34;Contoso Unveils First Self-Driving Car&#34;</span>,
</span></span><span style="display:flex;"><span>    NewsWebUrl = <span style="color:#e6db74">&#34;https://someexternalnewssite.com/article&#34;</span>,
</span></span><span style="display:flex;"><span>    Description = <span style="color:#e6db74">&#34;A brief description of the article.&#34;</span>
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> result = <span style="color:#66d9ef">await</span> GraphClient.Sites[siteId].Pages.PostAsync(page, requestConfiguration =&gt;
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    requestConfiguration.Headers.Add(<span style="color:#e6db74">&#34;prefer&#34;</span>, <span style="color:#e6db74">&#34;include-unknown-enum-members&#34;</span>);
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><p>Two things to note:</p>
<p>The <code>Prefer: include-unknown-enum-members</code> header is required. Without it, the API won&rsquo;t return <code>newsLink</code> as a valid <code>pageLayoutType</code> value — it&rsquo;s an evolvable enum that hasn&rsquo;t been promoted to v1.0 yet, so Graph treats it as unknown by default.</p>
<p>The page is also created as a draft. You still need to publish it separately before it shows up in the news feed.</p>
<h2 id="the-full-solution-with-banner-image">The Full Solution: With Banner Image</h2>
<p>Here&rsquo;s the complete implementation including banner image upload and publishing:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">internal</span> <span style="color:#66d9ef">async</span> Task CreateNewsLink(
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> siteId,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string?</span> title,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string?</span> url,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string?</span> description,
</span></span><span style="display:flex;"><span>    Stream? bannerImageContent)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> page = <span style="color:#66d9ef">new</span> NewsLinkPage
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        OdataType = <span style="color:#e6db74">&#34;#microsoft.graph.newsLinkPage&#34;</span>,
</span></span><span style="display:flex;"><span>        Title = title,
</span></span><span style="display:flex;"><span>        NewsWebUrl = url,
</span></span><span style="display:flex;"><span>        Description = description,
</span></span><span style="display:flex;"><span>        AdditionalData = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">object</span>&gt;
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">            [&#34;@microsoft.graph.bannerImageWebUrlContent&#34;]</span> = <span style="color:#e6db74">&#34;name:content&#34;</span>
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> multipartBody = <span style="color:#66d9ef">new</span> MultipartBody();
</span></span><span style="display:flex;"><span>    multipartBody.AddOrReplacePart(<span style="color:#e6db74">&#34;request&#34;</span>, <span style="color:#e6db74">&#34;application/json&#34;</span>, page);
</span></span><span style="display:flex;"><span>    multipartBody.AddOrReplacePart(<span style="color:#e6db74">&#34;content&#34;</span>, <span style="color:#e6db74">&#34;image/jpeg&#34;</span>, bannerImageContent, fileName: <span style="color:#e6db74">&#34;banner.jpg&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> requestInfo = <span style="color:#66d9ef">new</span> RequestInformation
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        HttpMethod = Method.POST,
</span></span><span style="display:flex;"><span>        UrlTemplate = <span style="color:#e6db74">$&#34;https://graph.microsoft.com/beta/sites/{siteId}/pages&#34;</span>
</span></span><span style="display:flex;"><span>    };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    requestInfo.Headers.Add(<span style="color:#e6db74">&#34;prefer&#34;</span>, <span style="color:#e6db74">&#34;include-unknown-enum-members&#34;</span>);
</span></span><span style="display:flex;"><span>    requestInfo.SetContentFromParsable(GraphClient.RequestAdapter, <span style="color:#e6db74">&#34;multipart/form-data&#34;</span>, multipartBody);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> response = <span style="color:#66d9ef">await</span> GraphClient.RequestAdapter.SendAsync&lt;NewsLinkPage&gt;(
</span></span><span style="display:flex;"><span>        requestInfo,
</span></span><span style="display:flex;"><span>        NewsLinkPage.CreateFromDiscriminatorValue
</span></span><span style="display:flex;"><span>    );
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> createdNewslink = <span style="color:#66d9ef">await</span> GraphClient.Sites[siteId].Pages[response.Id].GetAsync(
</span></span><span style="display:flex;"><span>        requestConfiguration =&gt;
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            requestConfiguration.Headers.Add(<span style="color:#e6db74">&#34;prefer&#34;</span>, <span style="color:#e6db74">&#34;include-unknown-enum-members&#34;</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    );
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> publishRequestInfo = <span style="color:#66d9ef">new</span> RequestInformation
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        HttpMethod = Method.POST,
</span></span><span style="display:flex;"><span>        UrlTemplate = <span style="color:#e6db74">$&#34;https://graph.microsoft.com/beta/sites/{siteId}/pages/{createdNewslink.Id}/microsoft.graph.newsLinkPage/publish&#34;</span>
</span></span><span style="display:flex;"><span>    };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> GraphClient.RequestAdapter.SendNoContentAsync(publishRequestInfo);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>It looks like a lot, but each part has a specific reason for being there. Let me explain the non-obvious bits.</p>
<h2 id="breaking-down-the-code">Breaking Down the Code</h2>
<h3 id="the-microsoftgraphbannerimageweburlcontent-annotation">The <code>@microsoft.graph.bannerImageWebUrlContent</code> Annotation</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span>AdditionalData = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">object</span>&gt;
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">    [&#34;@microsoft.graph.bannerImageWebUrlContent&#34;]</span> = <span style="color:#e6db74">&#34;name:content&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>This is the glue between the JSON part and the image bytes. The value <code>&quot;name:content&quot;</code> tells the Graph API: <em>&ldquo;find the image bytes in the multipart part named &lsquo;content&rsquo;&rdquo;</em>.</p>
<p>When the API processes the request, it reads this annotation, locates the <code>content</code> part in the multipart body, saves the image to the site&rsquo;s assets library, and sets the <code>bannerImageWebUrl</code> property on the created page. The naming has to match — the part you add with <code>AddOrReplacePart(&quot;content&quot;, ...)</code> is what the annotation references.</p>
<h3 id="why-multipartbody-instead-of-the-fluent-api">Why <code>MultipartBody</code> Instead of the Fluent API</h3>
<p>The normal fluent API — <code>GraphClient.Sites[siteId].Pages.PostAsync(...)</code> — only supports JSON payloads. There&rsquo;s no overload that accepts binary data or constructs a multipart request.</p>
<p><code>MultipartBody</code> fills that gap. You compose the request from named parts, each with their own content type:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> multipartBody = <span style="color:#66d9ef">new</span> MultipartBody();
</span></span><span style="display:flex;"><span>multipartBody.AddOrReplacePart(<span style="color:#e6db74">&#34;request&#34;</span>, <span style="color:#e6db74">&#34;application/json&#34;</span>, page);
</span></span><span style="display:flex;"><span>multipartBody.AddOrReplacePart(<span style="color:#e6db74">&#34;content&#34;</span>, <span style="color:#e6db74">&#34;image/jpeg&#34;</span>, bannerImageContent, fileName: <span style="color:#e6db74">&#34;banner.jpg&#34;</span>);
</span></span></code></pre></div><p>The <code>&quot;request&quot;</code> part carries the JSON, the <code>&quot;content&quot;</code> part carries the image bytes. The names are what you reference in <code>@microsoft.graph.bannerImageWebUrlContent</code>.</p>
<h3 id="why-requestinformation-directly">Why <code>RequestInformation</code> Directly</h3>
<p>Since the fluent API can&rsquo;t send multipart requests, we drop down one level to <code>RequestInformation</code> — the underlying request abstraction that all Kiota-generated clients use internally:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> requestInfo = <span style="color:#66d9ef">new</span> RequestInformation
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    HttpMethod = Method.POST,
</span></span><span style="display:flex;"><span>    UrlTemplate = <span style="color:#e6db74">$&#34;https://graph.microsoft.com/beta/sites/{siteId}/pages&#34;</span>
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>requestInfo.SetContentFromParsable(GraphClient.RequestAdapter, <span style="color:#e6db74">&#34;multipart/form-data&#34;</span>, multipartBody);
</span></span></code></pre></div><p><code>SetContentFromParsable</code> serializes the <code>MultipartBody</code> and sets the correct <code>Content-Type</code> header — including the boundary parameter that multipart requests require. This is one of those things you have to discover by reading the Kiota source code rather than the docs.</p>
<h3 id="the-extra-get-after-creation">The Extra GET After Creation</h3>
<p>You might notice the code fetches the page again right after creating it:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> createdNewslink = <span style="color:#66d9ef">await</span> GraphClient.Sites[siteId].Pages[response.Id].GetAsync(...);
</span></span></code></pre></div><p>This is a quirk of the multipart POST. The response from a raw <code>RequestInformation</code>-based call doesn&rsquo;t go through the same deserialization pipeline as the fluent API, which means the returned <code>NewsLinkPage</code> object isn&rsquo;t fully populated. Rather than fighting with it, a quick GET on the newly created page — with the proper <code>prefer</code> header — gives you a cleanly deserialized object with the correct <code>Id</code> to use for publishing.</p>
<h2 id="publishing-the-news-link">Publishing the News Link</h2>
<p>Pages are created as drafts. To make the News Link appear in the SharePoint news feed, you have to explicitly publish it:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> publishRequestInfo = <span style="color:#66d9ef">new</span> RequestInformation
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    HttpMethod = Method.POST,
</span></span><span style="display:flex;"><span>    UrlTemplate = <span style="color:#e6db74">$&#34;https://graph.microsoft.com/beta/sites/{siteId}/pages/{createdNewslink.Id}/microsoft.graph.newsLinkPage/publish&#34;</span>
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">await</span> GraphClient.RequestAdapter.SendNoContentAsync(publishRequestInfo);
</span></span></code></pre></div><p>Again, <code>RequestInformation</code> directly — the Graph Beta SDK&rsquo;s fluent API doesn&rsquo;t expose a typed publish method for <code>newsLinkPage</code>.</p>
<h2 id="what-about-the-v10-sdk">What About the v1.0 SDK?</h2>
<p>Not supported yet. The <code>NewsLinkPage</code> type, the <code>pageLayout: newsLink</code> enum value, and the publish endpoint are all beta-only. When the API graduates to v1.0, the approach should be almost identical — just swap <code>Microsoft.Graph.Beta</code> for <code>Microsoft.Graph</code>.</p>
<p>Until then, you&rsquo;re on beta. Microsoft officially cautions against using beta APIs in production, but in practice this particular API has been stable for a while. Use your own judgment.</p>
<h2 id="references">References</h2>
<ul>
<li><a href="https://learn.microsoft.com/en-us/graph/api/newslinkpage-create?view=graph-rest-beta">Create newsLinkPage - Microsoft Graph beta</a></li>
<li><a href="https://learn.microsoft.com/en-us/graph/api/resources/newslinkpage?view=graph-rest-beta">newsLinkPage resource type</a></li>
<li><a href="https://learn.microsoft.com/en-us/graph/sdks/use-beta">Use the Microsoft Graph SDKs with the beta API</a></li>
<li><a href="https://www.nuget.org/packages/Microsoft.Kiota.Serialization.Multipart">Microsoft.Kiota.Serialization.Multipart on NuGet</a></li>
</ul>
]]></content:encoded></item><item><title>Stop Spamming Your Users: Create Microsoft 365 Groups Without Welcome Emails</title><link>https://jeppe-spanggaard.dk/blogs/graph-sdk-create-group-without-welcome-email/</link><pubDate>Sat, 14 Feb 2026 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/graph-sdk-create-group-without-welcome-email/</guid><description>Learn how to create Microsoft 365 groups programmatically with the Graph SDK in C# without sending a welcome email flood to every member.</description><content:encoded><![CDATA[<h2 id="tldr">TL;DR</h2>
<p>Add <code>WelcomeEmailDisabled</code> to <code>resourceBehaviorOptions</code> via <code>AdditionalData</code> when creating the group. It can <strong>only</strong> be set at creation time — not patched afterward.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span>AdditionalData = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">object</span>&gt;
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    { <span style="color:#e6db74">&#34;resourceBehaviorOptions&#34;</span>, <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">string</span>&gt; { <span style="color:#e6db74">&#34;WelcomeEmailDisabled&#34;</span> } }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Want the full example with owners and members included? Read on. 👇</p>
<h2 id="the-problem">The Problem</h2>
<p>When building automated site provisioning, every Microsoft 365 group creation triggers a welcome email to each member by default. In an automated scenario where multiple groups are created at once, that quickly turns into a flood of emails your users didn&rsquo;t ask for — and they&rsquo;re going to call it spam.</p>
<p>Microsoft <a href="https://learn.microsoft.com/en-us/graph/group-set-options">documents</a> <code>WelcomeEmailDisabled</code> as a supported option, but doesn&rsquo;t show you how to actually set it from the Graph SDK in C#. That&rsquo;s what this post is about.</p>
<h2 id="the-weird-part-additionaldata">The Weird Part: AdditionalData</h2>
<p>If you look at the typed <code>Group</code> object in the SDK, you won&rsquo;t find a <code>ResourceBehaviorOptions</code> property anywhere. It has to be set through <code>AdditionalData</code> — a catch-all dictionary for properties that exist in the Graph API but aren&rsquo;t modeled as first-class typed properties in the SDK.</p>
<p>It works fine, but it does mean you lose IntelliSense and compile-time safety. The same pattern applies to <code>owners@odata.bind</code> and <code>members@odata.bind</code>, which let you assign owners and members at creation time without separate follow-up calls.</p>
<h2 id="the-solution">The Solution</h2>
<p>Here&rsquo;s the complete group creation request with welcome emails disabled, owners set, and members added — all in a single API call:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> ownerId = <span style="color:#e6db74">$&#34;https://graph.microsoft.com/v1.0/users/{ownerObjectId}&#34;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> groupMembers = <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">string</span>&gt;
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">$&#34;https://graph.microsoft.com/v1.0/users/{memberObjectId1}&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">$&#34;https://graph.microsoft.com/v1.0/users/{memberObjectId2}&#34;</span>
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Group requestBody = <span style="color:#66d9ef">new</span>()
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    Description = description,
</span></span><span style="display:flex;"><span>    DisplayName = displayName,
</span></span><span style="display:flex;"><span>    GroupTypes = [<span style="color:#e6db74">&#34;Unified&#34;</span>],
</span></span><span style="display:flex;"><span>    MailEnabled = <span style="color:#66d9ef">false</span>,
</span></span><span style="display:flex;"><span>    MailNickname = siteName,
</span></span><span style="display:flex;"><span>    SecurityEnabled = <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>    AdditionalData = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">object</span>&gt;
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        { <span style="color:#e6db74">&#34;resourceBehaviorOptions&#34;</span>, <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">string</span>&gt; { <span style="color:#e6db74">&#34;WelcomeEmailDisabled&#34;</span> } },
</span></span><span style="display:flex;"><span>        { <span style="color:#e6db74">&#34;owners@odata.bind&#34;</span>, <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">string</span>&gt; { ownerId } },
</span></span><span style="display:flex;"><span>        { <span style="color:#e6db74">&#34;members@odata.bind&#34;</span>, groupMembers }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Group? <span style="color:#66d9ef">group</span> = <span style="color:#66d9ef">await</span> _graphClient.Groups.PostAsync(requestBody);
</span></span></code></pre></div><h3 id="resourcebehavioroptions">resourceBehaviorOptions</h3>
<p><code>resourceBehaviorOptions</code> controls specific group behaviors at creation. <code>WelcomeEmailDisabled</code> simply stops Microsoft 365 from sending the welcome email to members.</p>
<p>You can also combine multiple options in the same list if needed:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span>{ <span style="color:#e6db74">&#34;resourceBehaviorOptions&#34;</span>, <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">string</span>&gt; { <span style="color:#e6db74">&#34;WelcomeEmailDisabled&#34;</span>, <span style="color:#e6db74">&#34;HideGroupInOutlook&#34;</span> } }
</span></span></code></pre></div><p>Other supported values are documented <a href="https://learn.microsoft.com/en-us/graph/group-set-options">here</a>.</p>
<h3 id="and"><a href="mailto:owners@odata.bind">owners@odata.bind</a> and <a href="mailto:members@odata.bind">members@odata.bind</a></h3>
<p>The <code>@odata.bind</code> syntax binds users to the group by their full resource URL at creation time, so you don&rsquo;t need separate <code>POST /groups/{id}/members</code> calls afterward. The URL format must be the full Graph v1.0 path:</p>
<pre tabindex="0"><code>https://graph.microsoft.com/v1.0/users/{objectId}
</code></pre><h2 id="references">References</h2>
<ul>
<li><a href="https://learn.microsoft.com/en-us/graph/group-set-options">Microsoft 365 group behaviors and provisioning options</a></li>
<li><a href="https://learn.microsoft.com/en-us/graph/api/group-post-groups">Create group - Microsoft Graph API</a></li>
</ul>
]]></content:encoded></item><item><title>CSOM Performance: Fast Taxonomy Loading for SharePoint List Items with Many Terms</title><link>https://jeppe-spanggaard.dk/blogs/csom-taxonomy-performance-optimization/</link><pubDate>Sun, 21 Dec 2025 00:00:00 +0000</pubDate><author>Jeppe Spanggaard</author><guid>https://jeppe-spanggaard.dk/blogs/csom-taxonomy-performance-optimization/</guid><description>An undocumented but effective approach to dramatically speed up CSOM taxonomy field loading when dealing with SharePoint list items that have many terms.</description><content:encoded><![CDATA[<h2 id="tldr">TL;DR</h2>
<p><strong>Problem</strong>: Loading taxonomy fields via CSOM for SharePoint list items with many terms is painfully slow. Including taxonomy fields in ViewFields makes the initial query extremely expensive.</p>
<p><strong>Solution</strong>: Batch-load items in chunks, use <code>FieldValuesForEdit</code> to get raw taxonomy data, and parse the internal format directly instead of using TaxonomyFieldValue API calls.</p>
<p><strong>Impact</strong>: ~70-75% performance improvement. Load times dropped from 90-120 seconds to 25-35 seconds for lists with many taxonomy terms.</p>
<p><strong>The Magic</strong>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// Step 1: Load items WITHOUT taxonomy fields in ViewFields (much faster)</span>
</span></span><span style="display:flex;"><span>CamlQuery query = <span style="color:#66d9ef">new</span> CamlQuery();
</span></span><span style="display:flex;"><span>query.Query = <span style="color:#e6db74">@&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &lt;View&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        &lt;ViewFields&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            &lt;FieldRef Name=&#39;ID&#39;/&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            &lt;FieldRef Name=&#39;Title&#39;/&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            &lt;!-- NO taxonomy fields here! --&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        &lt;/ViewFields&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &lt;/View&gt;&#34;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>ListItemCollection items = list.GetItems(query);
</span></span><span style="display:flex;"><span>clientContext.Load(items);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">await</span> clientContext.ExecuteQueryAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Step 2: Load taxonomy fields separately using FieldValuesForEdit</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> (ListItem[] chunk <span style="color:#66d9ef">in</span> items.Cast&lt;ListItem&gt;().Chunk(<span style="color:#ae81ff">100</span>))
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> taxonomyData = <span style="color:#66d9ef">await</span> LoadTaxonomyField(chunk, <span style="color:#e6db74">&#34;TaxonomyFieldInternalName&#34;</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>⚠️ Important</strong>: This is an undocumented approach that works with SharePoint&rsquo;s internal formats. Use with caution and test thoroughly.</p>
<hr>
<h2 id="when-fast-wasnt-fast-enough">When Fast Wasn&rsquo;t Fast Enough</h2>
<p>I was working on a SharePoint solution where we had a list with items containing multiple taxonomy fields. Each item could have anywhere from 5 to 50 taxonomy terms across different fields. The client needed to load hundreds of these items regularly.</p>
<p>The traditional CSOM approach was&hellip; well, let&rsquo;s just say coffee breaks became very popular during load times.</p>
<p><strong>The loading process was taking 90-120 seconds for a few hundred items.</strong> Users were literally walking away from their computers while waiting for data to load.</p>
<p>That&rsquo;s when I realized we needed to completely rethink how we approach taxonomy loading in CSOM.</p>
<h2 id="the-traditional-slow-way">The Traditional (Slow) Way</h2>
<p>Here&rsquo;s what most developers do, and what I was doing initially:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// The traditional approach - including taxonomy fields in ViewFields</span>
</span></span><span style="display:flex;"><span>CamlQuery query = <span style="color:#66d9ef">new</span> CamlQuery();
</span></span><span style="display:flex;"><span>query.Query = <span style="color:#e6db74">@&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &lt;View&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        &lt;ViewFields&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            &lt;FieldRef Name=&#39;ID&#39;/&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            &lt;FieldRef Name=&#39;Title&#39;/&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            &lt;FieldRef Name=&#39;CategoryField&#39;/&gt;     &lt;!-- Taxonomy field --&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            &lt;FieldRef Name=&#39;TagField&#39;/&gt;          &lt;!-- Taxonomy field --&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            &lt;FieldRef Name=&#39;OtherTaxField&#39;/&gt;     &lt;!-- Taxonomy field --&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        &lt;/ViewFields&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &lt;/View&gt;&#34;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>ListItemCollection items = list.GetItems(query);
</span></span><span style="display:flex;"><span>clientContext.Load(items);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">await</span> clientContext.ExecuteQueryAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Then access taxonomy fields normally</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> (ListItem item <span style="color:#66d9ef">in</span> items)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> categoryField = item[<span style="color:#e6db74">&#34;CategoryField&#34;</span>] <span style="color:#66d9ef">as</span> TaxonomyFieldValueCollection;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> tagField = item[<span style="color:#e6db74">&#34;TagField&#34;</span>] <span style="color:#66d9ef">as</span> TaxonomyFieldValueCollection;
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Process taxonomy values...</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Problems with this approach:</strong></p>
<ul>
<li><strong>Including taxonomy fields in ViewFields is extremely slow</strong></li>
<li>SharePoint has to load and process all taxonomy data upfront</li>
<li>Multiple expensive taxonomy service calls during the initial load</li>
<li>No control over how taxonomy data is loaded</li>
</ul>
<p>Result: Coffee break time. Lots of it.</p>
<h2 id="the-optimization-journey">The Optimization Journey</h2>
<p>After digging into SharePoint&rsquo;s internals and some experimentation, I discovered two key things:</p>
<ol>
<li>
<p><strong>Including taxonomy fields in ViewFields is what kills performance.</strong> SharePoint has to load and process all taxonomy data during the initial query, which is extremely slow.</p>
</li>
<li>
<p><strong>SharePoint stores taxonomy data in a raw format</strong> that can be accessed via <code>FieldValuesForEdit</code> and parsed directly. This bypasses the expensive taxonomy service calls entirely.</p>
</li>
</ol>
<p><strong>The breakthrough:</strong> Separate regular field loading from taxonomy field loading. Load items with only the fields you need immediately, then load taxonomy fields separately using the faster <code>FieldValuesForEdit</code> approach.</p>
<h3 id="key-insights">Key Insights:</h3>
<ol>
<li><strong>Exclude taxonomy fields from ViewFields</strong> - This is the biggest performance gain. Load regular fields first, taxonomy fields separately.</li>
<li><strong><code>FieldValuesForEdit</code> contains raw taxonomy data</strong> in the format: <code>Label|GUID;Label|GUID</code> - much faster to parse than TaxonomyFieldValue API</li>
<li><strong>Batch loading is crucial</strong> - load all items&rsquo; FieldValuesForEdit in one call per chunk</li>
<li><strong>Chunking prevents timeouts</strong> - process items in manageable batches</li>
<li><strong>Dictionary lookups are fast</strong> - map terms back to items efficiently</li>
</ol>
<h2 id="the-optimized-solution">The Optimized Solution</h2>
<p>Here&rsquo;s the approach that cut our load times by 70-75%:</p>
<h3 id="main-loading-logic">Main Loading Logic</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">async</span> Task&lt;List&lt;MyItemDTO?&gt;?&gt; GetItemsByOptions(<span style="color:#66d9ef">string</span>[] options)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Step 1: Load list items WITHOUT taxonomy fields in ViewFields</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// This is much faster than including taxonomy fields in the initial query</span>
</span></span><span style="display:flex;"><span>    List list = clientContext.Web.Lists.GetByTitle(<span style="color:#e6db74">&#34;YourListName&#34;</span>);
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    CamlQuery query = <span style="color:#66d9ef">new</span> CamlQuery();
</span></span><span style="display:flex;"><span>    query.Query = <span style="color:#e6db74">@&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        &lt;View&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            &lt;ViewFields&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">                &lt;FieldRef Name=&#39;ID&#39;/&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">                &lt;FieldRef Name=&#39;Title&#39;/&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">                &lt;FieldRef Name=&#39;OtherRegularFields&#39;/&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">                &lt;!-- Notice: NO taxonomy fields here --&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            &lt;/ViewFields&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            &lt;Query&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">                &lt;!-- Your where clause here --&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            &lt;/Query&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        &lt;/View&gt;&#34;</span>;
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    ListItemCollection items = list.GetItems(query);
</span></span><span style="display:flex;"><span>    clientContext.Load(items);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> clientContext.ExecuteQueryAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (items == <span style="color:#66d9ef">null</span> || items.Count == <span style="color:#ae81ff">0</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Step 2: Process in chunks and load taxonomy fields separately</span>
</span></span><span style="display:flex;"><span>    List&lt;MyItemDTO&gt; result = <span style="color:#66d9ef">new</span> List&lt;MyItemDTO&gt;();
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> (ListItem[] chunk <span style="color:#66d9ef">in</span> items.Cast&lt;ListItem&gt;().Chunk(<span style="color:#ae81ff">100</span>))
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Load taxonomy fields separately using FieldValuesForEdit</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> categoryTerms = <span style="color:#66d9ef">await</span> LoadTaxonomyField(chunk, <span style="color:#e6db74">&#34;CategoryField&#34;</span>);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> tagTerms = <span style="color:#66d9ef">await</span> LoadTaxonomyField(chunk, <span style="color:#e6db74">&#34;TagField&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Step 3: Map everything together</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> item <span style="color:#66d9ef">in</span> chunk)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> dto = <span style="color:#66d9ef">new</span> MyItemDTO
</span></span><span style="display:flex;"><span>            {
</span></span><span style="display:flex;"><span>                Id = item.Id,
</span></span><span style="display:flex;"><span>                Title = item[<span style="color:#e6db74">&#34;Title&#34;</span>]?.ToString(),
</span></span><span style="display:flex;"><span>                <span style="color:#75715e">// Map regular fields normally</span>
</span></span><span style="display:flex;"><span>            };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Add taxonomy terms from our separate loading</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> (categoryTerms.TryGetValue(item.Id.ToString(), <span style="color:#66d9ef">out</span> <span style="color:#66d9ef">var</span> terms))
</span></span><span style="display:flex;"><span>                dto.CategoryTerms = terms;
</span></span><span style="display:flex;"><span>                
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> (tagTerms.TryGetValue(item.Id.ToString(), <span style="color:#66d9ef">out</span> <span style="color:#66d9ef">var</span> tags))
</span></span><span style="display:flex;"><span>                dto.TagTerms = tags;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            result.Add(dto);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> result;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="batch-taxonomy-loading">Batch Taxonomy Loading</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">async</span> Task&lt;Dictionary&lt;<span style="color:#66d9ef">string</span>, List&lt;TaxonomyTerm&gt;&gt;&gt; LoadTaxonomyField(
</span></span><span style="display:flex;"><span>    IEnumerable&lt;ListItem&gt; items, 
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> taxonomyFieldInternalName)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Load FieldValuesForEdit for all items in one batch</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> item <span style="color:#66d9ef">in</span> items)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        clientContext.Load(item, i =&gt; i.FieldValuesForEdit);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> clientContext.ExecuteQueryRetryAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Get the field ID for internal format matching</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> list = clientContext.Web.Lists.GetById(listGuid);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> field = list.Fields.GetFieldByInternalName(taxonomyFieldInternalName);
</span></span><span style="display:flex;"><span>    clientContext.Load(field);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> clientContext.ExecuteQueryRetryAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> fieldId = field.Id.ToString();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Parse taxonomy data from each item&#39;s FieldValuesForEdit</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> items.ToDictionary(
</span></span><span style="display:flex;"><span>        item =&gt; item.Id.ToString(),
</span></span><span style="display:flex;"><span>        item =&gt; ParseTaxonomyFromEditValues(item, fieldId)
</span></span><span style="display:flex;"><span>    );
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">private</span> List&lt;TaxonomyTerm&gt; ParseTaxonomyFromEditValues(ListItem item, <span style="color:#66d9ef">string</span> fieldId)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Find the correct field key (SharePoint uses shortened GUIDs)</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> fieldValues = item.FieldValuesForEdit.FieldValues
</span></span><span style="display:flex;"><span>        .Where(x =&gt; x.Key.EndsWith(fieldId.Replace(<span style="color:#e6db74">&#34;-&#34;</span>, <span style="color:#e6db74">&#34;&#34;</span>).Substring(<span style="color:#ae81ff">4</span>)));
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (!fieldValues.Any())
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> List&lt;TaxonomyTerm&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> actualFieldKey = fieldValues.First().Key;
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (!item.FieldValuesForEdit.FieldValues.ContainsKey(actualFieldKey))
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> List&lt;TaxonomyTerm&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Parse the raw taxonomy string</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> rawTaxonomyData = item.FieldValuesForEdit[actualFieldKey];
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> ParseTaxonomyEditString(rawTaxonomyData);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="raw-format-parser">Raw Format Parser</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> List&lt;TaxonomyTerm&gt; ParseTaxonomyEditString(<span style="color:#66d9ef">string</span> editString)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">string</span>.IsNullOrWhiteSpace(editString))
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> List&lt;TaxonomyTerm&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Format: &#34;Label1|GUID1;Label2|GUID2;Label3|GUID3&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> editString
</span></span><span style="display:flex;"><span>        .Split(<span style="color:#66d9ef">new</span>[] { <span style="color:#e6db74">&#39;;&#39;</span> }, StringSplitOptions.RemoveEmptyEntries)
</span></span><span style="display:flex;"><span>        .Select(entry =&gt; 
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> parts = entry.Split(<span style="color:#e6db74">&#39;|&#39;</span>);
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> (parts.Length == <span style="color:#ae81ff">2</span> &amp;&amp; Guid.TryParse(parts[<span style="color:#ae81ff">1</span>], <span style="color:#66d9ef">out</span> Guid guid))
</span></span><span style="display:flex;"><span>            {
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> TaxonomyTerm 
</span></span><span style="display:flex;"><span>                { 
</span></span><span style="display:flex;"><span>                    Label = parts[<span style="color:#ae81ff">0</span>].Trim(), 
</span></span><span style="display:flex;"><span>                    TermGuid = guid 
</span></span><span style="display:flex;"><span>                };
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>        })
</span></span><span style="display:flex;"><span>        .Where(t =&gt; t != <span style="color:#66d9ef">null</span>)
</span></span><span style="display:flex;"><span>        .Select(t =&gt; t!)
</span></span><span style="display:flex;"><span>        .ToList();
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">TaxonomyTerm</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string</span> Label { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> Guid TermGuid { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="performance-impact">Performance Impact</h2>
<p>The results were dramatic:</p>
<p><strong>Before optimization:</strong></p>
<ul>
<li>90-120 seconds for a few hundred items with multiple taxonomy fields</li>
<li>Multiple <code>ExecuteQueryRetryAsync()</code> calls per item</li>
<li>Heavy taxonomy service usage</li>
</ul>
<p><strong>After optimization:</strong></p>
<ul>
<li>25-35 seconds for the same dataset</li>
<li><strong>~70-75% performance improvement</strong></li>
<li>Minimal API calls (2 per chunk: one for items, one for field metadata)</li>
</ul>
<p>The exact savings depend on the number of items and terms, but the pattern held consistently across different datasets.</p>
<h2 id="should-you-try-this-approach">Should You Try This Approach?</h2>
<p><strong>If you&rsquo;re experiencing slow loading times with taxonomy fields, this might be worth testing.</strong></p>
<p>I can&rsquo;t give you specific numbers for when this optimization makes sense - every environment and scenario is different. What I can tell you is that in my case, it made a dramatic difference.</p>
<p><strong>My recommendation:</strong></p>
<ul>
<li>If your current taxonomy loading is painfully slow, try this approach in a test environment</li>
<li>Measure the before and after performance in your specific scenario</li>
<li>Keep the complexity vs. benefit trade-off in mind</li>
<li>Always have a fallback to the traditional approach</li>
</ul>
<p><strong>Remember the limitations:</strong></p>
<ul>
<li>This is an undocumented approach that works with SharePoint&rsquo;s internal formats</li>
<li>You only get basic term information (label + GUID)</li>
<li>You&rsquo;ll need to test thoroughly in your environment</li>
</ul>
<p>The traditional CSOM approach works fine for many scenarios. But if you&rsquo;re hitting performance walls with taxonomy fields, this optimization might be exactly what you need.</p>
<h2 id="important-disclaimers">Important Disclaimers</h2>
<p>⚠️ <strong>This is an undocumented approach</strong> that relies on SharePoint&rsquo;s internal storage format for taxonomy fields. While it has worked reliably in my experience, it&rsquo;s not officially supported by Microsoft.</p>
<h2 id="key-takeaways">Key Takeaways</h2>
<ol>
<li><strong>Batch operations are crucial</strong> - Load multiple items&rsquo; data in single API calls</li>
<li><strong>Chunking prevents timeouts</strong> - Process large datasets in manageable pieces</li>
<li><strong>Raw formats can be faster</strong> - Sometimes bypassing official APIs improves performance</li>
<li><strong>Know when to optimize</strong> - Don&rsquo;t add complexity unless you really need the performance</li>
<li><strong>Document your risks</strong> - Be transparent about using undocumented approaches</li>
</ol>
<p>The traditional CSOM taxonomy approach works fine for simple scenarios. But when you&rsquo;re dealing with lots of items and lots of terms, sometimes you need to think outside the box.</p>
]]></content:encoded></item><item><title>SharePoint CSOM: Prevent Unnecessary Updates and API Calls</title><link>https://jeppe-spanggaard.dk/blogs/sharepoint-csom-prevent-unnecessary-updates/</link><pubDate>Wed, 05 Nov 2025 10:00:00 +0100</pubDate><author>Jeppe Spanggaard</author><guid>https://jeppe-spanggaard.dk/blogs/sharepoint-csom-prevent-unnecessary-updates/</guid><description>The SharePoint API call that wasted resources is now optimized with smart change detection, reducing unnecessary updates and costs.</description><content:encoded><![CDATA[<h2 id="tldr">TL;DR</h2>
<p><strong>Problem</strong>: I was building an API that was blindly updating SharePoint list items, even when users didn&rsquo;t actually change anything. Classic &ldquo;save happy&rdquo; user behavior was triggering thousands of pointless API calls.</p>
<p><strong>Solution</strong>: Built smart field change detection that asks &ldquo;what actually changed?&rdquo; before hitting SharePoint with update operations.</p>
<p><strong>Impact</strong>: 70% reduction in API calls for a typical user workflow, plus way less throttling headaches and happier &ldquo;Service prioritization in SharePoint&rdquo; bills.</p>
<p><strong>The Magic</strong>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> differences = GetDifferences(listItem, newValues, treatEmptyStringAsNull: <span style="color:#66d9ef">true</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (differences.Any())
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Only update if something actually changed</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> (fieldName, newValue) <span style="color:#66d9ef">in</span> differences)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        listItem[fieldName] = newValue;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    listItem.Update();
</span></span><span style="display:flex;"><span>    clientContext.ExecuteQuery();
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Otherwise? Do absolutely nothing. Your API calls will thank you.</span>
</span></span></code></pre></div><hr>
<h2 id="the-moment-everything-clicked">The Moment Everything Clicked</h2>
<p>So there I was, building what I thought was a pretty straightforward API endpoint. Users fill out a form, click save, and boom—SharePoint list item gets updated. Simple, right?</p>
<p>Wrong.</p>
<p>It was during one of those routine maintenance windows when I decided to review some API logs that I noticed something odd. The numbers just didn&rsquo;t add up. We were making way more SharePoint API calls than seemed necessary for the amount of actual data changes happening in the system.</p>
<p><strong>Every operation was hitting SharePoint. Even when nothing had actually changed.</strong></p>
<p>That&rsquo;s when it hit me. Our application was treating every potential update as an actual update, regardless of whether the data was different from what SharePoint already had. Users could open a form, change nothing, click save, and we&rsquo;d still fire off an API call to &ldquo;update&rdquo; the item with identical values.</p>
<p>But SharePoint doesn&rsquo;t care if you&rsquo;re setting a field to the exact same value it already has. It still counts that as an API call. It still hits your throttling limits. And if you&rsquo;re paying for &ldquo;Service Prioritization in SharePoint&rdquo;? It still costs you money.</p>
<h2 id="the-why-did-i-even-build-this-moment">The &ldquo;Why Did I Even Build This?&rdquo; Moment</h2>
<p>I spent some time digging deeper into the patterns, and the numbers were&hellip; enlightening.</p>
<p>This wasn&rsquo;t just a user behavior issue. It was happening everywhere:</p>
<ul>
<li>Users frequently save forms without making actual changes</li>
<li>Automated processes periodically &ldquo;sync&rdquo; data that&rsquo;s already current</li>
<li>Applications send complete object states rather than just changed fields</li>
<li>Integration systems perform routine updates as part of larger workflows</li>
</ul>
<p>When I looked at a typical day&rsquo;s worth of operations, I found that <strong>roughly 70% of our SharePoint update calls weren&rsquo;t actually changing anything</strong>. We were essentially paying SharePoint to accept identical data and politely say &ldquo;thanks for the update!&rdquo;</p>
<p>The more I thought about it, the more I realized this pattern was probably happening in systems everywhere. How many developers have built the same &ldquo;just update everything&rdquo; approach without stopping to ask whether anything actually changed?</p>
<h2 id="sharepoints-sure-ill-take-your-money-attitude">SharePoint&rsquo;s &ldquo;Sure, I&rsquo;ll Take Your Money&rdquo; Attitude</h2>
<p>Here&rsquo;s the thing about SharePoint&rsquo;s CSOM that nobody really talks about: it doesn&rsquo;t care if you&rsquo;re being wasteful. You want to set a field to the exact same value it already has? &ldquo;No problem!&rdquo; says SharePoint. &ldquo;That&rsquo;ll be one API call, please.&rdquo;</p>
<p>And if you&rsquo;re using Service Prioritization in SharePoint? That&rsquo;s real money walking out the door.</p>
<p>Let me show you what I mean with some concrete numbers from a client I worked with recently:</p>
<h3 id="the-head-scratching-cases">The Head-Scratching Cases</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// These should be considered equal, but .NET says &#34;nope&#34;</span>
</span></span><span style="display:flex;"><span>currentValue: <span style="color:#e6db74">&#34;John Doe&#34;</span>
</span></span><span style="display:flex;"><span>newValue: <span style="color:#e6db74">&#34;John Doe&#34;</span>  <span style="color:#75715e">// Easy case, no problem here</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>currentValue: <span style="color:#66d9ef">null</span>
</span></span><span style="display:flex;"><span>newValue: <span style="color:#e6db74">&#34;&#34;</span>  <span style="color:#75715e">// Is empty string the same as null? Your call!</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>currentValue: <span style="color:#ae81ff">42</span>
</span></span><span style="display:flex;"><span>newValue: <span style="color:#e6db74">&#34;42&#34;</span>  <span style="color:#75715e">// Different types, same value. Fun times.</span>
</span></span></code></pre></div><h3 id="the-sharepoint-special-cases">The SharePoint Special Cases</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// User fields (oh boy...)</span>
</span></span><span style="display:flex;"><span>currentValue: FieldUserValue { LookupId = <span style="color:#ae81ff">15</span>, LookupValue = <span style="color:#e6db74">&#34;John Doe&#34;</span> }
</span></span><span style="display:flex;"><span>newValue: FieldUserValue { LookupId = <span style="color:#ae81ff">15</span>, LookupValue = <span style="color:#e6db74">&#34;John Doe&#34;</span> }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Multi-choice fields that hate you</span>
</span></span><span style="display:flex;"><span>currentValue: [<span style="color:#e6db74">&#34;Option A&#34;</span>, <span style="color:#e6db74">&#34;Option C&#34;</span>, <span style="color:#e6db74">&#34;Option B&#34;</span>]
</span></span><span style="display:flex;"><span>newValue: [<span style="color:#e6db74">&#34;Option B&#34;</span>, <span style="color:#e6db74">&#34;Option A&#34;</span>, <span style="color:#e6db74">&#34;Option C&#34;</span>]  <span style="color:#75715e">// Same options, different order</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Taxonomy fields (because why not make it complicated?)</span>
</span></span><span style="display:flex;"><span>currentValue: TaxonomyFieldValue { TermGuid = <span style="color:#e6db74">&#34;abc-123&#34;</span>, Label = <span style="color:#e6db74">&#34;Technology&#34;</span> }
</span></span><span style="display:flex;"><span>newValue: TaxonomyFieldValue { TermGuid = <span style="color:#e6db74">&#34;abc-123&#34;</span>, Label = <span style="color:#e6db74">&#34;Technology&#34;</span> }
</span></span></code></pre></div><p>After an hour of testing different scenarios, I realized I needed something more sophisticated than <code>oldValue == newValue</code>.</p>
<h2 id="building-the-actually-smart-comparison-engine">Building the &ldquo;Actually Smart&rdquo; Comparison Engine</h2>
<p>Alright, time to get serious. I needed a comparison function that could handle all of SharePoint&rsquo;s quirky field types and still be fast enough to not slow down my API.</p>
<p>Here&rsquo;s what I ended up building (and yes, it&rsquo;s a bit of a beast):</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">internal</span> <span style="color:#66d9ef">static</span> List&lt;(<span style="color:#66d9ef">string</span> InternalName, <span style="color:#66d9ef">object?</span> NewValue)&gt; GetDifferences(
</span></span><span style="display:flex;"><span>    ListItem item,
</span></span><span style="display:flex;"><span>    IDictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">object?</span>&gt; newValues,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">bool</span> treatEmptyStringAsNull)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> diffs = <span style="color:#66d9ef">new</span> List&lt;(<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">object?</span>)&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> kvp <span style="color:#66d9ef">in</span> newValues)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> name = kvp.Key;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> newVal = kvp.Value;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">object?</span> currentVal = <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (item.FieldValues != <span style="color:#66d9ef">null</span> &amp;&amp; item.FieldValues.TryGetValue(name, <span style="color:#66d9ef">out</span> <span style="color:#66d9ef">var</span> cv))
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            currentVal = cv;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (!ValuesEqual(currentVal, newVal, treatEmptyStringAsNull))
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            diffs.Add((name, newVal));
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> diffs;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">bool</span> ValuesEqual(<span style="color:#66d9ef">object?</span> a, <span style="color:#66d9ef">object?</span> b, <span style="color:#66d9ef">bool</span> treatEmptyStringAsNull)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (ReferenceEquals(a, b))
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">true</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (a <span style="color:#66d9ef">is</span> <span style="color:#66d9ef">null</span> || b <span style="color:#66d9ef">is</span> <span style="color:#66d9ef">null</span>)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (treatEmptyStringAsNull)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> ((a <span style="color:#66d9ef">is</span> <span style="color:#66d9ef">null</span> &amp;&amp; IsEmptyStringLike(b)) || (b <span style="color:#66d9ef">is</span> <span style="color:#66d9ef">null</span> &amp;&amp; IsEmptyStringLike(a)))
</span></span><span style="display:flex;"><span>            {
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">true</span>;
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">false</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    a = Normalize(a, treatEmptyStringAsNull);
</span></span><span style="display:flex;"><span>    b = Normalize(b, treatEmptyStringAsNull);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (a <span style="color:#66d9ef">is</span> IStructuralEquatable seA &amp;&amp; b <span style="color:#66d9ef">is</span> IStructuralEquatable seB)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> StructuralComparisons.StructuralEqualityComparer.Equals(seA, seB);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> Equals(a, b);
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">bool</span> IsEmptyStringLike(<span style="color:#66d9ef">object?</span> x)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> x <span style="color:#66d9ef">is</span> <span style="color:#66d9ef">string</span> s &amp;&amp; <span style="color:#66d9ef">string</span>.IsNullOrWhiteSpace(s);
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">object?</span> Normalize(<span style="color:#66d9ef">object?</span> v, <span style="color:#66d9ef">bool</span> treatEmptyStringAsNull)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (v == <span style="color:#66d9ef">null</span>)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">switch</span> (v)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> <span style="color:#66d9ef">string</span> s:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> (treatEmptyStringAsNull &amp;&amp; <span style="color:#66d9ef">string</span>.IsNullOrWhiteSpace(s)) ? <span style="color:#66d9ef">null</span> : s;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> <span style="color:#66d9ef">bool</span> b:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> b;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> <span style="color:#66d9ef">byte</span> or <span style="color:#66d9ef">sbyte</span> or <span style="color:#66d9ef">short</span> or <span style="color:#66d9ef">ushort</span> or <span style="color:#66d9ef">int</span> or <span style="color:#66d9ef">uint</span> or <span style="color:#66d9ef">long</span> or <span style="color:#66d9ef">ulong</span> or <span style="color:#66d9ef">float</span> or <span style="color:#66d9ef">double</span> or <span style="color:#66d9ef">decimal</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> Convert.ToDecimal(v, CultureInfo.InvariantCulture);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> DateTime dt:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> utc = (dt.Kind == DateTimeKind.Utc ? dt : DateTime.SpecifyKind(dt, DateTimeKind.Unspecified)).ToUniversalTime();
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> DateTime(utc.Year, utc.Month, utc.Day, utc.Hour, utc.Minute, utc.Second, DateTimeKind.Utc);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> FieldUserValue u:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> u.LookupId;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> FieldLookupValue l:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> l.LookupId;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> IEnumerable&lt;FieldLookupValue&gt; multiLookup:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> multiLookup.Select(x =&gt; x?.LookupId ?? <span style="color:#ae81ff">0</span>).OrderBy(x =&gt; x).ToArray();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> TaxonomyFieldValue tx:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> tx.TermGuid?.Trim().ToLowerInvariant();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> TaxonomyFieldValueCollection txc:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> txc
</span></span><span style="display:flex;"><span>                .Where(x =&gt; x != <span style="color:#66d9ef">null</span> &amp;&amp; !<span style="color:#66d9ef">string</span>.IsNullOrEmpty(x.TermGuid))
</span></span><span style="display:flex;"><span>                .Select(x =&gt; x.TermGuid.Trim().ToLowerInvariant())
</span></span><span style="display:flex;"><span>                .OrderBy(g =&gt; g)
</span></span><span style="display:flex;"><span>                .ToArray();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> FieldGeolocationValue geo:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> ValueTuple&lt;<span style="color:#66d9ef">double</span>, <span style="color:#66d9ef">double</span>, <span style="color:#66d9ef">double</span>, <span style="color:#66d9ef">double</span>&gt;(
</span></span><span style="display:flex;"><span>                Math.Round(geo.Latitude, <span style="color:#ae81ff">6</span>),
</span></span><span style="display:flex;"><span>                Math.Round(geo.Longitude, <span style="color:#ae81ff">6</span>),
</span></span><span style="display:flex;"><span>                Math.Round(geo.Altitude, <span style="color:#ae81ff">2</span>),
</span></span><span style="display:flex;"><span>                Math.Round(geo.Measure, <span style="color:#ae81ff">2</span>));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> <span style="color:#66d9ef">string</span>[] ss:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> ss
</span></span><span style="display:flex;"><span>                .Select(x =&gt; treatEmptyStringAsNull &amp;&amp; <span style="color:#66d9ef">string</span>.IsNullOrWhiteSpace(x) ? <span style="color:#66d9ef">null</span> : x)
</span></span><span style="display:flex;"><span>                .OrderBy(x =&gt; x, StringComparer.Ordinal)
</span></span><span style="display:flex;"><span>                .ToArray();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> IEnumerable enumerable when v <span style="color:#66d9ef">is</span> not <span style="color:#66d9ef">string</span>:
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> list = <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">object?</span>&gt;();
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> e <span style="color:#66d9ef">in</span> enumerable)
</span></span><span style="display:flex;"><span>            {
</span></span><span style="display:flex;"><span>                list.Add(Normalize(e, treatEmptyStringAsNull));
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> list.ToArray();
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">default</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> v;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="the-magic-behind-the-scenes">The Magic Behind the Scenes</h2>
<h3 id="1-the-normalization-dance">1. <strong>The Normalization Dance</strong></h3>
<p>The <code>Normalize</code> method is where the real magic happens. It takes SharePoint&rsquo;s various field types and converts them into something we can actually compare:</p>
<ul>
<li><strong>Numbers</strong>: Everything becomes a <code>decimal</code> because SharePoint loves to mix integers and strings</li>
<li><strong>DateTime</strong>: Converted to UTC and truncated to seconds (because who needs millisecond precision for list items?)</li>
<li><strong>User/Lookup Fields</strong>: We only care about the <code>LookupId</code>, not the display text that might change</li>
<li><strong>Collections</strong>: Sorted alphabetically because SharePoint doesn&rsquo;t guarantee order</li>
<li><strong>Strings</strong>: Can optionally treat empty/whitespace as null (trust me, you want this)</li>
</ul>
<h3 id="2-the">2. <strong>The &ldquo;It&rsquo;s Not You, It&rsquo;s SharePoint&rdquo; Handling</strong></h3>
<p>For arrays and complex objects, I use <code>IStructuralEquatable</code> to do deep comparisons. Because yes, SharePoint will absolutely give you arrays that contain the same items but in different orders.</p>
<h3 id="3-the-business-logic-escape-hatch">3. <strong>The Business Logic Escape Hatch</strong></h3>
<p>The <code>treatEmptyStringAsNull</code> parameter saved my sanity. Different parts of your application might handle empty values differently, and this lets you define your own rules for what counts as &ldquo;unchanged.&rdquo;</p>
<h2 id="but-wait-why-not-just-use-json-comparison">But Wait, Why Not Just Use JSON Comparison?</h2>
<p>Before you ask (because I know you&rsquo;re thinking it): &ldquo;Why build this elaborate comparison engine when you could just serialize both objects to JSON and compare the strings?&rdquo;</p>
<p>Great question. I actually thought the same thing initially. How hard could it be, right? Just <code>JsonSerializer.Serialize()</code> both the old and new values, compare the resulting strings, and call it a day.</p>
<p>Here&rsquo;s what the &ldquo;simple&rdquo; JSON approach looks like:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">internal</span> <span style="color:#66d9ef">static</span> List&lt;(<span style="color:#66d9ef">string</span> InternalName, <span style="color:#66d9ef">object?</span> NewValue)&gt; GetDifferencesJson(
</span></span><span style="display:flex;"><span>    IDictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">object?</span>&gt; oldValues,
</span></span><span style="display:flex;"><span>    IDictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">object?</span>&gt; newValues,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">bool</span> treatEmptyStringAsNull)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> diffs = <span style="color:#66d9ef">new</span> List&lt;(<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">object?</span>)&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> kvp <span style="color:#66d9ef">in</span> newValues)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> name = kvp.Key;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> newVal = kvp.Value;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">object?</span> currentVal = <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>        oldValues?.TryGetValue(name, <span style="color:#66d9ef">out</span> currentVal);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> newJson = JsonSerializer.Serialize(newVal);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> oldJson = JsonSerializer.Serialize(currentVal);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (newJson != oldJson)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            diffs.Add((name, newVal));
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> diffs;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Looks clean and simple, right? That&rsquo;s what I thought too. So I built both approaches and put them head-to-head with BenchmarkDotNet. The results were&hellip; eye-opening.</p>
<h3 id="the-performance-reality-check">The Performance Reality Check</h3>
<p>Here&rsquo;s what I found when comparing the performance of my custom approach versus JSON string comparison:</p>
<p><strong>Single List Item Comparison:</strong></p>
<ul>
<li><strong>Custom Dictionary Approach</strong>: 749,625 operations/second (1.334 μs per operation)</li>
<li><strong>JSON String Approach</strong>: 201,109 operations/second (4.972 μs per operation)</li>
<li><strong>Performance difference</strong>: 3.7x faster with the custom approach</li>
</ul>
<p><strong>Batch Operations (300 items):</strong></p>
<ul>
<li><strong>Custom Dictionary Approach</strong>: 3,705 operations/second (269.91 μs per batch)</li>
<li><strong>JSON String Approach</strong>: 712 operations/second (1,404.06 μs per batch)</li>
<li><strong>Performance difference</strong>: 5.2x faster with the custom approach</li>
</ul>
<h3 id="why-json-comparison-falls-short">Why JSON Comparison Falls Short</h3>
<p>The numbers tell the story, but here&rsquo;s what&rsquo;s actually happening under the hood:</p>
<ol>
<li><strong>Serialization Overhead</strong>: Converting SharePoint field values to JSON strings requires multiple allocations and string operations</li>
<li><strong>Memory Pressure</strong>: JSON approach allocated nearly 50% more memory (4.67KB vs 3.17KB per operation)</li>
<li><strong>Type Conversion Issues</strong>: JSON serialization doesn&rsquo;t handle SharePoint&rsquo;s quirky field types the way we need</li>
<li><strong>Garbage Collection</strong>: More allocations = more GC pressure = slower overall performance</li>
</ol>
<p>When you&rsquo;re processing hundreds or thousands of list items in batch operations, that 3-5x performance difference really adds up. Plus, the custom approach gives us complete control over how different field types are compared, which JSON comparison simply can&rsquo;t provide.</p>
<h2 id="putting-it-all-together-the-real-world-implementation">Putting It All Together: The Real-World Implementation</h2>
<p>Here&rsquo;s how I actually use this in my APIs:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">async</span> Task&lt;<span style="color:#66d9ef">bool</span>&gt; UpdateListItemAsync(<span style="color:#66d9ef">int</span> itemId, Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">object?</span>&gt; newValues)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> clientContext = GetClientContext();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> list = clientContext.Web.Lists.GetByTitle(<span style="color:#e6db74">&#34;YourList&#34;</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> listItem = list.GetItemById(itemId);
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    clientContext.Load(listItem);
</span></span><span style="display:flex;"><span>    clientContext.ExecuteQuery();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// The moment of truth - what actually changed?</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> differences = GetDifferences(listItem, newValues, treatEmptyStringAsNull: <span style="color:#66d9ef">true</span>);
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (!differences.Any())
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Nothing changed! Skip the update entirely</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">false</span>; <span style="color:#75715e">// &#34;Thanks for playing, but you didn&#39;t actually change anything&#34;</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Only update the fields that actually changed</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> (fieldName, newValue) <span style="color:#66d9ef">in</span> differences)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        listItem[fieldName] = newValue;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    listItem.Update();
</span></span><span style="display:flex;"><span>    clientContext.ExecuteQuery();
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">true</span>; <span style="color:#75715e">// &#34;Something actually happened!&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="the-numbers-dont-lie-and-theyre-pretty-great">The Numbers Don&rsquo;t Lie (And They&rsquo;re Pretty Great)</h2>
<p>After implementing this change detection across our applications, here&rsquo;s what we typically see:</p>
<p><strong>Before implementing change detection:</strong></p>
<ul>
<li>Applications making thousands of update requests per day</li>
<li>Every single one triggered a SharePoint <code>ExecuteQuery()</code></li>
<li>High API call volumes and occasional throttling issues</li>
</ul>
<p><strong>After implementing change detection:</strong></p>
<ul>
<li>Same number of update requests from users/systems</li>
<li>60-80% contained no actual changes and were skipped</li>
<li>Dramatically reduced SharePoint API consumption</li>
<li>Much lower throttling risk</li>
</ul>
<p><strong>The typical impact:</strong></p>
<ul>
<li><strong>Significant reduction in monthly API calls</strong></li>
<li><strong>Cost savings</strong> with Service Prioritization in SharePoint (varies by usage)</li>
<li><strong>Reduced throttling risk</strong> during busy periods</li>
<li><strong>Faster API responses</strong> because skipped operations are basically instant</li>
</ul>
<p>But here&rsquo;s what really matters: the performance improvement isn&rsquo;t just about the numbers. Users notice when applications feel more responsive, and developers notice when they stop getting throttling alerts.</p>
<h2 id="the-gotchas-because-there-are-always-gotchas">The Gotchas (Because There Are Always Gotchas)</h2>
<h3 id="when-this-approach-might-not-be-for-you">When This Approach Might Not Be For You</h3>
<ol>
<li>
<p><strong>Audit Requirements</strong>: If you need to log every single &ldquo;save&rdquo; action regardless of whether anything changed, this might not work for your use case.</p>
</li>
<li>
<p><strong>Always Update Timestamps</strong>: Some business requirements dictate that &ldquo;LastModified&rdquo; should always be updated when a user clicks save, even if the content is identical.</p>
</li>
<li>
<p><strong>Super Simple Scenarios</strong>: If you&rsquo;re only updating one field and it&rsquo;s a simple string comparison, the overhead of this elaborate comparison might not be worth it.</p>
</li>
</ol>
<h3 id="the-sharepoint-field-types-that-made-me-question-my-life-choices">The SharePoint Field Types That Made Me Question My Life Choices</h3>
<p>Some field types need special handling:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// Calculated fields - don&#39;t even try to update these</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (field.ReadOnlyField || field.FieldTypeKind == FieldType.Calculated)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">continue</span>; <span style="color:#75715e">// SharePoint will just ignore you anyway</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Attachment fields - these need completely different logic</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (field.FieldTypeKind == FieldType.Attachments)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Handle separately - attachments are their own special nightmare</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">continue</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="wrapping-up-and-why-this-matters-more-than-you-think">Wrapping Up (And Why This Matters More Than You Think)</h2>
<p>Look, I know this might seem like over-engineering for a simple &ldquo;update SharePoint item&rdquo; operation. But here&rsquo;s the thing: those small inefficiencies add up fast.</p>
<p>In my case, this one change:</p>
<ul>
<li><strong>Improved user experience</strong> with faster response times</li>
<li><strong>Reduced throttling headaches</strong> during busy periods</li>
<li><strong>Made me feel like a responsible developer</strong> (this one&rsquo;s important too!)</li>
</ul>
<p>The pattern I&rsquo;ve shown you isn&rsquo;t just about SharePoint or just about API optimization. It&rsquo;s about asking the fundamental question: <strong>&ldquo;Is this operation actually necessary?&rdquo;</strong></p>
<p>That question has made me a better developer across all the platforms I work with, not just SharePoint.</p>
<p>So next time you&rsquo;re building any kind of update operation—whether it&rsquo;s SharePoint, a database, or that JSON file you&rsquo;re definitely not using as a database—pause for a moment and ask: &ldquo;Did anything actually change?&rdquo;</p>
<p>Your future self (and your API bills) will thank you.</p>
<p><strong>Pro tip</strong>: Start implementing this pattern on your highest-traffic endpoints first. That&rsquo;s where you&rsquo;ll see the biggest impact, and it&rsquo;ll give you the confidence to roll it out everywhere else.</p>
<p>Now go forth and eliminate those unnecessary API calls. Your SharePoint environment will purr like a happy cat.</p>
]]></content:encoded></item><item><title>SharePoint's Server-Side Try-Catch: ExceptionHandlingScope</title><link>https://jeppe-spanggaard.dk/blogs/sharepoint-exceptionhandlingscope-csom-ensureuser/</link><pubDate>Mon, 20 Oct 2025 10:00:00 +0100</pubDate><author>Jeppe Spanggaard</author><guid>https://jeppe-spanggaard.dk/blogs/sharepoint-exceptionhandlingscope-csom-ensureuser/</guid><description>Discover how SharePoint's ExceptionHandlingScope can optimize your API calls, reduce costs, and enhance performance with server-side exception handling.</description><content:encoded><![CDATA[<h2 id="tldr">TL;DR</h2>
<p><strong>Problem</strong>: Client making 90,000 CSOM calls/month, 88% were redundant <code>EnsureUser</code> operations for users already on the site.</p>
<p><strong>Solution</strong>: Use <code>FieldUserValue.FromUser()</code> optimistically with <code>ExceptionHandlingScope</code> for server-side try-catch logic.</p>
<p><strong>Impact</strong>: 75-85% reduction in API calls (90,000 → 10,800-21,600), saving $68-79/month and dramatically reducing throttling risk.</p>
<p><strong>Key Insight</strong>: ExceptionHandlingScope doesn&rsquo;t reduce server requests—it reduces network round trips. The real savings come from smarter business logic that avoids unnecessary <code>EnsureUser</code> calls.</p>
<p><strong>Code Pattern</strong>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> scope = <span style="color:#66d9ef">new</span> ExceptionHandlingScope(clientContext);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">using</span> (scope.StartScope()) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">using</span> (scope.StartTry()) {
</span></span><span style="display:flex;"><span>        listItem[<span style="color:#e6db74">&#34;Field&#34;</span>] = FieldUserValue.FromUser(<span style="color:#e6db74">&#34;user@company.com&#34;</span>); <span style="color:#75715e">// Try optimistic</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">using</span> (scope.StartCatch()) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> user = clientContext.Web.EnsureUser(<span style="color:#e6db74">&#34;user@company.com&#34;</span>); <span style="color:#75715e">// Fallback if needed</span>
</span></span><span style="display:flex;"><span>        listItem[<span style="color:#e6db74">&#34;Field&#34;</span>] = FieldUserValue.FromUser(<span style="color:#e6db74">&#34;user@company.com&#34;</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>clientContext.ExecuteQuery(); <span style="color:#75715e">// One network call handles all the logic</span>
</span></span></code></pre></div><hr>
<h2 id="the-service-prioritization-in-sharepoint-cost-analysis">The Service Prioritization in SharePoint Cost Analysis</h2>
<p>Recently, I was working with a client to calculate the cost of enabling <a href="https://learn.microsoft.com/en-us/sharepoint/sharepoint-prioritization">Service Prioritization in SharePoint</a> on one of their app registrations. The pricing model is straightforward: USD $0.50 per 1,000 Graph calls and USD $1.00 per 1,000 SP REST/CSOM calls.</p>
<p>During the analysis, I discovered something that caught my attention: their solution was making approximately <strong>90,000 calls per month</strong>, and <strong>88% of all their CSOM calls were <code>EnsureUser</code> operations.</strong></p>
<p>This was particularly interesting because the users being &ldquo;ensured&rdquo; were already associated with the site where the data was being created. It seemed like there should be a more efficient approach - similar to what&rsquo;s possible with Graph API.</p>
<h2 id="the-problem-with-user-field-assignment">The Problem with User Field Assignment</h2>
<p>If you&rsquo;ve worked with SharePoint user fields, you&rsquo;re familiar with this common pattern:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// The standard approach</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> user = clientContext.Web.EnsureUser(<span style="color:#e6db74">&#34;user@company.com&#34;</span>);
</span></span><span style="display:flex;"><span>clientContext.Load(user);
</span></span><span style="display:flex;"><span>clientContext.ExecuteQuery(); <span style="color:#75715e">// Network call #1</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>listItem[<span style="color:#e6db74">&#34;AssignedTo&#34;</span>] = <span style="color:#66d9ef">new</span> FieldUserValue()
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    LookupId = user.Id
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>listItem.Update();
</span></span><span style="display:flex;"><span>clientContext.ExecuteQuery(); <span style="color:#75715e">// Network call #2</span>
</span></span></code></pre></div><p>This requires two network calls for each user assignment. Microsoft provides <code>FieldUserValue.FromUser(&quot;user@company.com&quot;)</code> as an alternative, but it throws an exception if the user isn&rsquo;t already &ldquo;known&rdquo; to the site. This leads to either making two calls to be safe, or handling exceptions with additional calls.</p>
<h2 id="discovering-exceptionhandlingscope">Discovering ExceptionHandlingScope</h2>
<p>While researching optimization approaches, I came across a section in Microsoft&rsquo;s documentation for &ldquo;<a href="https://learn.microsoft.com/en-us/sharepoint/dev/sp-add-ins/complete-basic-operations-using-sharepoint-client-library-code">Complete basic operations using SharePoint client library code</a>&rdquo; about <strong>ExceptionHandlingScope</strong>.</p>
<p>ExceptionHandlingScope allows you to implement try-catch logic that executes on the SharePoint server rather than requiring multiple round trips from your client application. Instead of this painful dance:</p>
<ol>
<li>Try operation → Network call</li>
<li>Handle exception → Network call</li>
<li>Retry operation → Network call</li>
</ol>
<p>You get this beautiful symphony:</p>
<ol>
<li>Send try-catch logic to server → Network call</li>
<li>Server handles everything internally</li>
<li>Get result → You&rsquo;re done</li>
</ol>
<p>Here&rsquo;s the magic in action:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> scope = <span style="color:#66d9ef">new</span> ExceptionHandlingScope(clientContext);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">using</span> (scope.StartScope())
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">using</span> (scope.StartTry())
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Try the optimistic approach first</span>
</span></span><span style="display:flex;"><span>        listItem[<span style="color:#e6db74">&#34;CaseResponsible&#34;</span>] = FieldUserValue.FromUser(<span style="color:#e6db74">&#34;user@company.com&#34;</span>);
</span></span><span style="display:flex;"><span>        listItem.Update();
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">using</span> (scope.StartCatch())
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// If that fails, ensure the user exists</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> user = clientContext.Web.EnsureUser(<span style="color:#e6db74">&#34;user@company.com&#34;</span>);
</span></span><span style="display:flex;"><span>        listItem[<span style="color:#e6db74">&#34;CaseResponsible&#34;</span>] = FieldUserValue.FromUser(<span style="color:#e6db74">&#34;user@company.com&#34;</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Execute once - the server handles all the logic</span>
</span></span><span style="display:flex;"><span>clientContext.ExecuteQuery();
</span></span></code></pre></div><p><strong>One network call. That&rsquo;s it.</strong></p>
<p>The server receives your entire try-catch block, attempts the optimistic operation first, and only falls back to <code>EnsureUser</code> if needed. No round trips. No guessing. No waste.</p>
<h2 id="real-world-impact-significant-call-reduction">Real-World Impact: Significant Call Reduction</h2>
<p>Looking back at the client scenario with ExceptionHandlingScope:</p>
<ul>
<li><strong>Before</strong>: ~79,200 <code>EnsureUser</code> calls + ~10,800 actual operations = 90,000 total calls</li>
<li><strong>After</strong>: ~10,800-21,600 operations (depending on how many users need ensuring) = substantial reduction</li>
</ul>
<p>This represents a potential <strong>75-85% reduction</strong> in API calls, with corresponding improvements in both performance and Service Prioritization in SharePoint costs.</p>
<h3 id="the-cost-breakdown">The Cost Breakdown</h3>
<p>Let&rsquo;s translate this into actual Service Prioritization in SharePoint costs:</p>
<p><strong>Before optimization:</strong></p>
<ul>
<li>90,000 CSOM calls per month</li>
<li>At USD $1.00 per 1,000 calls</li>
<li><strong>Monthly cost: $90</strong></li>
</ul>
<p><strong>After ExceptionHandlingScope optimization:</strong></p>
<ul>
<li>Best case: 10,800 calls (if most users are already known) = <strong>$10.80/month</strong></li>
<li>Worst case: 21,600 calls (if many users need ensuring) = <strong>$21.60/month</strong></li>
<li><strong>Monthly savings: $68.40 - $79.20</strong></li>
</ul>
<p><strong>Annual savings: $820 - $950</strong></p>
<p>These savings become even more significant at scale. For organizations with multiple solutions or higher call volumes, the cost difference can easily reach thousands of dollars annually.</p>
<p>More importantly, the performance improvements—reduced network latency, faster operations, and lower throttling risk—often provide value that far exceeds the direct cost savings.</p>
<h2 id="throttling-the-hidden-performance-killer">Throttling: The Hidden Performance Killer</h2>
<p>Beyond cost savings, ExceptionHandlingScope provides significant throttling benefits that are often more impactful than the financial savings.</p>
<h3 id="understanding-sharepoint-throttling">Understanding SharePoint Throttling</h3>
<p>SharePoint applies throttling limits to prevent abuse and ensure service stability. When you exceed these limits, you&rsquo;ll encounter:</p>
<ul>
<li><strong>HTTP 429 (Too Many Requests)</strong> responses</li>
<li><strong>Exponential backoff delays</strong> (sometimes minutes)</li>
<li><strong>User experience degradation</strong> as operations slow down</li>
<li><strong>Potential service interruptions</strong> during peak usage</li>
</ul>
<h3 id="the-throttling-math">The Throttling Math</h3>
<p>In our client scenario:</p>
<p><strong>Before ExceptionHandlingScope:</strong></p>
<ul>
<li>90,000 calls per month = ~3,000 calls per day</li>
<li>During peak hours, this could easily trigger throttling</li>
<li>Each throttled request requires retry with exponential backoff</li>
<li>A single throttled operation can delay your entire batch</li>
</ul>
<p><strong>After ExceptionHandlingScope:</strong></p>
<ul>
<li>10,800-21,600 calls per month = ~360-720 calls per day</li>
<li><strong>10x reduction in throttling risk</strong></li>
<li>Smoother operation during peak business hours</li>
<li>More predictable performance for end users</li>
</ul>
<h3 id="real-world-throttling-impact">Real-World Throttling Impact</h3>
<p>Consider a typical business day where multiple users are creating items simultaneously:</p>
<ul>
<li><strong>Without optimization</strong>: 88% of your throttling budget consumed by redundant EnsureUser calls</li>
<li><strong>With ExceptionHandlingScope</strong>: 88% of your throttling budget available for actual business operations</li>
</ul>
<p>The beauty of ExceptionHandlingScope is that it doesn&rsquo;t just reduce the number of calls—it makes your remaining calls more valuable and less likely to be throttled.</p>
<h2 id="important-exceptionhandlingscope-is-not-a-magic-bullet">Important: ExceptionHandlingScope Is NOT a Magic Bullet</h2>
<p><strong>Critical Disclaimer</strong>: ExceptionHandlingScope itself does NOT reduce the number of requests to SharePoint. Each operation within the scope still counts as a separate request for throttling purposes.</p>
<p>The reduction in our scenario comes from <strong>changing the approach</strong>, not from using ExceptionHandlingScope:</p>
<h3 id="what-actually-reduces-calls">What Actually Reduces Calls</h3>
<ul>
<li><strong>Before</strong>: Always calling <code>EnsureUser</code> + setting the field = 2 calls per user</li>
<li><strong>After</strong>: Using <code>FieldUserValue.FromUser()</code> directly, falling back to <code>EnsureUser</code> only when needed</li>
</ul>
<h3 id="what-exceptionhandlingscope-actually-does">What ExceptionHandlingScope Actually Does</h3>
<p>ExceptionHandlingScope reduces <strong>network round trips</strong>, not <strong>server requests</strong>:</p>
<ul>
<li><strong>Network benefit</strong>: 1 round trip instead of potentially 2-3</li>
<li><strong>Throttling impact</strong>: Each operation still counts toward throttling limits</li>
<li><strong>Performance gain</strong>: Reduced latency, not reduced server load</li>
</ul>
<h3 id="the-real-magic">The Real Magic</h3>
<p>The 75-85% reduction in our client scenario comes from this logic change:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// This approach reduces actual requests</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Most users are already known to the site</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// So most operations only need 1 request instead of 2</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">using</span> (scope.StartTry())
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// This succeeds for ~80% of users (1 request)</span>
</span></span><span style="display:flex;"><span>    listItem[<span style="color:#e6db74">&#34;Field&#34;</span>] = FieldUserValue.FromUser(<span style="color:#e6db74">&#34;user@company.com&#34;</span>);
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">using</span> (scope.StartCatch())
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// This only runs for ~20% of users (1 request)</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> user = clientContext.Web.EnsureUser(<span style="color:#e6db74">&#34;user@company.com&#34;</span>);
</span></span><span style="display:flex;"><span>    listItem[<span style="color:#e6db74">&#34;Field&#34;</span>] = FieldUserValue.FromUser(<span style="color:#e6db74">&#34;user@company.com&#34;</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>ExceptionHandlingScope simply makes this pattern efficient by handling the try-catch logic server-side instead of requiring multiple network round trips to determine which approach to use.</p>
<p><strong>Bottom line</strong>: ExceptionHandlingScope optimizes network efficiency, but the request reduction comes from smarter business logic, not from the scope itself.</p>
<h2 id="the-broader-lesson">The Broader Lesson</h2>
<p>This experience highlighted an important principle: before optimizing an existing approach, it&rsquo;s worth questioning whether there&rsquo;s a fundamentally different way to solve the problem. In this case, server-side exception handling provided a cleaner solution than client-side optimization or caching strategies.</p>
<p>ExceptionHandlingScope has been available since SharePoint 2013, but it&rsquo;s not widely discussed in the SharePoint development community. It&rsquo;s a good reminder to periodically review Microsoft&rsquo;s documentation for features that might address current challenges in new ways.</p>
<h2 id="your-next-steps">Your Next Steps</h2>
<p>Before you write your next SharePoint operation that involves potential exceptions:</p>
<ol>
<li><strong>Pause and calculate</strong>: How many network calls will your approach generate?</li>
<li><strong>Question the pattern</strong>: Could server-side logic handle the complexity?</li>
<li><strong>Explore ExceptionHandlingScope</strong>: Can your try-catch logic run on the server?</li>
<li><strong>Measure the impact</strong>: Compare network calls before and after implementation</li>
</ol>
<p>Sometimes the most powerful optimizations come from using the tools that were there all along. ExceptionHandlingScope isn&rsquo;t just a performance optimization—it&rsquo;s a reminder that the platform often provides solutions we didn&rsquo;t know we were looking for.</p>
<p>Next time you&rsquo;re facing a SharePoint performance challenge, remember: the answer might not be in the latest framework or cutting-edge technique. Sometimes, it&rsquo;s hiding in plain sight in the documentation you scrolled past.</p>
]]></content:encoded></item><item><title>Optimize SharePoint Webhooks: Debouncing User Actions</title><link>https://jeppe-spanggaard.dk/blogs/optimize-sharepoint-webhooks-debouncing-user-actions/</link><pubDate>Tue, 14 Oct 2025 00:00:00 +0000</pubDate><guid>https://jeppe-spanggaard.dk/blogs/optimize-sharepoint-webhooks-debouncing-user-actions/</guid><description>Learn how to effectively debounce SharePoint webhooks to optimize performance and reduce unnecessary operations when users frequently click save.</description><content:encoded><![CDATA[<p>Have you ever watched a user frantically editing a SharePoint list item? Click save, make another tiny change, click save again, then realize they made a typo and&hellip; click save once more. Meanwhile, your perfectly crafted webhook is firing off like a machine gun, triggering expensive operations for every single keystroke (okay, not literally every keystroke—but you get the idea).</p>
<p>This is exactly the problem I ran into while building a system where SharePoint list changes needed to trigger updates to site titles. Users would edit customer information, and I wanted the customer&rsquo;s SharePoint site title to reflect those changes. But I definitely didn&rsquo;t want to rename a site five times in thirty seconds just because someone couldn&rsquo;t decide between &ldquo;Acme Corp&rdquo; and &ldquo;ACME Corporation.&rdquo;</p>
<p>The solution? Debouncing. Just like the <code>useDebounce</code> hook that React developers know and love, but for SharePoint webhooks.</p>
<h2 id="tldr">TL;DR</h2>
<p>SharePoint webhooks can trigger expensive operations every time a user clicks save—even for minor edits. To avoid unnecessary updates and improve efficiency, I implemented a debounce pattern using Azure Service Bus scheduled messages and Azure Table Storage. This ensures only the final change is processed after users finish editing, reducing load and preventing out-of-order operations.</p>
<h2 id="the-business-problem">The Business Problem</h2>
<p>Let me paint you the picture. We have a UI that updates SharePoint list items every time a user makes a change. When they modify a customer&rsquo;s name, we want to update the title of that customer&rsquo;s SharePoint site. Simple enough, right?</p>
<p>Wrong.</p>
<p>Users don&rsquo;t make one clean edit and walk away. They edit the name, realize they want to change the format, edit it again, spot a typo, fix that too, and maybe adjust the capitalization for good measure. Each of these changes triggers our SharePoint webhook, which in turn tries to update the site title.</p>
<p>Not only is this inefficient, but it&rsquo;s also potentially problematic. Site title updates aren&rsquo;t instant operations, and we definitely don&rsquo;t want them queuing up and executing out of order.</p>
<p>What we really want is to wait until the user is <em>done</em> making changes, then process all their edits as one logical operation.</p>
<h2 id="enter-the-debounce-pattern">Enter the Debounce Pattern</h2>
<p>The concept is borrowed straight from the front-end world. Instead of reacting to every single change immediately, we wait. If another change comes in within our debounce window, we cancel the previous operation and reset the timer.</p>
<p>Here&rsquo;s the entry point to my implementation:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">WebhookProcessor</span>() {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">async</span> Task HandleListChangeAsync(<span style="color:#66d9ef">string</span> itemId, <span style="color:#66d9ef">string</span> listId) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">using</span> (DebounceScheduler debouncer = <span style="color:#66d9ef">new</span> DebounceScheduler()) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> message = <span style="color:#66d9ef">new</span> ServiceBusMessage() {
</span></span><span style="display:flex;"><span>                ContentType = <span style="color:#e6db74">&#34;application/json&#34;</span>,
</span></span><span style="display:flex;"><span>            };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">await</span> debouncer.DebounceAsync(itemId, listId, message, TimeSpan.FromMinutes(<span style="color:#ae81ff">5</span>));
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Simple on the surface, but there&rsquo;s quite a bit happening under the hood.</p>
<h2 id="the-architecture-azure-service-bus--table-storage">The Architecture: Azure Service Bus + Table Storage</h2>
<p>I built this debounce system using two Azure services:</p>
<ol>
<li><strong>Azure Service Bus</strong> for scheduled message delivery</li>
<li><strong>Azure Table Storage</strong> for tracking sequence numbers</li>
</ol>
<p>Here&rsquo;s why this combination works so well:</p>
<h3 id="azure-service-bus-scheduled-messages">Azure Service Bus Scheduled Messages</h3>
<p>Service Bus has a fantastic feature called scheduled messages. You can schedule a message to be delivered at a specific time in the future, and critically, you can cancel that scheduled message if needed.</p>
<p>This is perfect for debouncing. When a webhook fires, I schedule a message to be processed in 5 minutes. If another webhook fires for the same item before those 5 minutes are up, I cancel the previously scheduled message and schedule a new one.</p>
<h3 id="table-storage-for-state-management">Table Storage for State Management</h3>
<p>The tricky part is remembering which messages you&rsquo;ve scheduled so you can cancel them later. That&rsquo;s where Table Storage comes in.</p>
<p>The core logic looks something like this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">internal</span> <span style="color:#66d9ef">async</span> Task DebounceAsync(<span style="color:#66d9ef">string</span> entityId, <span style="color:#66d9ef">string</span> listId, ServiceBusMessage message, TimeSpan delay) {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Get any existing scheduled message for this entity</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> oldSeq = <span style="color:#66d9ef">await</span> store.GetSequenceNumberAsync(entityId);
</span></span><span style="display:flex;"><span>   
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (oldSeq.HasValue) {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Cancel the previous scheduled message</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">await</span> _sender.CancelScheduledMessageAsync(oldSeq.Value);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">await</span> store.DeleteSequenceNumberAsync(entityId);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Schedule a new message</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> scheduledTime = DateTimeOffset.UtcNow.Add(delay);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">long</span> newSeq = <span style="color:#66d9ef">await</span> _sender.ScheduleMessageAsync(message, scheduledTime);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Store the new sequence number for future cancellation</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> store.SetSequenceNumberAsync(entityId, newSeq, listId);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>For each item being debounced, I store:</p>
<ul>
<li>The sequence number of the scheduled Service Bus message</li>
<li>The list ID</li>
</ul>
<p>When a new change comes in, I can look up the previous sequence number, cancel that message, and schedule a new one.</p>
<h2 id="the-flow-in-action">The Flow in Action</h2>
<p>Let&rsquo;s walk through what happens when a user edits a customer:</p>
<ol>
<li>User changes customer name → SharePoint webhook fires</li>
<li>My webhook Azure Function receives the notification and queues it</li>
<li>The webhook queue processor calls <code>WebhookProcessor.HandleListChangeAsync()</code></li>
<li><code>DebounceScheduler</code> checks Table Storage for any existing scheduled message</li>
<li>If one exists, it gets cancelled via the Service Bus sequence number</li>
<li>A new message is scheduled for 5 minutes from now</li>
<li>The new sequence number is saved to Table Storage</li>
</ol>
<p>If the user makes another change within 5 minutes, steps 4-7 repeat, effectively resetting the countdown.</p>
<p>Once 5 minutes pass without any changes, the scheduled message is delivered to our debounce queue, and the actual processing begins.</p>
<h2 id="when-sharepoint-gets-slow-really-slow">When SharePoint Gets Slow (Really Slow)</h2>
<p>Here&rsquo;s where things got interesting. During testing, I discovered that SharePoint webhooks aren&rsquo;t always as snappy as you&rsquo;d expect.</p>
<p>One evening, I made a change to a list item and noticed that both my webhook and a Power Automate flow (each set up for the same list) didn&rsquo;t fire until 22 minutes later. The timing was unusual, especially since this happened right when the customer&rsquo;s backup solution started running.</p>
<p>I can&rsquo;t say for sure what caused the delay, and I don&rsquo;t want to speculate or draw any conclusions based on this single incident. However, it does highlight an important consideration: webhook delivery isn&rsquo;t guaranteed to be immediate.</p>
<h2 id="trade-offs-and-considerations">Trade-offs and Considerations</h2>
<p>Like any architectural decision, this approach comes with trade-offs:</p>
<p><strong>Pros:</strong></p>
<ul>
<li>Dramatically reduces unnecessary processing</li>
<li>Handles the &ldquo;fidgety user&rdquo; problem elegantly</li>
<li>Resilient to webhook delivery delays</li>
<li>Scales well (Service Bus handles the heavy lifting)</li>
<li>Works across multiple function instances</li>
</ul>
<p><strong>Cons:</strong></p>
<ul>
<li>Adds latency (minimum 5-minute delay)</li>
<li>Requires additional Azure services (cost)</li>
<li>More complex than direct processing</li>
<li>Potential for message loss (though Service Bus is pretty reliable)</li>
</ul>
<p><strong>Alternative Approaches I Considered:</strong></p>
<ol>
<li><strong>In-memory debouncing</strong>: Would work for a single instance, but doesn&rsquo;t scale across multiple Azure Function instances</li>
<li><strong>Redis-based debouncing</strong>: Would work, but adds another dependency and requires more custom logic</li>
<li><strong>Database polling</strong>: Could work, but feels clunky and less efficient than Service Bus scheduled messages</li>
<li><strong>Simple delays</strong>: Just wait X seconds before processing, but this doesn&rsquo;t handle multiple rapid changes</li>
</ol>
<p>The Service Bus + Table Storage approach won because it&rsquo;s distributed by nature, leverages existing Azure services we were already using, and handles cancellation elegantly.</p>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>Debouncing SharePoint webhooks with Azure Service Bus and Table Storage has made our operations more efficient and resilient to user behavior and platform quirks.</p>
<h2 id="references">References</h2>
<ul>
<li><a href="https://learn.microsoft.com/en-us/sharepoint/dev/apis/webhooks/overview-sharepoint-webhooks">Overview of SharePoint webhooks</a></li>
<li><a href="https://learn.microsoft.com/en-us/azure/service-bus-messaging/message-sessions">Azure Service Bus scheduled messages</a></li>
<li><a href="https://learn.microsoft.com/en-us/azure/storage/tables/table-storage-overview">Azure Table Storage documentation</a></li>
</ul>
]]></content:encoded></item><item><title>Stop Retrying Everything: Smart Graph Batch Retry Logic</title><link>https://jeppe-spanggaard.dk/blogs/graph-batch-smart-retry/</link><pubDate>Mon, 22 Sep 2025 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/graph-batch-smart-retry/</guid><description>Learn smart retry logic for Microsoft Graph batching to optimize API calls, reduce throttling, and enhance user experience.</description><content:encoded><![CDATA[<h2 id="the-day-my-batch-requests-started-fighting-back">The Day My Batch Requests Started Fighting Back</h2>
<p>Picture this: It&rsquo;s 2 AM, you&rsquo;re on your third cup of coffee, and you&rsquo;re watching your perfectly crafted Microsoft Graph batch request fail spectacularly. Again.</p>
<p>You&rsquo;ve got 25 files to download from SharePoint. Your batch processes 24 of them perfectly, then one lonely file decides to throw a throttling tantrum. What does your retry logic do? It throws away all 24 successful downloads and starts over. From scratch. Like a digital Groundhog Day, but less amusing and more soul-crushing.</p>
<p>Sound familiar? Welcome to the &ldquo;retry everything&rdquo; club – where perfectly good API calls go to die unnecessarily. 😅</p>
<h2 id="the-grocery-cart-problem-or-why-were-doing-this-wrong">The Grocery Cart Problem (Or: Why We&rsquo;re Doing This Wrong)</h2>
<p>Let me paint you a picture. You&rsquo;re at the grocery store with a cart full of 20 items. You get to checkout, and the cashier says, &ldquo;Sorry, we&rsquo;re out of milk.&rdquo;</p>
<p>What would you do?</p>
<ul>
<li><strong>Option A:</strong> Put back everything, go home, and come back later to shop for all 20 items again</li>
<li><strong>Option B:</strong> Buy the 19 items you can get, then come back just for the milk</li>
</ul>
<p>If you picked Option A, congratulations – you think like most API retry logic! If you picked Option B, you&rsquo;re ready to learn about smart retries.</p>
<p><strong>The &ldquo;retry everything&rdquo; approach is like Option A, and here&rsquo;s why it&rsquo;s bonkers:</strong></p>
<ul>
<li>🔄 <strong>Wasted effort</strong>: You&rsquo;re re-requesting stuff that already worked perfectly</li>
<li>🐌 <strong>Slower performance</strong>: Users wait longer while you redo successful work</li>
<li>📈 <strong>Throttling amplification</strong>: You&rsquo;re actually making the problem worse by hitting successful endpoints again</li>
<li>🔍 <strong>Poor debugging</strong>: Can&rsquo;t easily identify which specific requests are the real troublemakers</li>
</ul>
<p>I learned this the hard way when I watched a simple file sync turn into an API call avalanche. 20 requests became 40, then 80, then&hellip; well, let&rsquo;s just say Microsoft&rsquo;s throttling system got very acquainted with my application.</p>
<h2 id="the-aha-moment-its-simpler-than-you-think">The &ldquo;Aha!&rdquo; Moment (It&rsquo;s Simpler Than You Think)</h2>
<p>The solution hit me during one of those 2 AM debugging sessions: <strong>What if we only retry the stuff that actually failed?</strong></p>
<p>Revolutionary, right? 😏</p>
<p>Here&rsquo;s the beautiful thing – this isn&rsquo;t some PhD-level computer science. It&rsquo;s just common sense applied to code. Keep the winners, retry the losers. Simple.</p>
<p>But (there&rsquo;s always a &ldquo;but&rdquo;), there&rsquo;s one sneaky technical challenge that makes this trickier than it sounds. Microsoft&rsquo;s Graph SDK has a helpful method called <code>NewBatchWithFailedRequests()</code>, but it has a quirk: it generates brand new request IDs. This breaks your ability to map responses back to your original data.</p>
<p>Think of it like this: You order pizza for table 5, but when they bring the replacement slice, they call it table 23. Good luck figuring out who ordered what!</p>
<p>If you&rsquo;re new to Graph batching or request mapping, I&rsquo;d recommend checking out my post on <a href="https://jeppe-spanggaard.dk/blogs/graph-batching-file-content-mapping/">Graph Batching for File Content: Mapping Requests to Responses</a> first. It&rsquo;s like the prequel to this story – explains how to keep track of what&rsquo;s what when dealing with batch responses.</p>
<h2 id="quick-win-summary-for-the-impatient-developers">Quick Win Summary (For the Impatient Developers)</h2>
<p><strong>The Problem:</strong> Your retry logic is like that friend who starts the entire conversation over when they missed one word. Inefficient and annoying.</p>
<p><strong>The Solution:</strong> A drop-in extension method that only retries the actual failures while keeping successful responses safe and sound.</p>
<p><strong>The Payoff:</strong></p>
<ul>
<li>⚡ Faster operations (no more re-downloading working files)</li>
<li>📉 Fewer API calls (your rate limits will thank you)</li>
<li>🎯 Less throttling (stop beating dead endpoints)</li>
<li>😌 Happier users (and happier you at 2 AM)</li>
</ul>
<p><strong>The Catch:</strong> You need to understand request-to-response mapping. Don&rsquo;t worry, it&rsquo;s not rocket science, and I&rsquo;ve got a whole post about it.</p>
<p><strong>Time Investment:</strong> About 5 minutes to implement, countless hours of frustration saved.</p>
<p>Ready for the nitty-gritty? Let&rsquo;s dive in! 👇</p>
<h2 id="the-hero-of-our-story-the-smart-retry-extension">The Hero of Our Story: The Smart Retry Extension</h2>
<p>Okay, here&rsquo;s where we get our hands dirty. The main challenge isn&rsquo;t just filtering out successful requests – it&rsquo;s that pesky <code>NewBatchWithFailedRequests</code> method that scrambles your request IDs like eggs at Sunday brunch.</p>
<p>Here&rsquo;s the extension method that saves the day:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#66d9ef">internal</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">GraphServiceClientExtensions</span> 
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">internal</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">async</span> Task&lt;(IReadOnlyDictionary&lt;<span style="color:#66d9ef">string</span>, HttpStatusCode&gt; Statuses, Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpResponseMessage&gt; BatchResponse)&gt; 
</span></span><span style="display:flex;"><span>        PostBatchWithFailedDependencyRetriesAsync(<span style="color:#66d9ef">this</span> GraphServiceClient graphClient, BatchRequestContentCollection originalBatch) 
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">const</span> <span style="color:#66d9ef">int</span> maxRetries = <span style="color:#ae81ff">5</span>;
</span></span><span style="display:flex;"><span>        TimeSpan delay = TimeSpan.FromSeconds(<span style="color:#ae81ff">1</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpResponseMessage&gt; allResponses = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpResponseMessage&gt;();
</span></span><span style="display:flex;"><span>        Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpStatusCode&gt; allStatuses = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpStatusCode&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        BatchRequestContentCollection batchToSend = originalBatch;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> (<span style="color:#66d9ef">int</span> attempt = <span style="color:#ae81ff">1</span>; attempt &lt;= maxRetries; attempt++) 
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            BatchResponseContentCollection batchResponse = <span style="color:#66d9ef">await</span> graphClient.Batch.PostAsync(batchToSend);
</span></span><span style="display:flex;"><span>            Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpStatusCode&gt; responses = <span style="color:#66d9ef">await</span> batchResponse.GetResponsesStatusCodesAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Filter out failures (excluding redirects which are normal for file content)</span>
</span></span><span style="display:flex;"><span>            Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpStatusCode&gt; failedRequests = responses
</span></span><span style="display:flex;"><span>                .Where(kvp =&gt; !BatchResponseContent.IsSuccessStatusCode(kvp.Value) &amp;&amp; kvp.Value != HttpStatusCode.Found)
</span></span><span style="display:flex;"><span>                .ToDictionary(kvp =&gt; kvp.Key, kvp =&gt; kvp.Value);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Collect all responses from this attempt</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> kvp <span style="color:#66d9ef">in</span> responses) 
</span></span><span style="display:flex;"><span>            {
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">var</span> response = <span style="color:#66d9ef">await</span> batchResponse.GetResponseByIdAsync(kvp.Key);
</span></span><span style="display:flex;"><span>                allResponses[kvp.Key] = response;
</span></span><span style="display:flex;"><span>                allStatuses[kvp.Key] = kvp.Value;
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> (failedRequests.Count == <span style="color:#ae81ff">0</span> || attempt == maxRetries) 
</span></span><span style="display:flex;"><span>            {
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">await</span> Task.Delay(delay);
</span></span><span style="display:flex;"><span>            delay = TimeSpan.FromSeconds(delay.TotalSeconds * <span style="color:#ae81ff">2</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// The key problem: NewBatchWithFailedRequests creates new request IDs!</span>
</span></span><span style="display:flex;"><span>            batchToSend = batchToSend.NewBatchWithFailedRequests(responses);
</span></span><span style="display:flex;"><span>            
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// This is why we need this method - restore the original request IDs</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">await</span> RestoreOriginalRequestIdsAsync(batchToSend, originalBatch);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> (allStatuses, allResponses);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">async</span> Task RestoreOriginalRequestIdsAsync(
</span></span><span style="display:flex;"><span>        BatchRequestContentCollection newBatch, 
</span></span><span style="display:flex;"><span>        BatchRequestContentCollection originalBatch) 
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> stepsSnapshot = newBatch.BatchRequestSteps.ToArray();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> kvp <span style="color:#66d9ef">in</span> stepsSnapshot) 
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> oldStepId = kvp.Key;
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> step = kvp.Value;
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> requestPath = step.Request.RequestUri!.AbsolutePath;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Find the original request ID by matching the request path</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> matchingOriginal = originalBatch.BatchRequestSteps
</span></span><span style="display:flex;"><span>                .First(x =&gt; x.Value.Request.RequestUri!.AbsolutePath == requestPath);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> originalStepId = matchingOriginal.Key;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> (oldStepId != originalStepId) 
</span></span><span style="display:flex;"><span>            {
</span></span><span style="display:flex;"><span>                newBatch.RemoveBatchRequestStepWithId(oldStepId);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">var</span> newStep = <span style="color:#66d9ef">new</span> BatchRequestStep(
</span></span><span style="display:flex;"><span>                    requestId: originalStepId,
</span></span><span style="display:flex;"><span>                    httpRequestMessage: step.Request,
</span></span><span style="display:flex;"><span>                    dependsOn: step.DependsOn);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>                newBatch.AddBatchRequestStep(newStep);
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>What&rsquo;s happening here?</strong> Think of it as a diplomatic negotiator for your API calls:</p>
<ol>
<li><strong>The ID Shuffle Problem</strong>: <code>NewBatchWithFailedRequests</code> gives failed requests shiny new IDs, like witness protection for HTTP requests</li>
<li><strong>The Detective Work</strong>: <code>RestoreOriginalRequestIdsAsync</code> plays detective, matching requests by their paths to find their original identities</li>
<li><strong>The Happy Reunion</strong>: Failed requests get their original IDs back, so your mapping dictionary doesn&rsquo;t break down in tears</li>
</ol>
<p>It&rsquo;s like having a really good wedding planner who makes sure everyone sits at the right table, even after the venue changes.</p>
<h2 id="showtime-watching-smart-retries-in-action">Showtime: Watching Smart Retries in Action</h2>
<p>Now let&rsquo;s see our smart retry logic work its magic in a real-world scenario. Imagine you&rsquo;re building a document sync tool and need to download 25 files from SharePoint. Some will work perfectly, others might throw tantrums due to throttling or network hiccups.</p>
<p>Here&rsquo;s how the new approach handles it like a champ:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#75715e">// Let&#39;s say you need to download content from 25 SharePoint files</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> batch = <span style="color:#66d9ef">new</span> BatchRequestContentCollection(graphClient);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> fileMapping = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, FileContentRequest&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Build the batch for file content downloads</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> filesToDownload = <span style="color:#66d9ef">await</span> GetFilesToProcess(); <span style="color:#75715e">// Your method to get file list</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> file <span style="color:#66d9ef">in</span> filesToDownload) 
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> requestInfo = graphClient.Sites[siteId]
</span></span><span style="display:flex;"><span>                                .Drives[driveId]
</span></span><span style="display:flex;"><span>                                .Items[file.DriveItemId]
</span></span><span style="display:flex;"><span>                                .Content
</span></span><span style="display:flex;"><span>                                .ToGetRequestInformation();
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> requestId = <span style="color:#66d9ef">await</span> batch.AddBatchRequestStepAsync(requestInfo);
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Map the request ID to your file info (same pattern as previous post)</span>
</span></span><span style="display:flex;"><span>    fileMapping[requestId] = <span style="color:#66d9ef">new</span> FileContentRequest 
</span></span><span style="display:flex;"><span>    { 
</span></span><span style="display:flex;"><span>        DriveItemId = file.DriveItemId,
</span></span><span style="display:flex;"><span>        FileName = file.Name,
</span></span><span style="display:flex;"><span>        ExpectedSize = file.Size,
</span></span><span style="display:flex;"><span>        DownloadStartTime = DateTime.Now
</span></span><span style="display:flex;"><span>    };
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// 🎯 Here&#39;s where the magic happens - just one line change!</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> (statuses, responses) = <span style="color:#66d9ef">await</span> graphClient.PostBatchWithFailedDependencyRetriesAsync(batch);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Process results - this is where the retry really shines</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> successfulDownloads = <span style="color:#66d9ef">new</span> List&lt;FileDownloadResult&gt;();
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> failedDownloads = <span style="color:#66d9ef">new</span> List&lt;<span style="color:#66d9ef">string</span>&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> kvp <span style="color:#66d9ef">in</span> fileMapping) 
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> requestId = kvp.Key;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> fileRequest = kvp.Value;
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (responses.TryGetValue(requestId, <span style="color:#66d9ef">out</span> <span style="color:#66d9ef">var</span> response)) 
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> statusCode = statuses[requestId];
</span></span><span style="display:flex;"><span>        
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (BatchResponseContent.IsSuccessStatusCode(statusCode)) 
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Success! Handle the file content</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> contentBytes = <span style="color:#66d9ef">await</span> response.Content.ReadAsByteArrayAsync();
</span></span><span style="display:flex;"><span>            
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Save to your desired location</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> localPath = Path.Combine(downloadFolder, fileRequest.FileName);
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">await</span> File.WriteAllBytesAsync(localPath, contentBytes);
</span></span><span style="display:flex;"><span>            
</span></span><span style="display:flex;"><span>            successfulDownloads.Add(<span style="color:#66d9ef">new</span> FileDownloadResult 
</span></span><span style="display:flex;"><span>            { 
</span></span><span style="display:flex;"><span>                FileName = fileRequest.FileName,
</span></span><span style="display:flex;"><span>                LocalPath = localPath,
</span></span><span style="display:flex;"><span>                ActualSize = contentBytes.Length,
</span></span><span style="display:flex;"><span>                ExpectedSize = fileRequest.ExpectedSize,
</span></span><span style="display:flex;"><span>                DownloadTime = DateTime.Now - fileRequest.DownloadStartTime
</span></span><span style="display:flex;"><span>            });
</span></span><span style="display:flex;"><span>        } 
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">else</span> 
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Even after smart retries, this file failed</span>
</span></span><span style="display:flex;"><span>            failedDownloads.Add(<span style="color:#e6db74">$&#34;{fileRequest.FileName} ({statusCode})&#34;</span>);
</span></span><span style="display:flex;"><span>            
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Log the specific failure for debugging</span>
</span></span><span style="display:flex;"><span>            Console.WriteLine(<span style="color:#e6db74">$&#34;Failed to download {fileRequest.FileName}: {statusCode}&#34;</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Always clean up the response</span>
</span></span><span style="display:flex;"><span>        response.Dispose();
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Console.WriteLine(<span style="color:#e6db74">$&#34;Successfully downloaded: {successfulDownloads.Count} files&#34;</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (failedDownloads.Any())
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    Console.WriteLine(<span style="color:#e6db74">$&#34;Failed downloads: {string.Join(&#34;</span>, <span style="color:#e6db74">&#34;, failedDownloads)}&#34;</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>The beautiful part?</strong> Look at that line with the magic emoji 🎯. That&rsquo;s literally the only change you need to make to your existing batch processing code. Everything else stays exactly the same.</p>
<p><strong>Here&rsquo;s what&rsquo;s happening behind the scenes:</strong></p>
<ol>
<li><strong>First batch attempt</strong>: Say 20 files succeed, 5 fail due to throttling</li>
<li><strong>Smart filtering</strong>: Keep those 20 successful responses safe</li>
<li><strong>Targeted retry</strong>: Build a new batch with just the 5 failures</li>
<li><strong>ID preservation</strong>: Make sure those 5 retries still map to your original file info</li>
<li><strong>Rinse and repeat</strong>: Maybe 4 of the 5 succeed on retry, leaving just 1 persistent troublemaker</li>
</ol>
<p><strong>The result?</strong> Instead of making 250 API calls (25 files × 5 retry attempts for the unlucky ones), you might only make 35 total calls. Your throttling problems become manageable, and files download way faster.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#75715e">// Supporting classes for the example above</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">FileContentRequest</span> 
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string</span> DriveItemId { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; } = <span style="color:#66d9ef">string</span>.Empty;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string</span> FileName { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; } = <span style="color:#66d9ef">string</span>.Empty;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">long</span> ExpectedSize { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> DateTime DownloadStartTime { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">FileDownloadResult</span> 
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string</span> FileName { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; } = <span style="color:#66d9ef">string</span>.Empty;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string</span> LocalPath { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; } = <span style="color:#66d9ef">string</span>.Empty;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">long</span> ActualSize { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">long</span> ExpectedSize { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> TimeSpan DownloadTime { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="the-method-to-the-madness-whats-really-happening">The Method to the Madness (What&rsquo;s Really Happening)</h2>
<p>I know that extension method looks intimidating – like trying to read assembly instructions in a foreign language. But once you break it down, it&rsquo;s actually pretty logical. Let me walk you through the step-by-step dance:</p>
<p><strong>Step 1: The First Attempt</strong>
&ldquo;Let&rsquo;s try everything once and see what happens&rdquo;</p>
<p>Sends your original batch of 25 files and carefully captures every single response and status code. No throwing anything away yet.</p>
<p><strong>Step 2: The Great Sorting</strong>
&ldquo;Okay, who succeeded and who&rsquo;s being difficult?&rdquo;</p>
<p>Separates the winners from the losers, but (and this is important) ignores redirect responses. Why? Because when you&rsquo;re downloading large files, redirects are totally normal – SharePoint often redirects you to the actual storage location.</p>
<p><strong>Step 3: The Preservation Society</strong>
&ldquo;Keep the good stuff safe while we deal with the troublemakers&rdquo;</p>
<p>All successful responses get stored in a safe place while we build a new, smaller batch containing only the failed requests. It&rsquo;s like having a really good filing system for your API responses.</p>
<p><strong>Step 4: The Identity Crisis Resolution</strong>
&ldquo;Wait, who are you again? Let me check your original ID&hellip;&rdquo;</p>
<p>This is the tricky bit! The <code>NewBatchWithFailedRequests</code> method gives everyone new IDs, like a witness protection program for HTTP requests. Our <code>RestoreOriginalRequestIdsAsync</code> method plays detective, matching requests by their URL paths to restore their original identities.</p>
<p><strong>Step 5: The Polite Wait</strong>
&ldquo;Let&rsquo;s not be pushy – maybe try again in a second?&rdquo;</p>
<p>Implements <a href="https://docs.microsoft.com/en-us/azure/architecture/patterns/retry">exponential backoff</a> – starts with a 1-second wait, then 2 seconds, then 4 seconds, etc. This prevents your app from being that annoying person who keeps knocking on the door every second.</p>
<p><strong>Step 6: The Safety Net</strong>
&ldquo;Okay, we tried 5 times. Some files just aren&rsquo;t meant to be downloaded today.&rdquo;</p>
<p>Gives up after 5 attempts to prevent infinite retry loops. Because sometimes you need to know when to walk away from the poker table.</p>
<h2 id="why-this-actually-works-the-science-behind-the-magic">Why This Actually Works (The Science Behind the Magic)</h2>
<p>Here&rsquo;s what makes this approach so much better than the &ldquo;retry everything&rdquo; strategy:</p>
<p><strong>🎯 Surgical Precision</strong>
Only retry what actually failed – it&rsquo;s like having a really good therapist who focuses on the actual problems instead of rehashing everything from childhood.</p>
<p>No wasted API calls on requests that already succeeded. If 24 out of 25 files downloaded perfectly, why punish them with another round trip?</p>
<p><strong>⚡ Speed Demon</strong>
Successful requests don&rsquo;t get repeated, so everything finishes faster – sometimes dramatically faster.</p>
<p>Users see their successful downloads immediately while you quietly retry the problematic ones in the background.</p>
<p><strong>🤝 Throttling-Friendly</strong>
Fewer total requests means you&rsquo;re less likely to hit Microsoft&rsquo;s rate limits, and when you do, recovery is faster.</p>
<p>Instead of amplifying throttling issues, you&rsquo;re actually helping to resolve them by reducing load on the endpoints that are already struggling.</p>
<p><strong>🔄 Drop-in Simplicity</strong>
Change literally one line of code and you&rsquo;re done. No architectural rewrites, no complex state management – just swap out the method call.</p>
<p>Your existing error handling, logging, and business logic all stay exactly the same.</p>
<p><strong>🔍 Debug Paradise</strong>
Easy to see exactly which requests are consistently failing, making troubleshooting a breeze instead of a nightmare.</p>
<p>When file &ldquo;ImportantDocument.pdf&rdquo; fails on every retry attempt, you know there&rsquo;s something specific about that file, not your entire batch logic.</p>
<h2 id="the-before-and-after-moment">The &ldquo;Before and After&rdquo; Moment</h2>
<p>Let me paint you a picture of how this changes your life:</p>
<p><strong>Before Smart Retries:</strong></p>
<ul>
<li>25 file batch fails on 3 files due to throttling</li>
<li>Retry all 25 files → now 5 files fail due to increased throttling</li>
<li>Retry all 25 files again → now 8 files fail</li>
<li>You&rsquo;re now in the throttling spiral of doom</li>
<li>Users are staring at loading spinners</li>
<li>You&rsquo;re questioning your career choices</li>
</ul>
<p><strong>After Smart Retries:</strong></p>
<ul>
<li>25 file batch fails on 3 files due to throttling</li>
<li>Keep the 22 successful files, retry only the 3 failures</li>
<li>Maybe 2 of the 3 succeed on retry, leaving 1 stubborn file</li>
<li>Final retry gets the last file, or you log it as a persistent issue</li>
<li>Users get 24/25 files quickly, you sleep better at night</li>
</ul>
<p>It&rsquo;s the difference between being stuck in traffic because one lane is blocked (and everyone keeps switching to that lane), versus just using the open lanes and going around the problem.</p>
<h2 id="the-bottom-line-and-why-your-future-self-will-thank-you">The Bottom Line (And Why Your Future Self Will Thank You)</h2>
<p>I&rsquo;ll be real with you – when I first started working with Microsoft Graph batching, I thought the built-in retry policies were enough. &ldquo;How hard could it be?&rdquo; I thought. &ldquo;APIs fail sometimes, just retry them!&rdquo;</p>
<p>Then I built my first real-world document sync application. Suddenly, I was dealing with users uploading hundreds of files, enterprise throttling limits, and the occasional network hiccup that would bring the whole operation to a screeching halt.</p>
<p><strong>That&rsquo;s when I learned the hard way that &ldquo;retry everything&rdquo; is like using a sledgehammer to hang a picture frame.</strong> Sure, it might work, but you&rsquo;re probably going to break some stuff in the process.</p>
<p>This selective retry approach has been a game-changer. Not just for performance (though users definitely notice when their bulk operations actually complete), but for debugging too. When you can see that <code>ImportantReport_v23_FINAL_REALLY_FINAL.docx</code> is the file that keeps failing, you can actually do something about it.</p>
<p><strong>The best part?</strong> Once you have this extension method in your toolkit, it becomes muscle memory. You&rsquo;re not adding complexity to your day-to-day development – you&rsquo;re just swapping out one method call for a smarter one. It&rsquo;s like upgrading from a flip phone to a smartphone – you wonder how you ever lived without it.</p>
<p><strong>Pro tip:</strong> After implementing this, keep an eye on your application logs. You&rsquo;ll start to notice patterns in failures that you never saw before. Maybe certain file types are more prone to issues, or maybe there&rsquo;s a specific time of day when throttling gets worse. This kind of insight is pure gold for optimization.</p>
<p>The moral of the story? Sometimes the biggest performance improvements come not from doing things faster, but from doing fewer unnecessary things. And sometimes, the best debugging tool is just&hellip; not breaking the working stuff while you fix the broken stuff.</p>
<p>Your 2 AM debugging sessions will never be the same. 😌</p>
<h2 id="want-to-learn-more-the-reading-list">Want to Learn More? (The Reading List)</h2>
<ul>
<li><strong><a href="https://docs.microsoft.com/en-us/graph/json-batching">Microsoft Graph JSON batching</a></strong> - The official documentation (surprisingly readable!)</li>
<li><strong><a href="https://jeppe-spanggaard.dk/blogs/graph-batching-file-content-mapping/">Graph Batching for File Content: Mapping Requests to Responses</a></strong> - My previous post that sets up the foundation for this one</li>
<li><strong><a href="https://docs.microsoft.com/en-us/graph/throttling">Microsoft Graph throttling guidance</a></strong> - Understanding what makes Microsoft&rsquo;s APIs cranky</li>
<li><strong><a href="https://developer.microsoft.com/en-us/graph/graph-explorer">Graph Explorer</a></strong> - Test your batch requests interactively (great for experimenting)</li>
<li><strong><a href="https://docs.microsoft.com/en-us/azure/architecture/patterns/retry">Exponential Backoff Pattern</a></strong> - The polite way to retry things</li>
</ul>
<p>Now go forth and batch smarter, not harder! 🚀</p>
]]></content:encoded></item><item><title>Graph Batching for File Content: Mapping Requests to Responses</title><link>https://jeppe-spanggaard.dk/blogs/graph-batching-file-content-mapping/</link><pubDate>Wed, 10 Sep 2025 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/graph-batching-file-content-mapping/</guid><description>How to handle Graph batching when downloading file content and mapping responses back to original requests</description><content:encoded><![CDATA[<h2 id="the-problem-thatll-drive-you-crazy">The Problem That&rsquo;ll Drive You Crazy</h2>
<p>Picture this: you need to download 50 files from SharePoint using Microsoft Graph. Being a good developer, you decide to use batching instead of making 50 individual API calls (because nobody wants to wait that long, and Microsoft&rsquo;s throttling limits aren&rsquo;t going anywhere).</p>
<p>You set up your batch request, send it off, and get your responses back. Great! Except&hellip; now you&rsquo;re staring at a bunch of file content with absolutely no way to tell which file is which. 😅</p>
<p>Unlike other Graph operations that return nice JSON objects with IDs and metadata, file content responses are just raw bytes. No file name, no path, no ID - nothing to help you figure out which response belongs to which original request.</p>
<p>I learned this the hard way when I first tried Graph batching for file downloads. Spent way too much time trying to correlate responses by file size or content patterns before realizing there was a much cleaner solution.</p>
<h2 id="tldr">TL;DR</h2>
<p><strong>The Problem:</strong> Graph batch responses for file content are just raw bytes with no metadata - you can&rsquo;t tell which response belongs to which original file request.</p>
<p><strong>The Solution:</strong> Use a dictionary to map batch request IDs to your original file information:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// When building requests</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">string</span> requestId = batchRequestContent.AddBatchRequestStep(...);
</span></span><span style="display:flex;"><span>requestMapping[requestId] = fileInfo;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// When processing responses</span>
</span></span><span style="display:flex;"><span>FileInfoDTO originalFile = requestMapping[requestId];
</span></span></code></pre></div><p><strong>Key Points:</strong></p>
<ul>
<li>Handle both direct content (200 OK) and redirects (302) for large files</li>
<li>Always dispose <code>HttpResponseMessage.Content</code> to prevent memory leaks</li>
<li>Batch limit is 20 requests - chunk larger batches</li>
<li>Request IDs are unique per batch, not globally</li>
</ul>
<p>Want the details? Keep reading! 👇</p>
<h2 id="the-mapping-solution-its-simpler-than-you-think">The Mapping Solution (It&rsquo;s Simpler Than You Think)</h2>
<p>The trick is surprisingly straightforward: use the batch request ID as your bridge between the original file info and the response content. Every batch request gets a unique ID, and that same ID comes back with the response.</p>
<p>Here&rsquo;s the game plan:</p>
<ol>
<li>Create a dictionary mapping request IDs to your original file information</li>
<li>Build your batch requests and store the mappings</li>
<li>Process responses using the request ID to look up the original file info</li>
</ol>
<p>Let me show you exactly how this works.</p>
<h2 id="setting-up-your-file-information">Setting Up Your File Information</h2>
<p>First, let&rsquo;s create a simple model to hold our file details:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">FileInfoDTO</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> Path { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> Name { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> RelativePath { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> UniqueFileName { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Nothing fancy here - just the basics we need to identify and process each file.</p>
<h2 id="the-complete-batch-download-method">The Complete Batch Download Method</h2>
<p>Here&rsquo;s the full implementation. Don&rsquo;t worry, I&rsquo;ll break down the important parts afterward:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">async</span> Task&lt;Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">byte</span>[]&gt;&gt; DownloadFilesBatchAsync(
</span></span><span style="display:flex;"><span>    FileInfoDTO[] fileInfos, 
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> siteId, 
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> driveId) {
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">using</span> BatchRequestContent batchRequestContent = <span style="color:#66d9ef">new</span> BatchRequestContent();
</span></span><span style="display:flex;"><span>    Dictionary&lt;<span style="color:#66d9ef">string</span>, FileInfoDTO&gt; requestMapping = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, FileInfoDTO&gt;();
</span></span><span style="display:flex;"><span>    Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">byte</span>[]&gt; results = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">byte</span>[]&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Build batch requests with mapping</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> (FileInfoDTO fileInfo <span style="color:#66d9ef">in</span> fileInfos) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">string</span>.IsNullOrEmpty(fileInfo.RelativePath) || <span style="color:#66d9ef">string</span>.IsNullOrEmpty(fileInfo.UniqueFileName))
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">continue</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">string</span> requestId = batchRequestContent.AddBatchRequestStep(
</span></span><span style="display:flex;"><span>            GraphClient.Sites[siteId]
</span></span><span style="display:flex;"><span>                      .Drives[driveId]
</span></span><span style="display:flex;"><span>                      .Root
</span></span><span style="display:flex;"><span>                      .ItemWithPath(fileInfo.RelativePath)
</span></span><span style="display:flex;"><span>                      .Content
</span></span><span style="display:flex;"><span>                      .Request());
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// This is the magic - storing the mapping!</span>
</span></span><span style="display:flex;"><span>        requestMapping[requestId] = fileInfo;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Execute batch request</span>
</span></span><span style="display:flex;"><span>    BatchResponseContent batchResponse = <span style="color:#66d9ef">await</span> GraphClient.Batch.Request().PostAsync(batchRequestContent);
</span></span><span style="display:flex;"><span>    Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpResponseMessage&gt; responses = <span style="color:#66d9ef">await</span> batchResponse.GetResponsesAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Process responses using our mapping</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> ((<span style="color:#66d9ef">string</span> requestId, HttpResponseMessage response) <span style="color:#66d9ef">in</span> responses) {
</span></span><span style="display:flex;"><span>        FileInfoDTO originalFile = requestMapping[requestId]; <span style="color:#75715e">// Look up the original file info</span>
</span></span><span style="display:flex;"><span>        
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">switch</span> (response.StatusCode) {
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">case</span> HttpStatusCode.OK:
</span></span><span style="display:flex;"><span>                    <span style="color:#66d9ef">byte</span>[] content = <span style="color:#66d9ef">await</span> response.Content.ReadAsByteArrayAsync();
</span></span><span style="display:flex;"><span>                    results[originalFile.UniqueFileName!] = content;
</span></span><span style="display:flex;"><span>                    <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>                    
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">case</span> HttpStatusCode.Redirect:
</span></span><span style="display:flex;"><span>                    <span style="color:#75715e">// Handle redirect for large files (more on this below)</span>
</span></span><span style="display:flex;"><span>                    <span style="color:#66d9ef">byte</span>[] redirectContent = <span style="color:#66d9ef">await</span> DownloadFromRedirectAsync(response.Headers.Location);
</span></span><span style="display:flex;"><span>                    results[originalFile.UniqueFileName!] = redirectContent;
</span></span><span style="display:flex;"><span>                    <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>                    
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">case</span> HttpStatusCode.TooManyRequests:
</span></span><span style="display:flex;"><span>                    <span style="color:#75715e">// Handle throttling</span>
</span></span><span style="display:flex;"><span>                    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Exception(<span style="color:#e6db74">$&#34;Throttled request for {originalFile.Name}&#34;</span>);
</span></span><span style="display:flex;"><span>                    
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">default</span>:
</span></span><span style="display:flex;"><span>                    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Exception(<span style="color:#e6db74">$&#34;Failed to download {originalFile.Name}: {response.ReasonPhrase}&#34;</span>);
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">finally</span> {
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Always dispose - learned this one the hard way after some memory leak hunting</span>
</span></span><span style="display:flex;"><span>            response.Content.Dispose();
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> results;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="the-key-parts-explained">The Key Parts Explained</h2>
<h3 id="the-mapping-dictionary">The Mapping Dictionary</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span>Dictionary&lt;<span style="color:#66d9ef">string</span>, FileInfoDTO&gt; requestMapping = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, FileInfoDTO&gt;();
</span></span></code></pre></div><p>This is your lifeline. For every request you add to the batch, you store the request ID and link it to your original file information. When responses come back, you can instantly look up which file each response belongs to.</p>
<h3 id="building-requests-with-mapping">Building Requests with Mapping</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">string</span> requestId = batchRequestContent.AddBatchRequestStep(...);
</span></span><span style="display:flex;"><span>requestMapping[requestId] = fileInfo;
</span></span></code></pre></div><p>The <code>AddBatchRequestStep</code> method returns a unique request ID. Store this immediately - you&rsquo;ll need it to match responses later.</p>
<h2 id="handling-the-redirect-curveball">Handling the Redirect Curveball</h2>
<p>Here&rsquo;s something that caught me off guard initially: large files don&rsquo;t return content directly. Instead, Graph gives you a redirect to an Azure Blob Storage URL where the actual file lives. Microsoft doesn&rsquo;t specify the exact file size threshold, but in practice, I&rsquo;ve observed this happening with files larger than a few MB.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">async</span> Task&lt;<span style="color:#66d9ef">byte</span>[]&gt; DownloadFromRedirectAsync(Uri? redirectUri) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (redirectUri == <span style="color:#66d9ef">null</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> ArgumentException(<span style="color:#e6db74">&#34;Redirect URI is null&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">using</span> HttpClient httpClient = <span style="color:#66d9ef">new</span> HttpClient();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">await</span> httpClient.GetByteArrayAsync(redirectUri);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>This happens because Microsoft doesn&rsquo;t want to push huge files through the Graph API unnecessarily. The redirect URL is temporary and works great - just make sure you handle it properly.</p>
<p><strong>Note:</strong> The exact file size that triggers a redirect isn&rsquo;t officially documented by Microsoft, so always handle both direct content (200 OK) and redirect (302) responses in your code.</p>
<h2 id="error-handling-that-actually-helps">Error Handling That Actually Helps</h2>
<p>When things go wrong (and they will), you want meaningful error messages. Here&rsquo;s how to handle the common scenarios:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">async</span> Task ProcessBatchResponseAsync(
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> requestId, 
</span></span><span style="display:flex;"><span>    HttpResponseMessage response, 
</span></span><span style="display:flex;"><span>    FileInfoDTO originalFile,
</span></span><span style="display:flex;"><span>    Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">byte</span>[]&gt; results) {
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">switch</span> (response.StatusCode) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">case</span> HttpStatusCode.OK:
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">byte</span>[] content = <span style="color:#66d9ef">await</span> response.Content.ReadAsByteArrayAsync();
</span></span><span style="display:flex;"><span>                results[originalFile.UniqueFileName!] = content;
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>                
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">case</span> HttpStatusCode.Redirect:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">case</span> HttpStatusCode.Found:
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">byte</span>[] redirectContent = <span style="color:#66d9ef">await</span> DownloadFromRedirectAsync(response.Headers.Location);
</span></span><span style="display:flex;"><span>                results[originalFile.UniqueFileName!] = redirectContent;
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>                
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">case</span> HttpStatusCode.NotFound:
</span></span><span style="display:flex;"><span>                Console.WriteLine(<span style="color:#e6db74">$&#34;File not found: {originalFile.Name} at {originalFile.RelativePath}&#34;</span>);
</span></span><span style="display:flex;"><span>                <span style="color:#75715e">// Maybe the file was moved or deleted</span>
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>                
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">case</span> HttpStatusCode.TooManyRequests:
</span></span><span style="display:flex;"><span>                Console.WriteLine(<span style="color:#e6db74">$&#34;Throttled request for: {originalFile.Name}&#34;</span>);
</span></span><span style="display:flex;"><span>                <span style="color:#75715e">// This is where retry logic would go (coming in the next post!)</span>
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>                
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">case</span> HttpStatusCode.Forbidden:
</span></span><span style="display:flex;"><span>                Console.WriteLine(<span style="color:#e6db74">$&#34;Access denied for: {originalFile.Name}&#34;</span>);
</span></span><span style="display:flex;"><span>                <span style="color:#75715e">// Check your permissions</span>
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>                
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">default</span>:
</span></span><span style="display:flex;"><span>                Console.WriteLine(<span style="color:#e6db74">$&#34;Unexpected error downloading {originalFile.Name}: {response.StatusCode} - {response.ReasonPhrase}&#34;</span>);
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">finally</span> {
</span></span><span style="display:flex;"><span>        response.Content?.Dispose(); <span style="color:#75715e">// Don&#39;t leak memory!</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="important-things-to-remember">Important Things to Remember</h2>
<h3 id="batch-size-limits">Batch Size Limits</h3>
<p>Graph batching has a hard limit of 20 requests per batch. If you have more files, you&rsquo;ll need to chunk them:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#66d9ef">int</span> BATCH_SIZE = <span style="color:#ae81ff">20</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> (<span style="color:#66d9ef">int</span> i = <span style="color:#ae81ff">0</span>; i &lt; fileInfos.Length; i += BATCH_SIZE) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> batch = fileInfos.Skip(i).Take(BATCH_SIZE).ToArray();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> batchResults = <span style="color:#66d9ef">await</span> DownloadFilesBatchAsync(batch, siteId, driveId);
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Merge results...</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="memory-management">Memory Management</h3>
<p>Always dispose of <code>HttpResponseMessage.Content</code>. File downloads can be large, and forgetting to dispose will cause memory leaks that are painful to debug.</p>
<h3 id="request-id-uniqueness">Request ID Uniqueness</h3>
<p>Request IDs are unique within a single batch, but not across different batches. Don&rsquo;t try to reuse mappings between different batch operations.</p>
<h2 id="why-this-pattern-works-so-well">Why This Pattern Works So Well</h2>
<p>This approach has several advantages that make it my go-to solution:</p>
<ul>
<li><strong>Dead Simple</strong>: No complex logic, just a straightforward mapping pattern</li>
<li><strong>Reliable</strong>: Works consistently regardless of file sizes or response order</li>
<li><strong>Memory Efficient</strong>: Proper cleanup prevents memory leaks</li>
<li><strong>Debuggable</strong>: Easy to trace issues when something goes wrong</li>
<li><strong>Extensible</strong>: Perfect foundation for adding retry logic later</li>
</ul>
<p>The beauty is in its simplicity. You&rsquo;re not trying to guess which response belongs to which request - you know exactly because you mapped it from the start.</p>
<h2 id="whats-next">What&rsquo;s Next?</h2>
<p>This mapping pattern solves the core problem of correlating Graph batch responses with your original requests. But what happens when some requests fail due to throttling or temporary errors?</p>
<p>In my next post, I&rsquo;ll show you how to build retry logic on top of this foundation that automatically handles failed requests without losing track of which files still need to be downloaded.</p>
<p>Have you run into this mapping challenge before? Let me know in the comments how you solved it - I&rsquo;m always curious about different approaches! 🚀</p>
<h2 id="references">References</h2>
<ul>
<li><a href="https://docs.microsoft.com/en-us/graph/api/driveitem-get-content">Microsoft Graph API - Get Drive Item Content</a></li>
<li><a href="https://docs.microsoft.com/en-us/graph/json-batching">Microsoft Graph JSON Batching</a></li>
<li><a href="https://docs.microsoft.com/en-us/graph/api/driveitem-get-content?view=graph-rest-1.0">SharePoint Drive Item Content API</a></li>
</ul>
]]></content:encoded></item><item><title>CSOM Performance Optimization: Why You Should Batch Your SharePoint Operations</title><link>https://jeppe-spanggaard.dk/blogs/csom-performance-optimization-chunking/</link><pubDate>Sun, 31 Aug 2025 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/csom-performance-optimization-chunking/</guid><description>Learn how to dramatically improve CSOM performance by batching operations instead of executing them one by one</description><content:encoded><![CDATA[<h2 id="the-problem-one-by-one-operations-kill-performance">The Problem: One-by-One Operations Kill Performance</h2>
<p>Following up on my previous post about <a href="https://jeppe-spanggaard.dk/blogs/devproxy-throttling-testing/">DevProxy and throttling testing</a>, there&rsquo;s another critical performance issue I see regularly in SharePoint CSOM code: executing operations one by one instead of batching them.</p>
<p>Consider this common pattern that I see everywhere:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// ❌ This approach is slow and inefficient</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> id <span style="color:#66d9ef">in</span> itemIds)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> item = list.GetItemById(id);
</span></span><span style="display:flex;"><span>    context.Load(item);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> context.ExecuteQueryAsync(); <span style="color:#75715e">// Executing for EACH item!</span>
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Process the item</span>
</span></span><span style="display:flex;"><span>    ProcessItem(item);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>This code makes a server round-trip for every single item.</strong> If you&rsquo;re processing 50 items, that&rsquo;s 50 separate calls to SharePoint. Each call has network latency and server processing time.</p>
<h2 id="tldr">TL;DR</h2>
<p><strong>Problem</strong>: Executing CSOM operations one-by-one creates a server call for each item (50 items = 50 server calls = ~3-5 seconds).</p>
<p><strong>Solution</strong>: Batch up to 100 operations before calling <code>ExecuteQueryAsync()</code> (50 items = 1 server call = ~200-500ms).</p>
<p><strong>Result</strong>: 10x+ performance improvement with minimal code changes. Use the helper methods below to batch any CSOM operations.</p>
<h2 id="the-solution-batch-operations">The Solution: Batch Operations</h2>
<p>SharePoint CSOM reliably handles batches of around <strong>100 operations</strong> before calling <code>ExecuteQueryAsync()</code>. This means you can dramatically reduce the number of server round-trips.</p>
<p>Microsoft&rsquo;s official documentation also emphasizes this pattern in their <a href="https://learn.microsoft.com/en-us/sharepoint/dev/sp-add-ins/complete-basic-operations-using-sharepoint-client-library-code#group-data-retrieval-on-the-same-object-together-to-improve-performance">performance guidelines</a>, showing how grouping data retrieval operations significantly improves performance.</p>
<p>Here&rsquo;s the pattern I use in most of my SharePoint projects:</p>
<h2 id="the-helper-methods">The Helper Methods</h2>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">async</span> Task&lt;List&lt;T&gt;&gt; GetItemsByIds&lt;T&gt;(<span style="color:#66d9ef">this</span> List list, IEnumerable&lt;<span style="color:#66d9ef">int</span>&gt; ids) 
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">where</span> T : <span style="color:#66d9ef">new</span>()
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (ids == <span style="color:#66d9ef">null</span> || !ids.Any())
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> List&lt;T&gt;();
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> listItems = <span style="color:#66d9ef">await</span> CSOMHelpers.ProcessInChunks(ids.ToList(), <span style="color:#ae81ff">100</span>, <span style="color:#66d9ef">async</span> chunk =&gt; {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> items = chunk.Select(id =&gt; {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> item = list.GetItemById(id);
</span></span><span style="display:flex;"><span>            list.Context.Load(item);
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> item;
</span></span><span style="display:flex;"><span>        }).ToList();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">await</span> ExecuteQuery(list.Context, () =&gt; { <span style="color:#75715e">/* Load calls already done above */</span> });
</span></span><span style="display:flex;"><span>        
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> items;
</span></span><span style="display:flex;"><span>    });
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> listItems.ToList();
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">internal</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">async</span> Task&lt;List&lt;TOut&gt;&gt; ProcessInChunks&lt;TIn, TOut&gt;(List&lt;TIn&gt; source, <span style="color:#66d9ef">int</span> chunkSize, Func&lt;List&lt;TIn&gt;, Task&lt;List&lt;TOut&gt;&gt;&gt; action)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> result = <span style="color:#66d9ef">new</span> List&lt;TOut&gt;();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> chunk <span style="color:#66d9ef">in</span> source.Chunk(chunkSize))
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        result.AddRange(<span style="color:#66d9ef">await</span> action(chunk.ToList()));
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> result;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="performance-impact-the-numbers">Performance Impact: The Numbers</h2>
<p>Let me show you the difference with a real-world example. Loading 50 SharePoint list items:</p>
<h3 id="before-one-by-one">Before (One-by-One)</h3>
<ul>
<li><strong>50 server calls</strong></li>
<li><strong>Network latency</strong>: 50ms × 50 = 2.5 seconds</li>
<li><strong>Actual total time is usually higher due to server-side processing, hence</strong>: ~3-5 seconds</li>
</ul>
<h3 id="after-batched">After (Batched)</h3>
<ul>
<li><strong>1 server call</strong> (all 50 items in one batch)</li>
<li><strong>Network latency</strong>: 50ms × 1 = 50ms</li>
<li><strong>Total time</strong>: ~200-500ms</li>
</ul>
<blockquote>
<p><strong>⚠️ Performance Disclaimer</strong></p>
<p>The numbers shown above are from a specific example scenario and are meant to illustrate the concept of batching benefits. <strong>Your actual performance gains will vary significantly</strong> based on:</p>
<ul>
<li>Network latency and bandwidth</li>
<li>SharePoint server load and location</li>
<li>Item complexity and field count</li>
<li>Query complexity and filtering</li>
<li>Authentication overhead</li>
<li>Time of day and concurrent users</li>
</ul>
<p>While batching almost always improves performance, the exact improvement factor can range from marginal gains to dramatic improvements. The key takeaway is the <strong>pattern and approach</strong>, not the specific numbers.</p>
</blockquote>
<h2 id="applying-the-pattern-to-crud-operations">Applying the Pattern to CRUD Operations</h2>
<p>This pattern works for all CSOM operations, not just reading, but for all the CRUD operations.</p>
<h2 id="why-100-items">Why 100 Items?</h2>
<p>SharePoint has practical limits on how many operations you can batch:</p>
<ul>
<li><strong>CSOM limit</strong>: Around 100 operations per batch</li>
<li><strong>REST API limit</strong>: Different limits depending on operation type</li>
<li><strong>Network payload</strong>: Larger batches mean bigger HTTP requests</li>
</ul>
<p>I&rsquo;ve found <strong>100 items</strong> to be the sweet spot that:</p>
<ul>
<li>Maximizes performance gains</li>
<li>Stays well within SharePoint limits</li>
<li>Keeps HTTP payloads manageable</li>
<li>Works reliably across different SharePoint environments</li>
</ul>
<h2 id="combining-with-throttling-protection">Combining with Throttling Protection</h2>
<p>When you combine this batching approach with the throttling protection patterns from my <a href="https://jeppe-spanggaard.dk/blogs/devproxy-throttling-testing/">DevProxy post</a>, you get robust, high-performance SharePoint applications.</p>
<h2 id="best-practices">Best Practices</h2>
<ol>
<li><strong>Batch whenever you can</strong> - even for small numbers of items, batching usually helps.</li>
<li><strong>Use 100 as your chunk size</strong> for most scenarios</li>
<li><strong>Combine with retry logic</strong> for production resilience</li>
<li><strong>Test with DevProxy</strong> to ensure your batching works under throttling conditions</li>
<li><strong>Monitor performance</strong> before and after implementing batching</li>
</ol>
<h2 id="the-bottom-line">The Bottom Line</h2>
<p>Batching CSOM operations is one of the easiest wins for SharePoint performance optimization. The code pattern is straightforward to implement and reuse, but the performance impact is dramatic.</p>
<p><strong>Stop executing SharePoint operations one by one.</strong> Your users (and SharePoint servers) will thank you.</p>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://docs.microsoft.com/en-us/sharepoint/dev/sp-add-ins/complete-basic-operations-using-sharepoint-client-library-code">SharePoint CSOM Best Practices</a></li>
<li><a href="https://jeppe-spanggaard.dk/blogs/devproxy-throttling-testing/">DevProxy for Testing Throttling</a></li>
<li><a href="https://docs.microsoft.com/en-us/sharepoint/dev/general-development/how-to-avoid-getting-throttled-or-blocked-in-sharepoint-online">SharePoint Performance Guidelines</a></li>
</ul>
]]></content:encoded></item><item><title>DevProxy: How to Test API Rate Limiting and Throttling in C# Development</title><link>https://jeppe-spanggaard.dk/blogs/devproxy-throttling-testing/</link><pubDate>Sun, 10 Aug 2025 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/devproxy-throttling-testing/</guid><description>Discover how to simulate Microsoft Graph and SharePoint throttling locally using DevProxy, and prevent production slowdowns before they happen.</description><content:encoded><![CDATA[<h2 id="the-problem-when-parallel-programming-backfires">The Problem: When Parallel Programming Backfires</h2>
<p>If you’ve ever optimized your C# API calls with parallel programming, you might know this story.</p>
<p>Your client wants faster performance. You run multiple API requests in parallel. Locally, everything flies — especially late at night when traffic is low. But the next morning, on the final pre-deployment test, disaster strikes.</p>
<p><strong>Random errors. Requests delayed for 45 seconds.</strong><br>
Microsoft Graph or SharePoint has slammed you with throttling and rate limits. Your blazing-fast local solution crumbles under production-like conditions.</p>
<hr>
<h2 id="tldr">TL;DR</h2>
<ul>
<li>Parallel programming can trigger throttling and rate limiting in production, even if it works locally.</li>
<li><a href="https://github.com/dotnet/dev-proxy">DevProxy</a> lets you <strong>simulate throttling during development</strong> so you can fix issues early.</li>
<li>Use the sample configuration below to test Microsoft Graph and SharePoint API calls.</li>
<li>Follow <a href="https://github.com/OneDrive/samples/blob/master/scenarios/throttling-ratelimit-handling/readme.md">Bert Jansen’s throttling guide</a> for proven handling patterns.</li>
<li><strong>Start testing early</strong> — not after problems appear.</li>
</ul>
<hr>
<h2 id="how-crud-operations-can-secretly-burn-through-your-limits--and-how-to-catch-them-with-devproxy">How CRUD Operations Can Secretly Burn Through Your Limits — and How to Catch Them with DevProxy</h2>
<p>Here’s the thing:<br>
SharePoint Online charges “Resource Units” (RUs) for every request you make. Think of RUs as an invisible currency — every API call you send deducts from your allowance. Run out too fast, and throttling kicks in.</p>
<p>And those “simple” operations? They’re not as cheap as you think.</p>
<p><strong>From Microsoft’s RU table</strong>:</p>
<table>
  <thead>
      <tr>
          <th>Operation Type</th>
          <th>RU Cost</th>
          <th>What That Means</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Single item query</strong></td>
          <td>1 RU</td>
          <td>Reading one specific list item</td>
      </tr>
      <tr>
          <td><strong>Multi-item query</strong></td>
          <td>2 RUs</td>
          <td>Listing children, filtering, sorting</td>
      </tr>
      <tr>
          <td><strong>Create / Update / Delete / Upload</strong></td>
          <td>2 RUs</td>
          <td>Per individual operation (bulk operations may be more efficient)</td>
      </tr>
      <tr>
          <td><strong>Permission expansion</strong> (<code>$expand=permissions</code>)</td>
          <td>5 RUs</td>
          <td>Heavy permission lookups</td>
      </tr>
  </tbody>
</table>
<blockquote>
<p><strong>Source:</strong> <a href="https://learn.microsoft.com/en-us/sharepoint/dev/general-development/how-to-avoid-getting-throttled-or-blocked-in-sharepoint-online#resource-units">Microsoft&rsquo;s official SharePoint throttling documentation</a></p>
</blockquote>
<p>Permission expansions are just one type of &ldquo;expensive&rdquo; call. Another common scenario that can impact RU consumption is <strong>making multiple separate queries instead of using CAML joins</strong> — something I explored in <a href="https://jeppe-spanggaard.dk/blogs/joining-multiple-lists-csom-caml/">my CAML join post</a>.<br>
While joining multiple lists in a single query is actually more efficient than separate calls, poorly structured queries or retrieving unnecessarily large datasets can still consume RUs quickly if not optimized properly.</p>
<hr>
<h3 id="why-this-sneaks-up-on-you">Why This Sneaks Up on You</h3>
<p>When coding locally, it’s easy to run a handful of queries without noticing.<br>
But in production, with real concurrency and volume, these calls pile up in seconds.</p>
<p>Imagine:</p>
<ul>
<li>10 parallel updates (2 RUs each) = <strong>20 RUs in one burst</strong></li>
<li>Add a few joins/expansions or large list queries, and you’re suddenly <em>burning through limits 5x faster</em>.</li>
</ul>
<hr>
<h3 id="how-devproxy-can-show-you-the-pain-before-production">How DevProxy Can Show You the Pain Before Production</h3>
<p>Here’s where DevProxy shines.<br>
If you configure it to simulate throttling based on these RU-heavy calls, you’ll <em>see</em> the impact locally:</p>
<ol>
<li><strong>Enable throttling plugins</strong> in your <code>devproxy.json</code> (as shown earlier).</li>
<li>Point <code>urlsToWatch</code> at your SharePoint endpoints.</li>
<li>Run your CRUD-heavy code.</li>
</ol>
<p>DevProxy will start throwing 429s (Too Many Requests) once your “fake” RU budget is exhausted — just like SharePoint would in production.</p>
<p>The beautiful part?<br>
You can crank the limits <em>down</em> during testing to make expensive patterns obvious. Even a single large query or unbatched <code>Update</code> will light up your logs.</p>
<p>Example DevProxy log when hitting a throttling simulation:</p>
<pre tabindex="0"><code>[Warning] Throttling triggered: 2 parallel Create calls exceeded RU limit (RateLimitingPlugin)
Retry after: 30 seconds
</code></pre><p><strong>Pro tip:</strong><br>
Use DevProxy as a <strong>budget meter</strong> for your API calls. Treat every 2-RU and 5-RU operation as “spending big” — and redesign those spots <em>before</em> they become a production outage.</p>
<h2 id="what-is-devproxy">What is DevProxy?</h2>
<p><a href="https://github.com/dotnet/dev-proxy">DevProxy</a> is an open-source HTTP/HTTPS proxy server that can simulate real-world network issues, including:</p>
<ul>
<li><a href="https://learn.microsoft.com/en-us/microsoft-cloud/dev/dev-proxy/concepts/what-is-rate-limiting"><strong>Rate limiting</strong></a></li>
<li><a href="https://learn.microsoft.com/en-us/microsoft-cloud/dev/dev-proxy/concepts/what-is-throttling"><strong>Throttling</strong></a></li>
<li><a href="https://learn.microsoft.com/en-us/microsoft-cloud/dev/dev-proxy/how-to/simulate-slow-api-responses"><strong>Network delays</strong></a></li>
<li><a href="https://learn.microsoft.com/en-us/microsoft-cloud/dev/dev-proxy/how-to/test-my-app-with-random-errors"><strong>Intermittent errors</strong></a></li>
</ul>
<p>It’s free, open source, and designed for developers who want to <strong>catch performance bottlenecks before they reach production</strong>.</p>
<hr>
<h2 id="installing-devproxy">Installing DevProxy</h2>
<p>Follow the official setup guide here:<br>
<a href="https://learn.microsoft.com/en-us/microsoft-cloud/dev/dev-proxy/get-started/set-up">DevProxy Installation Documentation</a></p>
<hr>
<h2 id="basic-usage">Basic Usage</h2>
<p>Run DevProxy with the default configuration:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>devproxy
</span></span></code></pre></div><p>It will begin intercepting all HTTP/HTTPS requests.</p>
<blockquote>
<p><strong>Tip:</strong> Start DevProxy <em>before</em> running your own code — otherwise, it won’t capture the traffic.</p>
</blockquote>
<hr>
<h2 id="simulating-microsoft-graph-throttling">Simulating Microsoft Graph Throttling</h2>
<p>Here’s how to configure DevProxy to reproduce Microsoft Graph and SharePoint throttling issues locally.</p>
<h3 id="1-create-a-devproxyjson-configuration-file">1. Create a <code>devproxy.json</code> configuration file</h3>
<p>This is the exact configuration I used when testing my problematic parallel code:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;$schema&#34;</span>: <span style="color:#e6db74">&#34;https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v0.27.0/rc.schema.json&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;rate&#34;</span>: <span style="color:#ae81ff">25</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;plugins&#34;</span>: [
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;RetryAfterPlugin&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;enabled&#34;</span>: <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;pluginPath&#34;</span>: <span style="color:#e6db74">&#34;~appFolder/plugins/dev-proxy-plugins.dll&#34;</span>
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;RateLimitingPlugin&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;enabled&#34;</span>: <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;pluginPath&#34;</span>: <span style="color:#e6db74">&#34;~appFolder/plugins/dev-proxy-plugins.dll&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;configSection&#34;</span>: <span style="color:#e6db74">&#34;rateLimitingPlugin&#34;</span>
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;GraphRandomErrorPlugin&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;enabled&#34;</span>: <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;pluginPath&#34;</span>: <span style="color:#e6db74">&#34;~appFolder/plugins/dev-proxy-plugins.dll&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;configSection&#34;</span>: <span style="color:#e6db74">&#34;graphRandomErrorPlugin&#34;</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>  ],
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;urlsToWatch&#34;</span>: [
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://graph.microsoft.com/v1.0/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://graph.microsoft.com/beta/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://graph.microsoft.us/v1.0/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://graph.microsoft.us/beta/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://dod-graph.microsoft.us/v1.0/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://dod-graph.microsoft.us/beta/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://microsoftgraph.chinacloudapi.cn/v1.0/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://microsoftgraph.chinacloudapi.cn/beta/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://*.sharepoint.*/*_api/web/GetClientSideComponents&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://*.sharepoint.*/*_api/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://*.sharepoint.*/*_vti_bin/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://*.sharepoint-df.*/*_api/*&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;https://*.sharepoint-df.*/*_vti_bin/*&#34;</span>
</span></span><span style="display:flex;"><span>  ],
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;graphRandomErrorPlugin&#34;</span>: {
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;$schema&#34;</span>: <span style="color:#e6db74">&#34;https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v0.27.0/graphrandomerrorplugin.schema.json&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;allowedErrors&#34;</span>: [
</span></span><span style="display:flex;"><span>      <span style="color:#ae81ff">429</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#ae81ff">503</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#ae81ff">504</span>
</span></span><span style="display:flex;"><span>    ]
</span></span><span style="display:flex;"><span>  },
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;rateLimitingPlugin&#34;</span>: {
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;$schema&#34;</span>: <span style="color:#e6db74">&#34;https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v0.27.0/ratelimitingplugin.schema.json&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;costPerRequest&#34;</span>: <span style="color:#ae81ff">2</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;rateLimit&#34;</span>: <span style="color:#ae81ff">120</span>
</span></span><span style="display:flex;"><span>  },
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;logLevel&#34;</span>: <span style="color:#e6db74">&#34;information&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;newVersionNotification&#34;</span>: <span style="color:#e6db74">&#34;stable&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;showSkipMessages&#34;</span>: <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;showTimestamps&#34;</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="2-start-devproxy-with-your-configuration">2. Start DevProxy with your configuration</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>devproxy --config-file devproxy.json
</span></span></code></pre></div><hr>
<h2 id="example-of-problematic-code">Example of Problematic Code</h2>
<p>Here’s the snippet that caused my throttling nightmare. It uses <code>Parallel.ForEachAsync</code> to query SharePoint in bulk:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// ❌ This will likely trigger throttling in production</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> customers = <span style="color:#66d9ef">new</span> ConcurrentBag&lt;CustomerDTO&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">await</span> Parallel.ForEachAsync(departmentIds, <span style="color:#66d9ef">async</span> (departmentId, token) =&gt; 
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> clonedContext = _clientContext.Clone(_clientContext.Url);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> query = Camlex.Query()
</span></span><span style="display:flex;"><span>        .ViewFields(<span style="color:#66d9ef">new</span> CustomerDTO().ViewFields().ToArray().Append(<span style="color:#e6db74">&#34;DepartmentId&#34;</span>))
</span></span><span style="display:flex;"><span>        .LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;DepartmentLookup&#34;</span>].ForeignList(DEPARTMENT_LIST_GUID))
</span></span><span style="display:flex;"><span>        .ProjectedField(x =&gt; x[<span style="color:#e6db74">&#34;DepartmentId&#34;</span>].List(DEPARTMENT_LIST_GUID).ShowField(<span style="color:#e6db74">&#34;ID&#34;</span>))
</span></span><span style="display:flex;"><span>        .Where(x =&gt; x[<span style="color:#e6db74">&#34;DepartmentId&#34;</span>] == departmentId);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> departmentCustomers = <span style="color:#66d9ef">await</span> SharePointService.GetItemsFromListByQuery&lt;CustomerDTO&gt;(
</span></span><span style="display:flex;"><span>        CUSTOMER_LIST_GUID,
</span></span><span style="display:flex;"><span>        clonedContext,
</span></span><span style="display:flex;"><span>        query);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (departmentCustomers?.Any() == <span style="color:#66d9ef">true</span>)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> customer <span style="color:#66d9ef">in</span> departmentCustomers)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            customers.Add(customer);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><hr>
<h2 id="why-this-code-fails-in-production">Why This Code Fails in Production</h2>
<ol>
<li><strong>No throttling safeguards</strong> — Multiple parallel requests overwhelm SharePoint.</li>
<li><strong>No retry logic</strong> — Requests fail instead of recovering.</li>
<li><strong>No request limiting</strong> — All departments are processed simultaneously.</li>
<li><strong>Silent failures</strong> — Errors are ignored without logging or fallback.</li>
</ol>
<hr>
<h2 id="the-better-way">The Better Way</h2>
<p>Instead of hard-coding fixes here, I recommend Bert Jansen’s detailed guide on throttling and rate limit handling:<br>
<a href="https://github.com/OneDrive/samples/blob/master/scenarios/throttling-ratelimit-handling/readme.md">Throttling &amp; Rate Limit Handling Patterns</a></p>
<p>Key principles:</p>
<ul>
<li><strong>Limit concurrency</strong> with <code>SemaphoreSlim</code>.</li>
<li><strong>Use exponential backoff</strong> for retries.</li>
<li><strong>Monitor rate limit headers</strong> to adjust requests dynamically.</li>
<li><strong>Handle errors explicitly</strong> to prevent silent failures.</li>
</ul>
<hr>
<h2 id="conclusion">Conclusion</h2>
<p>DevProxy has become an essential tool in my Microsoft 365 development workflow. It helps me:</p>
<ul>
<li>Catch throttling issues <strong>before</strong> production.</li>
<li>Test error handling and retry logic <strong>locally</strong>.</li>
<li>Deliver applications that can survive real-world API limits.</li>
</ul>
<p>If I’d used DevProxy from the start, I could have avoided the last-minute throttling meltdown entirely.</p>
<p>💡 <strong>Pro tip:</strong> Make DevProxy part of your <em>early</em> development process, not your emergency toolkit.</p>
<hr>
<h2 id="additional-resources">Additional Resources</h2>
<ul>
<li><a href="https://github.com/dotnet/dev-proxy">DevProxy GitHub Repository</a></li>
<li><a href="https://docs.microsoft.com/en-us/graph/throttling">Microsoft Graph Throttling Guidelines</a></li>
<li><a href="https://aka.ms/devproxy/docs">DevProxy Documentation</a></li>
</ul>
]]></content:encoded></item><item><title>Efficient Multi-List Queries in CSOM: Using CAML Joins with CAMLEX</title><link>https://jeppe-spanggaard.dk/blogs/joining-multiple-lists-csom-caml/</link><pubDate>Mon, 28 Jul 2025 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/joining-multiple-lists-csom-caml/</guid><description>Learn how to efficiently query multiple SharePoint lists using CAML joins instead of multiple API calls to avoid throttling</description><content:encoded><![CDATA[<h2 id="the-problem-multiple-list-queries-and-throttling">The Problem: Multiple List Queries and Throttling</h2>
<p>When working with related data across multiple SharePoint lists, developers often fall into the trap of making multiple individual queries:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// ❌ Bad approach - Multiple API calls</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> customers = context.Web.Lists.GetByTitle(<span style="color:#e6db74">&#34;Customers&#34;</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> orders = context.Web.Lists.GetByTitle(<span style="color:#e6db74">&#34;Orders&#34;</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> orderItems = context.Web.Lists.GetByTitle(<span style="color:#e6db74">&#34;OrderItems&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Each query consumes 2 resource units</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> customerItems = customers.GetItems(camlQuery1);  <span style="color:#75715e">// 2 units</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> orderItems = orders.GetItems(camlQuery2);        <span style="color:#75715e">// 2 units  </span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> itemDetails = orderItems.GetItems(camlQuery3);   <span style="color:#75715e">// 2 units</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Total: 6 resource units + processing overhead</span>
</span></span></code></pre></div><p>This approach has several issues:</p>
<ul>
<li><strong>Higher resource consumption</strong>: Each query consumes <a href="https://learn.microsoft.com/en-us/sharepoint/dev/general-development/how-to-avoid-getting-throttled-or-blocked-in-sharepoint-online">2 resource units</a> per multi-item request</li>
<li><strong>Increased throttling risk</strong>: More API calls mean hitting limits faster</li>
<li><strong>Network overhead</strong>: Multiple round trips to SharePoint</li>
<li><strong>Complex data merging</strong>: Manual joining of results in C#</li>
</ul>
<h2 id="how-to-fix-it">How to fix it?</h2>
<p>I have worked alot with MSSQL where it is pretty easy to just join a table on a table on a table. But I did not know it was possible in CSOM. Eg. in the UI of SharePoint you can only expand one table - NOT multiple. But one day I deep dived into CAML and joins, and found out it was possible!</p>
<p><strong>Resource Unit Comparison:</strong></p>
<p>Let&rsquo;s break down the actual cost difference. According to Microsoft&rsquo;s <a href="https://learn.microsoft.com/en-us/sharepoint/dev/general-development/how-to-avoid-getting-throttled-or-blocked-in-sharepoint-online">throttling documentation</a>, each multi-item query consumes <strong>2 resource units</strong>.</p>
<p><strong>Multiple separate queries (❌ Bad approach):</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// Query 1: Get order items from main list</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> orderItems = ordersList.GetItems(query1);        <span style="color:#75715e">// 2 resource units</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Query 2: Get customer details  </span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> customers = customersList.GetItems(query2);      <span style="color:#75715e">// 2 resource units</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Query 3: Get order details</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> orderDetails = orderDetailsList.GetItems(query3); <span style="color:#75715e">// 2 resource units</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Total: 6 resource units + network overhead + manual C# joining</span>
</span></span></code></pre></div><p><strong>Single join query (✅ Good approach):</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// One query with joins gets ALL the data</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> items = ordersList.GetItems(joinQuery);          <span style="color:#75715e">// 2 resource units total</span>
</span></span></code></pre></div><p><strong>The math:</strong></p>
<ul>
<li>Multiple queries: <strong>2 units × 3 lists = 6 units</strong></li>
<li>Single join query: <strong>2 units total</strong></li>
<li><strong>Savings: 66% fewer resource units!</strong></li>
</ul>
<p>This becomes even more significant when you consider that SharePoint throttling limits are measured in resource units per time window. With joins, you can query 3x more data within the same throttling limits.</p>
<p><img src="https://jeppe-spanggaard.dk/images/CamlJoin.png" alt="alt text"></p>
<h2 id="the-solution-caml-joins">The Solution: CAML Joins</h2>
<p>CAML actually supports joining multiple lists in a single query! This means you can get data from related lists without multiple API calls.</p>
<p><strong>First, install CAMLEX via NuGet:</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-powershell" data-lang="powershell"><span style="display:flex;"><span>Install-Package Camlex.Client.dll
</span></span></code></pre></div><p><strong>Then build your join query:</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> list = context.Web.Lists.GetByTitle(<span style="color:#e6db74">&#34;YourMainList&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>CamlexNET.Interfaces.IQuery query = Camlex.Query();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>query = query
</span></span><span style="display:flex;"><span>.LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;OrderTaskLookUp&#34;</span>].ForeignList(ListGuidOrderTasks))
</span></span><span style="display:flex;"><span>.LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;OrderDetailLookUp&#34;</span>].PrimaryList(ListGuidOrderTasks).ForeignList(ListGuidOrders))
</span></span><span style="display:flex;"><span>.LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;CustomerLookUp&#34;</span>].PrimaryList(ListGuidOrders).ForeignList(ListGuidCustomers))
</span></span><span style="display:flex;"><span>.ProjectedField(x =&gt; x[<span style="color:#e6db74">&#34;CustomerNo&#34;</span>].List(ListGuidCustomers).ShowField(<span style="color:#e6db74">&#34;CustomerNo&#34;</span>))
</span></span><span style="display:flex;"><span>.ProjectedField(x =&gt; x[<span style="color:#e6db74">&#34;CustomerName&#34;</span>].List(ListGuidCustomers).ShowField(<span style="color:#e6db74">&#34;CustomerName&#34;</span>));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> camlQuery = <span style="color:#66d9ef">new</span> CamlQuery();
</span></span><span style="display:flex;"><span>camlQuery.ViewXml = query.ToString();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> items = list.GetItems(camlQuery);
</span></span><span style="display:flex;"><span>context.Load(items);
</span></span><span style="display:flex;"><span>context.ExecuteQuery();
</span></span></code></pre></div><p><strong>Important:</strong> Your lists need to be connected via lookup columns for joins to work!</p>
<h2 id="why-camlex-instead-of-raw-caml">Why CAMLEX Instead of Raw CAML?</h2>
<p>I don&rsquo;t write raw CAML myself - it&rsquo;s verbose and error-prone. Instead, I use <a href="https://github.com/sadomovalex/camlex">CAMLEX</a> because:</p>
<ul>
<li><strong>Cleaner syntax</strong>: C# lambda expressions instead of XML</li>
<li><strong>IntelliSense support</strong>: Catch errors at compile time</li>
<li><strong>Easier for the next developer</strong>: Self-documenting code</li>
<li><strong>Less mistakes</strong>: No more XML typos or missing tags</li>
</ul>
<h2 id="what-else">What else?</h2>
<p>One thing is to join multiple lists, but to filter on a value 4 lists away - That is neat!</p>
<p>For example, filtering orders by the customer&rsquo;s name, which is 2 lists away:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span>CamlexNET.Interfaces.IQuery query = Camlex.Query();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>query = query
</span></span><span style="display:flex;"><span>.Where(x =&gt; (<span style="color:#66d9ef">string</span>)x[<span style="color:#e6db74">&#34;CustomerName&#34;</span>] == <span style="color:#e6db74">&#34;Contoso&#34;</span>) <span style="color:#75715e">// &lt;---- Filtering on join field</span>
</span></span><span style="display:flex;"><span>.LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;OrderTaskLookUp&#34;</span>].ForeignList(ListGuidOrderTasks))
</span></span><span style="display:flex;"><span>.LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;OrderDetailLookUp&#34;</span>].PrimaryList(ListGuidOrderTasks).ForeignList(ListGuidOrders))
</span></span><span style="display:flex;"><span>.LeftJoin(x =&gt; x[<span style="color:#e6db74">&#34;CustomerLookUp&#34;</span>].PrimaryList(ListGuidOrders).ForeignList(ListGuidCustomers))
</span></span><span style="display:flex;"><span>.ProjectedField(x =&gt; x[<span style="color:#e6db74">&#34;CustomerNo&#34;</span>].List(ListGuidCustomers).ShowField(<span style="color:#e6db74">&#34;CustomerNo&#34;</span>))
</span></span><span style="display:flex;"><span>.ProjectedField(x =&gt; x[<span style="color:#e6db74">&#34;CustomerName&#34;</span>].List(ListGuidCustomers).ShowField(<span style="color:#e6db74">&#34;CustomerName&#34;</span>));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> camlQuery = <span style="color:#66d9ef">new</span> CamlQuery();
</span></span><span style="display:flex;"><span>camlQuery.ViewXml = query.ToString();
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> items = ordersList.GetItems(camlQuery);
</span></span></code></pre></div><p>This CAMLEX query automatically generates the following CAML XML - notice how complex the raw XML is compared to the clean C# syntax above:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span><span style="color:#f92672">&lt;View&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;Query&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;Where&gt;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&lt;Geq&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;FieldRef</span> <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;customerName&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;Value</span> <span style="color:#a6e22e">Type=</span><span style="color:#e6db74">&#34;Text&#34;</span><span style="color:#f92672">&gt;</span>Contoso<span style="color:#f92672">&lt;/Value&gt;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&lt;/Geq&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;/Where&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;/Query&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;ViewFields&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;FieldRef</span> <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;Id&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;FieldRef</span> <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;customerNo&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;FieldRef</span> <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;customerName&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;/ViewFields&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;Joins&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;Join</span> <span style="color:#a6e22e">Type=</span><span style="color:#e6db74">&#34;LEFT&#34;</span> <span style="color:#a6e22e">ListAlias=</span><span style="color:#e6db74">&#34;0f0cfc71-1c6e-4fd4-b6f2-279d0e3862f4&#34;</span><span style="color:#f92672">&gt;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&lt;Eq&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;FieldRef</span> <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;OrderTaskLookUp&#34;</span> <span style="color:#a6e22e">RefType=</span><span style="color:#e6db74">&#34;Id&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;FieldRef</span> <span style="color:#a6e22e">List=</span><span style="color:#e6db74">&#34;0f0cfc71-1c6e-4fd4-b6f2-279d0e3862f4&#34;</span> <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;Id&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&lt;/Eq&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;/Join&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;Join</span> <span style="color:#a6e22e">Type=</span><span style="color:#e6db74">&#34;LEFT&#34;</span> <span style="color:#a6e22e">ListAlias=</span><span style="color:#e6db74">&#34;ce57b9a2-5052-482c-a8c8-150c7d59ced3&#34;</span><span style="color:#f92672">&gt;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&lt;Eq&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;FieldRef</span> <span style="color:#a6e22e">List=</span><span style="color:#e6db74">&#34;0f0cfc71-1c6e-4fd4-b6f2-279d0e3862f4&#34;</span> <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;OrderDetailLookUp&#34;</span> <span style="color:#a6e22e">RefType=</span><span style="color:#e6db74">&#34;Id&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;FieldRef</span> <span style="color:#a6e22e">List=</span><span style="color:#e6db74">&#34;ce57b9a2-5052-482c-a8c8-150c7d59ced3&#34;</span> <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;Id&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&lt;/Eq&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;/Join&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;Join</span> <span style="color:#a6e22e">Type=</span><span style="color:#e6db74">&#34;LEFT&#34;</span> <span style="color:#a6e22e">ListAlias=</span><span style="color:#e6db74">&#34;e204ed0f-7c25-432f-9228-3eb438c527e2&#34;</span><span style="color:#f92672">&gt;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&lt;Eq&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;FieldRef</span> <span style="color:#a6e22e">List=</span><span style="color:#e6db74">&#34;ce57b9a2-5052-482c-a8c8-150c7d59ced3&#34;</span> <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;CustomerLookUp&#34;</span> <span style="color:#a6e22e">RefType=</span><span style="color:#e6db74">&#34;Id&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;FieldRef</span> <span style="color:#a6e22e">List=</span><span style="color:#e6db74">&#34;e204ed0f-7c25-432f-9228-3eb438c527e2&#34;</span> <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;Id&#34;</span> <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&lt;/Eq&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;/Join&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;/Joins&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;ProjectedFields&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;Field</span> 
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;customerNo&#34;</span> 
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">Type=</span><span style="color:#e6db74">&#34;Lookup&#34;</span> 
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">List=</span><span style="color:#e6db74">&#34;e204ed0f-7c25-432f-9228-3eb438c527e2&#34;</span> 
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">ShowField=</span><span style="color:#e6db74">&#34;customerNo&#34;</span> 
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;Field</span> 
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">Name=</span><span style="color:#e6db74">&#34;customerName&#34;</span> 
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">Type=</span><span style="color:#e6db74">&#34;Lookup&#34;</span> 
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">List=</span><span style="color:#e6db74">&#34;e204ed0f-7c25-432f-9228-3eb438c527e2&#34;</span> 
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">ShowField=</span><span style="color:#e6db74">&#34;customerName&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&lt;/ProjectedFields&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/View&gt;</span>
</span></span></code></pre></div><p>Imagine having to write and maintain that XML manually! This is exactly why CAMLEX is so valuable - you get all the power of CAML joins with readable C# syntax.</p>
<h2 id="performance-benefits">Performance Benefits</h2>
<p><strong>Before (Multiple queries):</strong></p>
<ul>
<li>🔴 6+ resource units</li>
<li>🔴 Multiple network calls</li>
<li>🔴 Complex C# merging logic</li>
</ul>
<p><strong>After (Single join):</strong></p>
<ul>
<li>✅ 2 resource units only</li>
<li>✅ One network call</li>
<li>✅ Server-side joining</li>
</ul>
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li><strong>Use joins instead of multiple queries</strong> to reduce resource consumption</li>
<li><strong>CAMLEX makes CAML readable</strong> for you and the next developer</li>
<li><strong>You can filter on joined data</strong> even multiple lists away</li>
<li><strong>SharePoint UI limitations ≠ API limitations</strong> - joins work even if the UI doesn&rsquo;t show it</li>
</ul>
<h2 id="common-issues--solutions">Common Issues &amp; Solutions</h2>
<p><strong>❌ &ldquo;List does not exist&rdquo; error</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// Use list GUIDs instead of names for reliability</span>
</span></span><span style="display:flex;"><span>.ForeignList(<span style="color:#66d9ef">new</span> Guid(<span style="color:#e6db74">&#34;12345678-1234-1234-1234-123456789012&#34;</span>))
</span></span></code></pre></div><p><strong>❌ &ldquo;Field not found&rdquo; error</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// Use internal field names, not display names</span>
</span></span><span style="display:flex;"><span>.ShowField(<span style="color:#e6db74">&#34;Title&#34;</span>)        <span style="color:#75715e">// ✅ Internal name</span>
</span></span><span style="display:flex;"><span>.ShowField(<span style="color:#e6db74">&#34;Customer Name&#34;</span>) <span style="color:#75715e">// ❌ Display name</span>
</span></span></code></pre></div><p><strong>❌ Join returns no data</strong></p>
<ul>
<li>Verify lookup columns exist and are properly configured</li>
<li>Check that you&rsquo;re joining on the correct fields</li>
<li>Ensure the lookup field contains valid IDs</li>
</ul>
<p><strong>❌ &ldquo;Cannot project this field type&rdquo; error</strong></p>
<p>Only specific field types can be included in ProjectedFields:</p>
<p>✅ Supported ProjectedFields types:</p>
<ul>
<li>Calculated (treated as plain text)</li>
<li>ContentTypeId</li>
<li>Counter</li>
<li>Currency</li>
<li>DateTime</li>
<li>Guid</li>
<li>Integer</li>
<li>Note (one-line only)</li>
<li>Number</li>
<li>Text</li>
</ul>
<p>❌ NOT supported in ProjectedFields:</p>
<ul>
<li>Multi-line text fields</li>
<li>Rich text fields</li>
<li>Choice fields</li>
<li>Lookup fields (use joins instead)</li>
<li>User/Person fields</li>
<li>Managed metadata fields</li>
</ul>
<p><strong>💡 Pro tip:</strong> If you need data from unsupported field types, query them separately after getting your joined results.</p>
<h2 id="tldr">TL;DR</h2>
<ul>
<li><strong>Problem</strong>: Multiple SharePoint list queries consume 6+ resource units and risk throttling</li>
<li><strong>Solution</strong>: Use CAML joins to get all data in one query (only 2 resource units)</li>
<li><strong>Tool</strong>: CAMLEX makes CAML joins readable with C# lambda expressions</li>
<li><strong>Benefit</strong>: 66% fewer resource units + no manual data merging</li>
<li><strong>Surprise</strong>: You can filter on data 4 lists away in a single query!</li>
</ul>
<h2 id="references">References</h2>
<ul>
<li><a href="https://github.com/sadomovalex/camlex">CAMLEX GitHub Repository</a></li>
<li><a href="https://learn.microsoft.com/en-us/sharepoint/dev/general-development/how-to-avoid-getting-throttled-or-blocked-in-sharepoint-online">SharePoint Throttling Guidance</a></li>
<li><a href="http://camlex.ru.net">CAMLEX Online Converter</a></li>
<li><a href="https://docs.microsoft.com/en-us/sharepoint/dev/schema/query-schema">CAML Query Schema Reference</a></li>
<li><a href="https://learn.microsoft.com/en-us/previous-versions/office/developer/sharepoint-2010/ee539975(v=office.14)">ProjectedFields Element (Microsoft)</a></li>
</ul>
]]></content:encoded></item><item><title>Microsoft Graph SDK Authentication in C#: Quick Start Guide</title><link>https://jeppe-spanggaard.dk/blogs/graph-sdk-authentication-csharp/</link><pubDate>Fri, 20 Jun 2025 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/graph-sdk-authentication-csharp/</guid><description>Quick setup guide for Microsoft Graph SDK using your existing app registration</description><content:encoded><![CDATA[<h2 id="what-is-microsoft-graph">What is Microsoft Graph?</h2>
<p>Microsoft Graph is the unified REST API for Microsoft 365. Think of it as a single gateway to access data across SharePoint, Teams, OneDrive, Outlook, Azure AD, and more - all through one consistent API.</p>
<p><strong>Graph vs CSOM/PnP.Framework:</strong></p>
<ul>
<li><strong>Graph</strong>: Modern REST API, works across all Microsoft 365 services</li>
<li><strong>CSOM/PnP</strong>: SharePoint-specific, more SharePoint features available</li>
</ul>
<h2 id="when-to-use-graph-sdk-vs-pnpframework">When to Use Graph SDK vs PnP.Framework</h2>
<p><strong>✅ Use Graph SDK when:</strong></p>
<ul>
<li>Building modern cloud applications</li>
<li>Need access to Teams, OneDrive, Exchange, or Azure AD</li>
<li>Want built-in retry logic, batching, and performance optimizations</li>
<li>Building apps that integrate multiple Microsoft 365 services</li>
</ul>
<p><strong>✅ Use PnP.Framework when:</strong></p>
<ul>
<li>Need advanced SharePoint features (site templates, provisioning, taxonomy)</li>
<li>Working with SharePoint-specific APIs not yet in Graph</li>
<li>Building SharePoint-focused solutions</li>
<li>Need features like search refiners, managed metadata, or custom actions</li>
</ul>
<p><strong>❌ Graph SDK limitations:</strong></p>
<ul>
<li>No support for SharePoint classic features (master pages, web parts)</li>
<li>Limited search capabilities compared to SharePoint Search API</li>
<li>Some advanced list/library features not available</li>
<li>Can&rsquo;t access SharePoint on-premises (SharePoint Server)</li>
</ul>
<h2 id="quick-start">Quick Start</h2>
<p>Building on the <a href="https://jeppe-spanggaard.dk/pnp-framework-authentication-csharp/">PnP.Framework authentication setup</a>, let&rsquo;s add Microsoft Graph SDK to access all Microsoft 365 services with the same certificate.</p>
<h2 id="add-graph-permissions">Add Graph Permissions</h2>
<p>In your existing app registration, go to &ldquo;API permissions&rdquo; → &ldquo;Add a permission&rdquo; → &ldquo;Microsoft Graph&rdquo; → &ldquo;Application permissions&rdquo;:</p>
<ul>
<li><strong>Sites.ReadWrite.All</strong>: SharePoint sites and lists access</li>
</ul>
<p><strong>Important</strong>: Click &ldquo;Grant admin consent&rdquo; after adding permissions!</p>
<h2 id="install-and-code">Install and Code</h2>
<p>Install the NuGet package:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>Install-Package Microsoft.Graph
</span></span></code></pre></div><p>Here&rsquo;s the same example from the PnP.Framework post, but using Graph SDK:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">using</span> Microsoft.Graph;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">using</span> Microsoft.Graph.Auth;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">using</span> System.Security.Cryptography.X509Certificates;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Program</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">readonly</span> <span style="color:#66d9ef">string</span> TenantId = <span style="color:#e6db74">&#34;your-tenant-id&#34;</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">readonly</span> <span style="color:#66d9ef">string</span> ClientId = <span style="color:#e6db74">&#34;your-client-id&#34;</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">readonly</span> <span style="color:#66d9ef">string</span> CertificatePath = <span style="color:#e6db74">@&#34;C:\Temp\cert\pnpappcert.pfx&#34;</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">readonly</span> <span style="color:#66d9ef">string</span> CertificatePassword = <span style="color:#e6db74">&#34;MySuperStrongPassword!&#34;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">async</span> Task Main(<span style="color:#66d9ef">string</span>[] args)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Create Graph client (same certificate as PnP.Framework)</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> certificate = <span style="color:#66d9ef">new</span> X509Certificate2(CertificatePath, CertificatePassword);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> options = <span style="color:#66d9ef">new</span> ClientCertificateCredentialOptions
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            AuthorityHost = AzureAuthorityHosts.AzurePublicCloud,
</span></span><span style="display:flex;"><span>        };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> clientCertCredential = <span style="color:#66d9ef">new</span> ClientCertificateCredential(
</span></span><span style="display:flex;"><span>            TenantId, ClientId, certificate, options);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> scopes = <span style="color:#66d9ef">new</span>[] { <span style="color:#e6db74">&#34;https://graph.microsoft.com/.default&#34;</span> };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> graphClient = <span style="color:#66d9ef">new</span> GraphServiceClient(clientCertCredential, scopes);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Test connection</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> org = <span style="color:#66d9ef">await</span> graphClient.Organization.Request().GetAsync();
</span></span><span style="display:flex;"><span>        Console.WriteLine(<span style="color:#e6db74">$&#34;Connected to: {org.First().DisplayName}&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Get all lists (like PnP.Framework example)</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> site = <span style="color:#66d9ef">await</span> graphClient.Sites[<span style="color:#e6db74">&#34;root&#34;</span>].Request().GetAsync();
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> lists = <span style="color:#66d9ef">await</span> graphClient.Sites[site.Id].Lists.Request().GetAsync();
</span></span><span style="display:flex;"><span>        
</span></span><span style="display:flex;"><span>        Console.WriteLine(<span style="color:#e6db74">&#34;Lists in this site:&#34;</span>);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> list <span style="color:#66d9ef">in</span> lists)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            Console.WriteLine(<span style="color:#e6db74">$&#34;- {list.DisplayName}&#34;</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Upload a document (like PnP.Framework example)</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> docLib = lists.FirstOrDefault(l =&gt; l.DisplayName == <span style="color:#e6db74">&#34;Documents&#34;</span>);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (docLib != <span style="color:#66d9ef">null</span>)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> content = System.Text.Encoding.UTF8.GetBytes(<span style="color:#e6db74">&#34;Hello from Graph SDK!&#34;</span>);
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> drive = <span style="color:#66d9ef">await</span> graphClient.Sites[site.Id].Lists[docLib.Id].Drive.Request().GetAsync();
</span></span><span style="display:flex;"><span>            
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> file = <span style="color:#66d9ef">await</span> graphClient.Drives[drive.Id].Root
</span></span><span style="display:flex;"><span>                .ItemWithPath(<span style="color:#e6db74">&#34;sample-document.txt&#34;</span>)
</span></span><span style="display:flex;"><span>                .Content
</span></span><span style="display:flex;"><span>                .Request()
</span></span><span style="display:flex;"><span>                .PutAsync&lt;DriveItem&gt;(<span style="color:#66d9ef">new</span> MemoryStream(content));
</span></span><span style="display:flex;"><span>            
</span></span><span style="display:flex;"><span>            Console.WriteLine(<span style="color:#e6db74">$&#34;Uploaded file: {file.Name}&#34;</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="key-differences-from-pnpframework">Key Differences from PnP.Framework</h2>
<p><strong>Same operations, different syntax:</strong></p>
<table>
  <thead>
      <tr>
          <th>Operation</th>
          <th>PnP.Framework</th>
          <th>Graph SDK</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Get Lists</strong></td>
          <td><code>context.Web.Lists</code></td>
          <td><code>graphClient.Sites[id].Lists</code></td>
      </tr>
      <tr>
          <td><strong>Upload File</strong></td>
          <td><code>docLib.RootFolder.Files.Add()</code></td>
          <td><code>drive.Root.ItemWithPath().Content.Put()</code></td>
      </tr>
      <tr>
          <td><strong>Authentication</strong></td>
          <td><code>AuthenticationManager</code></td>
          <td><code>ClientCertificateProvider</code></td>
      </tr>
  </tbody>
</table>
<p><strong>Graph advantages:</strong> Access to Teams, OneDrive, Exchange data with same client<br>
<strong>PnP advantages:</strong> More SharePoint-specific features like content types, site columns</p>
<h2 id="troubleshooting">Troubleshooting</h2>
<ul>
<li><strong>&ldquo;Insufficient privileges&rdquo;</strong>: Check permissions and admin consent</li>
<li><strong>&ldquo;Certificate not found&rdquo;</strong>: Verify certificate path and password</li>
<li><strong>&ldquo;Unauthorized&rdquo;</strong>: Ensure app has correct Graph permissions</li>
</ul>
<h2 id="next-steps">Next Steps</h2>
<p>You now have Graph SDK working with your existing app registration! With the basic setup, you can:</p>
<ul>
<li>Access SharePoint sites and lists</li>
<li>Upload and manage files in document libraries</li>
<li>Get organization information</li>
</ul>
<p>To access more Microsoft 365 services, add the appropriate permissions:</p>
<ul>
<li><strong>User.Read.All</strong> for user data</li>
<li><strong>Group.Read.All</strong> for Teams and groups</li>
<li><strong>Files.ReadWrite.All</strong> for broader file operations</li>
<li><strong>Mail.Read</strong> for Exchange data</li>
</ul>
<p>Perfect foundation for building comprehensive Microsoft 365 integrations.</p>
<h2 id="references">References</h2>
<ul>
<li><a href="https://docs.microsoft.com/en-us/graph/sdks/sdk-installation">Microsoft Graph SDK Documentation</a></li>
<li><a href="https://developer.microsoft.com/en-us/graph/graph-explorer">Graph Explorer for Testing</a></li>
<li><a href="https://jeppe-spanggaard.dk/pnp-framework-authentication-csharp/">PnP.Framework Setup</a> (Previous post)</li>
</ul>
]]></content:encoded></item><item><title>PnP.Framework Authentication in C#: A Beginner's Guide</title><link>https://jeppe-spanggaard.dk/blogs/pnp-framework-authentication-csharp/</link><pubDate>Tue, 03 Jun 2025 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/pnp-framework-authentication-csharp/</guid><description>Learn to authenticate C# applications with PnP.Framework and Azure using certificates in this beginner's guide to app registration and permissions.</description><content:encoded><![CDATA[<h2 id="app-registration">App registration</h2>
<p>Go to <a href="https://portal.azure.com">https://portal.azure.com</a> and search for &ldquo;App registrations&rdquo;:
<img src="https://jeppe-spanggaard.dk/images/AzurePortalSearchAppReg.png" alt="alt text"></p>
<p>Click &ldquo;New registration&rdquo; and fill out the name, and leave the &ldquo;Supported account types&rdquo; to default for now (Default: Accounts in this organizational directory only (xxx - Single tenant))</p>
<p>And now just press &ldquo;Register&rdquo;</p>
<p>Just like so, you have created your app registration! Take note of the <strong>Application (client) ID</strong> and <strong>Directory (tenant) ID</strong> from the overview page - you&rsquo;ll need these values later in your C# code.</p>
<p><img src="https://jeppe-spanggaard.dk/images/AppRegOverviewPage.png" alt="App Registration Overview Page"></p>
<h3 id="generate-certificate">Generate certificate</h3>
<p>We need a certificate on the app registration, and in our code to authenticate our C# application. Certificates are more secure than client secrets because they&rsquo;re harder to extract and can be managed through your organization&rsquo;s PKI infrastructure.</p>
<p>Open PowerShell as Administrator and run the following commands:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-PowerShell" data-lang="PowerShell"><span style="display:flex;"><span>$cert = New-SelfSignedCertificate `
</span></span><span style="display:flex;"><span>    -Subject <span style="color:#e6db74">&#34;CN=MyPnPAppCert&#34;</span> `
</span></span><span style="display:flex;"><span>    -CertStoreLocation <span style="color:#e6db74">&#34;cert:\CurrentUser\My&#34;</span> `
</span></span><span style="display:flex;"><span>    -KeyExportPolicy Exportable `
</span></span><span style="display:flex;"><span>    -KeySpec Signature `
</span></span><span style="display:flex;"><span>    -KeyLength <span style="color:#ae81ff">2048</span> `
</span></span><span style="display:flex;"><span>    -NotAfter (Get-Date).AddYears(<span style="color:#ae81ff">3</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$plainText = <span style="color:#e6db74">&#34;MySuperStrongPassword!&#34;</span>
</span></span><span style="display:flex;"><span>$secureString = ConvertTo-SecureString $plainText -AsPlainText -Force
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Export-PfxCertificate `
</span></span><span style="display:flex;"><span>    -Cert $cert `
</span></span><span style="display:flex;"><span>    -FilePath <span style="color:#e6db74">&#34;C:\Temp\cert\pnpappcert.pfx&#34;</span> `
</span></span><span style="display:flex;"><span>    -Password $secureString
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Export-Certificate `
</span></span><span style="display:flex;"><span>    -Cert $cert `
</span></span><span style="display:flex;"><span>    -FilePath <span style="color:#e6db74">&#34;C:\Temp\cert\pnpappcert.cer&#34;</span>        
</span></span></code></pre></div><p>This script creates a self-signed certificate that&rsquo;s valid for 3 years. The certificate gets stored in your personal certificate store, and we export both the .pfx file (containing the private key) and the .cer file (public key only). Make sure the <code>C:\Temp\cert\</code> directory exists before running this script.</p>
<h3 id="upload-certificate">Upload certificate</h3>
<p>We need to upload the generated certificate - the .cer file (NOT the .pfx), to the app registration. The .cer file contains only the public key, while the .pfx contains the private key that should never be shared.</p>
<p>Navigate to your app registration in Azure Portal, go to &ldquo;Certificates &amp; secrets&rdquo;, then click &ldquo;Upload certificate&rdquo;:
<img src="https://jeppe-spanggaard.dk/images/AppRegCertUpload.png" alt="alt text"></p>
<p>After uploading, you&rsquo;ll see the certificate listed with its thumbprint. The thumbprint can be useful for identifying which certificate to use if you have multiple certificates.</p>
<h3 id="add-permissions">Add permissions</h3>
<p>Our app registration needs permission to be able to do stuff. There are two types of permissions: Delegated and Application.
The differences between them are:</p>
<ul>
<li><strong>Delegated permissions</strong>: Your app acts on behalf of a signed-in user. The app can only access what the user has access to.</li>
<li><strong>Application permissions</strong>: Your app runs as itself without a signed-in user. The app has access based on what you grant it.</li>
</ul>
<p>For this example we&rsquo;ll use the SharePoint permission &ldquo;Sites.FullControl.All&rdquo; as an application permission, which gives our app full access to all SharePoint sites.</p>
<p>After adding the permission, don&rsquo;t forget to click &ldquo;Grant admin consent&rdquo; - this is crucial! Without admin consent, your app won&rsquo;t be able to use the permissions even if they&rsquo;re assigned.</p>
<p>Now our app registration is setup and ready to be used in our C# code.</p>
<h2 id="c-implementation">C# Implementation</h2>
<p>Now let&rsquo;s write the C# code to authenticate and connect to SharePoint using our certificate. I&rsquo;ll show you a complete working example that you can use as a starting point for your own projects.</p>
<p>First, create a new Console Application in Visual Studio and install the PnP.Framework NuGet package:</p>
<ul>
<li><a href="https://www.nuget.org/packages/PnP.Framework/1.18.0">https://www.nuget.org/packages/PnP.Framework/1.18.0</a></li>
</ul>
<p>You can install it via Package Manager Console: <code>Install-Package PnP.Framework</code></p>
<p>Here&rsquo;s a complete example that connects to SharePoint and demonstrates several common operations:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">using</span> PnP.Framework;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">using</span> System.Security.Cryptography.X509Certificates;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Program</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">readonly</span> <span style="color:#66d9ef">string</span> TenantId = <span style="color:#e6db74">&#34;your-tenant-id&#34;</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">readonly</span> <span style="color:#66d9ef">string</span> ClientId = <span style="color:#e6db74">&#34;your-client-id&#34;</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">readonly</span> <span style="color:#66d9ef">string</span> CertificatePath = <span style="color:#e6db74">@&#34;C:\Temp\cert\pnpappcert.pfx&#34;</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">readonly</span> <span style="color:#66d9ef">string</span> CertificatePassword = <span style="color:#e6db74">&#34;MySuperStrongPassword!&#34;</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">readonly</span> <span style="color:#66d9ef">string</span> SiteUrl = <span style="color:#e6db74">&#34;https://yourtenant.sharepoint.com/sites/yoursite&#34;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">async</span> Task Main(<span style="color:#66d9ef">string</span>[] args)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">try</span>
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Load the certificate</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> certificate = <span style="color:#66d9ef">new</span> X509Certificate2(CertificatePath, CertificatePassword);
</span></span><span style="display:flex;"><span>            
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Create authentication manager</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> authManager = <span style="color:#66d9ef">new</span> AuthenticationManager(ClientId, certificate, TenantId);
</span></span><span style="display:flex;"><span>            
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Get context</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">using</span> var context = authManager.GetContext(SiteUrl);
</span></span><span style="display:flex;"><span>            
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Load web properties</span>
</span></span><span style="display:flex;"><span>            context.Load(context.Web, w =&gt; w.Title, w =&gt; w.Url);
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">await</span> context.ExecuteQueryAsync();
</span></span><span style="display:flex;"><span>            
</span></span><span style="display:flex;"><span>            Console.WriteLine(<span style="color:#e6db74">$&#34;Connected to: {context.Web.Title}&#34;</span>);
</span></span><span style="display:flex;"><span>            Console.WriteLine(<span style="color:#e6db74">$&#34;Site URL: {context.Web.Url}&#34;</span>);
</span></span><span style="display:flex;"><span>            
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Example: Get all lists</span>
</span></span><span style="display:flex;"><span>            context.Load(context.Web.Lists, lists =&gt; lists.Include(l =&gt; l.Title, l =&gt; l.ItemCount));
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">await</span> context.ExecuteQueryAsync();
</span></span><span style="display:flex;"><span>            
</span></span><span style="display:flex;"><span>            Console.WriteLine(<span style="color:#e6db74">&#34;\nLists in this site:&#34;</span>);
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> list <span style="color:#66d9ef">in</span> context.Web.Lists)
</span></span><span style="display:flex;"><span>            {
</span></span><span style="display:flex;"><span>                Console.WriteLine(<span style="color:#e6db74">$&#34;- {list.Title} ({list.ItemCount} items)&#34;</span>);
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Example: Create a new list</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">await</span> CreateSampleList(context);
</span></span><span style="display:flex;"><span>            
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Example: Upload a document</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">await</span> UploadDocument(context);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">catch</span> (Exception ex)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            Console.WriteLine(<span style="color:#e6db74">$&#34;Error: {ex.Message}&#34;</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">async</span> Task CreateSampleList(ClientContext context)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">try</span>
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> listCreationInfo = <span style="color:#66d9ef">new</span> ListCreationInformation
</span></span><span style="display:flex;"><span>            {
</span></span><span style="display:flex;"><span>                Title = <span style="color:#e6db74">&#34;Sample List&#34;</span>,
</span></span><span style="display:flex;"><span>                TemplateType = (<span style="color:#66d9ef">int</span>)ListTemplateType.GenericList
</span></span><span style="display:flex;"><span>            };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> list = context.Web.Lists.Add(listCreationInfo);
</span></span><span style="display:flex;"><span>            context.Load(list);
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">await</span> context.ExecuteQueryAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            Console.WriteLine(<span style="color:#e6db74">$&#34;\nCreated list: {list.Title}&#34;</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">catch</span> (Exception ex)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            Console.WriteLine(<span style="color:#e6db74">$&#34;List creation error: {ex.Message}&#34;</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">async</span> Task UploadDocument(ClientContext context)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">try</span>
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> docLib = context.Web.Lists.GetByTitle(<span style="color:#e6db74">&#34;Documents&#34;</span>);
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> fileCreationInfo = <span style="color:#66d9ef">new</span> FileCreationInformation
</span></span><span style="display:flex;"><span>            {
</span></span><span style="display:flex;"><span>                Content = System.Text.Encoding.UTF8.GetBytes(<span style="color:#e6db74">&#34;Hello from PnP Framework!&#34;</span>),
</span></span><span style="display:flex;"><span>                Url = <span style="color:#e6db74">&#34;sample-document.txt&#34;</span>,
</span></span><span style="display:flex;"><span>                Overwrite = <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>            };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> uploadFile = docLib.RootFolder.Files.Add(fileCreationInfo);
</span></span><span style="display:flex;"><span>            context.Load(uploadFile);
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">await</span> context.ExecuteQueryAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            Console.WriteLine(<span style="color:#e6db74">$&#34;\nUploaded file: {uploadFile.Name}&#34;</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">catch</span> (Exception ex)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            Console.WriteLine(<span style="color:#e6db74">$&#34;File upload error: {ex.Message}&#34;</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="understanding-the-code">Understanding the Code</h3>
<p>Let&rsquo;s break down what this code does:</p>
<ol>
<li><strong>Certificate Loading</strong>: We load the .pfx file with the password we set earlier</li>
<li><strong>Authentication Manager</strong>: This handles the OAuth flow using the certificate</li>
<li><strong>Context Creation</strong>: Gets a <code>ClientContext</code> object that represents our connection to SharePoint</li>
<li><strong>Basic Operations</strong>: Shows how to read site properties, list all lists, create a new list, and upload a document</li>
</ol>
<p>The beauty of PnP.Framework is that it abstracts away all the complex authentication details. Once you have the <code>ClientContext</code>, you can use it just like the regular SharePoint CSOM (Client Side Object Model).</p>
<h3 id="important-notes">Important Notes</h3>
<ul>
<li>Replace <code>your-tenant-id</code>, <code>your-client-id</code>, and the site URL with your actual values from Azure Portal</li>
<li>The certificate path should point to your .pfx file (not the .cer file)</li>
<li>Make sure your app has the necessary permissions granted and admin consent given</li>
<li>Store sensitive information like certificates and passwords securely in production (use Azure Key Vault or similar)</li>
<li>Consider using certificate thumbprints instead of file paths in production environments</li>
<li>The <code>using</code> statement ensures proper disposal of the ClientContext</li>
</ul>
<h3 id="production-considerations">Production Considerations</h3>
<p>For production applications, consider these improvements:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// Load certificate from certificate store instead of file</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> store = <span style="color:#66d9ef">new</span> X509Store(StoreName.My, StoreLocation.CurrentUser);
</span></span><span style="display:flex;"><span>store.Open(OpenFlags.ReadOnly);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> certificate = store.Certificates.Find(X509FindType.FindByThumbprint, <span style="color:#e6db74">&#34;your-cert-thumbprint&#34;</span>, <span style="color:#66d9ef">false</span>)[<span style="color:#ae81ff">0</span>];
</span></span><span style="display:flex;"><span>store.Close();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Use configuration instead of hardcoded values</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> tenantId = Configuration[<span style="color:#e6db74">&#34;Azure:TenantId&#34;</span>];
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> clientId = Configuration[<span style="color:#e6db74">&#34;Azure:ClientId&#34;</span>];
</span></span></code></pre></div><h2 id="troubleshooting">Troubleshooting</h2>
<p>If you run into issues, here are some common problems and their solutions:</p>
<p><strong>Certificate not found</strong>: Make sure the certificate path is correct and the password matches what you used when creating it. You can verify the certificate exists by checking it in the Windows Certificate Manager (certmgr.msc).</p>
<p><strong>Access denied</strong>: Verify that your app registration has the correct permissions and that admin consent has been granted. Check the Azure Portal to ensure the permissions show as &ldquo;Granted for [Your Organization]&rdquo;.</p>
<p><strong>Tenant ID issues</strong>: You can find your tenant ID in the Azure Portal under Azure Active Directory &gt; Properties. It&rsquo;s also visible in the app registration overview page.</p>
<p><strong>&ldquo;The remote server returned an error: (401) Unauthorized&rdquo;</strong>: This usually means the certificate isn&rsquo;t properly associated with the app registration, or the app doesn&rsquo;t have sufficient permissions. Double-check both the certificate upload and permission configuration.</p>
<p><strong>Certificate validation errors</strong>: If you&rsquo;re using a self-signed certificate in a corporate environment, your organization&rsquo;s security policies might block it. Consider using a certificate issued by your internal CA.</p>
<h3 id="testing-your-setup">Testing Your Setup</h3>
<p>Before diving into complex operations, test your connection with this minimal code:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">try</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> certificate = <span style="color:#66d9ef">new</span> X509Certificate2(CertificatePath, CertificatePassword);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> authManager = <span style="color:#66d9ef">new</span> AuthenticationManager(ClientId, certificate, TenantId);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">using</span> var context = authManager.GetContext(SiteUrl);
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    context.Load(context.Web, w =&gt; w.Title);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> context.ExecuteQueryAsync();
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    Console.WriteLine(<span style="color:#e6db74">$&#34;Success! Connected to: {context.Web.Title}&#34;</span>);
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">catch</span> (Exception ex)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    Console.WriteLine(<span style="color:#e6db74">$&#34;Connection failed: {ex.Message}&#34;</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="wrapping-up">Wrapping Up</h2>
<p>You now have a working setup for authenticating with SharePoint using PnP.Framework and certificates. This method is perfect for background services, console applications, or any scenario where you need unattended access to SharePoint.</p>
<p>The certificate-based authentication is more secure than using client secrets since certificates are harder to compromise and can be managed through your organization&rsquo;s certificate infrastructure.</p>
<p>In my next post, I&rsquo;ll show you how to perform common SharePoint operations like creating lists, uploading files, and managing permissions using this authentication setup.</p>
<h2 id="references-">References 📚</h2>
<ul>
<li><a href="https://pnp.github.io/pnpframework/">PnP.Framework Documentation</a></li>
<li><a href="https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app">Azure AD App Registration Guide</a></li>
<li><a href="https://docs.microsoft.com/en-us/sharepoint/dev/sp-add-ins/add-in-permissions-in-sharepoint">SharePoint API Permissions</a></li>
</ul>
]]></content:encoded></item><item><title>Export files as zip from SharePoint</title><link>https://jeppe-spanggaard.dk/blogs/download-multiple-files-from-sharepoint/</link><pubDate>Sun, 01 Dec 2024 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/download-multiple-files-from-sharepoint/</guid><description>Download files from SharePoint as a zip, as is possible through the UI</description><content:encoded><![CDATA[<p>I have long been frustrated by the inability to download multiple files from SharePoint as a zip file via code, in the same way it’s possible through the user interface.
When this suddenly became a requirement from a customer , I had to come up with a solution 💡. Naturally, the solution needed to be robust 💪 — I could use the endpoint that SharePoint itself utilizes, though it’s not officially documented, but I decided against taking that route 🚫.</p>
<h2 id="what-was-the-solution-then-">What was the solution then? 🤔</h2>
<p>The solution came to me 🤔 while setting up the App Service Plan for my function—it struck me that I had 250GB of storage (App Service Premium Plan) available. I figured I could use this for something.</p>
<p>I was initially unsure whether I had permissions to write to this storage, but after a quick test, I confirmed that I could easily write to it—and, of course, read from it again. 🚀</p>
<p>Whether it’s the best solution to the problem, I’m not sure 🤷‍♂️, but I know it works, and I have full control over it, ensuring it won’t suddenly disappear—unlike an unofficial endpoint might. 💡</p>
<h3 id="fetch-the-files-">Fetch the files 📥</h3>
<p>When fetching the files, it’s, of course, important to minimize the number of calls to avoid throttling. The way I’ve attempted to prevent this is by using <a href="https://learn.microsoft.com/en-us/graph/json-batching">Graph batching</a>, which allows me to bundle 20 requests into one. 📦</p>
<p><strong>Example class</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">FileInfoDTO</span> {
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> FileName { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> RelativePath { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Graph batching</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#66d9ef">internal</span> <span style="color:#66d9ef">async</span> Task DownloadFilesFromPathsToTempFolderAsync(
</span></span><span style="display:flex;"><span>    List&lt;FileInfoDTO?&gt;? fileInfo, 
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> tempFolderPath, 
</span></span><span style="display:flex;"><span>    Site siteInfo, 
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string?</span> driveId) {
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	IEnumerable&lt;FileInfoDTO?[]&gt; chucked = fileInfo!.Chunk(<span style="color:#ae81ff">20</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">foreach</span> (FileInfoDTO?[] chunk <span style="color:#66d9ef">in</span> chucked) {
</span></span><span style="display:flex;"><span>		<span style="color:#66d9ef">await</span> DownloadFilesFromPathsToTempFolderAsync(chunk, tempFolderPath, siteInfo, driveId);
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">async</span> Task DownloadFilesFromPathsToTempFolderAsync(
</span></span><span style="display:flex;"><span>    FileInfoDTO?[] fileInfos, 
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> tempFolderPath, 
</span></span><span style="display:flex;"><span>    Site siteInfo, 
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string?</span> driveId) {
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">using</span> BatchRequestContent batchRequestContent = <span style="color:#66d9ef">new</span> BatchRequestContent();
</span></span><span style="display:flex;"><span>	Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">string</span>&gt; nameMapping = <span style="color:#66d9ef">new</span> Dictionary&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">string</span>&gt;(fileInfos.Length);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">foreach</span> (FileInfoDTO? fileInfo <span style="color:#66d9ef">in</span> fileInfos) {
</span></span><span style="display:flex;"><span>		<span style="color:#66d9ef">string</span> requestId = batchRequestContent.AddBatchRequestStep(
</span></span><span style="display:flex;"><span>			GraphClient.Sites[siteInfo.Id]
</span></span><span style="display:flex;"><span>                       .Drives[driveId]
</span></span><span style="display:flex;"><span>                       .Root
</span></span><span style="display:flex;"><span>                       .ItemWithPath(fileInfo.RelativePath)
</span></span><span style="display:flex;"><span>                       .Content.Request());
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>		nameMapping[requestId] = fileInfo.FileName!;
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	BatchResponseContent batchResponse = <span style="color:#66d9ef">await</span> GraphClient.Batch.Request().PostAsync(batchRequestContent);
</span></span><span style="display:flex;"><span>	Dictionary&lt;<span style="color:#66d9ef">string</span>, HttpResponseMessage&gt; responses = <span style="color:#66d9ef">await</span> batchResponse.GetResponsesAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">foreach</span> ((<span style="color:#66d9ef">string</span> requestId, HttpResponseMessage response) <span style="color:#66d9ef">in</span> responses) {
</span></span><span style="display:flex;"><span>		<span style="color:#66d9ef">if</span> (!response.IsSuccessStatusCode &amp;&amp; response.StatusCode != HttpStatusCode.Redirect) {
</span></span><span style="display:flex;"><span>			<span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Exception(<span style="color:#e6db74">$&#34;Error while getting file content: {response.ReasonPhrase}&#34;</span>);
</span></span><span style="display:flex;"><span>		}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>		<span style="color:#66d9ef">string</span> filePath = Path.Combine(tempFolderPath, nameMapping[requestId]);
</span></span><span style="display:flex;"><span>		<span style="color:#66d9ef">if</span> (response.StatusCode == HttpStatusCode.Redirect) {
</span></span><span style="display:flex;"><span>			<span style="color:#66d9ef">await</span> DownloadFileFromRedirectAsync(response.Headers.Location, filePath);
</span></span><span style="display:flex;"><span>		} <span style="color:#66d9ef">else</span> {
</span></span><span style="display:flex;"><span>			<span style="color:#66d9ef">await</span> WriteContentToFileAsync(response.Content, filePath);
</span></span><span style="display:flex;"><span>		}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>		response.Content.Dispose();
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>You should be aware that the StatusCode returned may not always be 200 and can still be valid — many of my requests, for example, returned with a Redirect. 🔄
As a result, I had to implement handling for that as well.</p>
<p>When I finally managed to get all my requests working to fetch the files, the next problem arose&hellip; How could I download the files and save them to my tempFolderPath without loading all the files into memory?</p>
<p>If I did, I’d quickly run out of Memory. The solution to this turned out to be the following:</p>
<p><strong>Example class</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">async</span> Task DownloadFileFromRedirectAsync(Uri? redirectUri, <span style="color:#66d9ef">string</span> filePath) {
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">using</span> HttpResponseMessage redirectResponse = 
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">await</span> GraphClient.HttpProvider.SendAsync(
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">new</span> HttpRequestMessage(HttpMethod.Get, redirectUri));
</span></span><span style="display:flex;"><span>	
</span></span><span style="display:flex;"><span>    redirectResponse.EnsureSuccessStatusCode();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">await</span> WriteContentToFileAsync(redirectResponse.Content, filePath);
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">async</span> Task WriteContentToFileAsync(HttpContent content, <span style="color:#66d9ef">string</span> filePath) {
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">await</span> <span style="color:#66d9ef">using</span> FileStream fileStream = 
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> FileStream(
</span></span><span style="display:flex;"><span>            filePath, 
</span></span><span style="display:flex;"><span>            FileMode.Create, 
</span></span><span style="display:flex;"><span>            FileAccess.Write, 
</span></span><span style="display:flex;"><span>            FileShare.None, 
</span></span><span style="display:flex;"><span>            <span style="color:#ae81ff">4096</span>, 
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">true</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">await</span> <span style="color:#66d9ef">using</span> Stream contentStream = 
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">await</span> content.ReadAsStreamAsync();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">await</span> contentStream.CopyToAsync(fileStream);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>By using using statements and ReadAsStream, each file only resides in Memory for a very short time before it is disposed. ♻️💡</p>
<p><strong>Example of a large file</strong></p>
<p><img src="https://jeppe-spanggaard.dk/images/MemoryUsages.png" alt="Memory Usages"></p>
<h3 id="return-the-zip-file-">Return the ZIP file 🚀</h3>
<p>So how did I implement it all in an endpoint? I did it as follows, and it even works locally on my PC, allowing me to test it easily.
To avoid using too much Memory again, I return the ZIP file as a FileStreamResult. 🚀🗂️</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#a6e22e">[Function(&#34;DownloadFilesFromSharepoint&#34;)]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">async</span> Task&lt;IActionResult&gt; DownloadFilesFromSharepoint(
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">		[HttpTrigger(AuthorizationLevel.Anonymous, &#34;post&#34;, Route = &#34;files/download&#34;)]</span> HttpRequest req) {
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	System.Guid guid = System.Guid.NewGuid();
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">string</span> tempFolderPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), guid.ToString());
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">string</span> zipFilePath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), <span style="color:#e6db74">$&#34;{guid}.zip&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	_logger.LogInformation(<span style="color:#e6db74">$&#34;Zip file path: {zipFilePath}&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	List&lt;FileInfoDTO?&gt;? fileInfo = <span style="color:#66d9ef">await</span> ...;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">if</span> (!System.IO.Directory.Exists(tempFolderPath)) {
</span></span><span style="display:flex;"><span>		System.IO.Directory.CreateDirectory(tempFolderPath);
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">await</span> DownloadFilesFromPathsToTempFolderAsync(fileInfo, tempFolderPath);
</span></span><span style="display:flex;"><span>	System.IO.Compression.ZipFile.CreateFromDirectory(tempFolderPath, zipFilePath);
</span></span><span style="display:flex;"><span>	System.IO.Directory.Delete(tempFolderPath, <span style="color:#66d9ef">true</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> FileStreamResult(<span style="color:#66d9ef">new</span> FileStream(zipFilePath, FileMode.Open), <span style="color:#e6db74">&#34;application/zip&#34;</span>) {
</span></span><span style="display:flex;"><span>		FileDownloadName = <span style="color:#e6db74">&#34;files.zip&#34;</span>,
</span></span><span style="display:flex;"><span>		EnableRangeProcessing = <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>	};
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>However, there’s one thing I haven’t yet found the perfect solution for—namely, deleting my ZIP files. 🗑️</p>
<p>Since I return them as a FileStreamResult and don’t load them into Memory as a byte[], I can’t delete the files immediately. Instead, I handle this with a timer job afterward, which I don’t think is the best solution. 🤷‍♂️⏳</p>
<p>But again, it solved the customer&rsquo;s problem, and they’re happy 😊, so I’m not planning to do much more about it. 🚀</p>
<h3 id="tldr">TL;DR</h3>
<p>This post explores how to programmatically download multiple files from SharePoint as a zip file using C# and Microsoft Graph API. 🚀</p>
<p><strong>Key takeaways:</strong></p>
<ul>
<li><strong>Storage Solution:</strong> Leveraged 250GB of App Service Premium storage to temporarily store files.</li>
<li><strong>Efficient API Usage:</strong> Minimized Graph API calls using batching to avoid throttling (bundling 20 requests into one).</li>
<li><strong>Memory Optimization:</strong> Used <code>ReadAsStream</code> and <code>FileStreamResult</code> to handle files efficiently without overloading RAM.</li>
<li><strong>ZIP File Handling:</strong> Created a ZIP file from the downloaded files and returned it via a streaming endpoint.</li>
<li><strong>Cleanup Challenge:</strong> Deleting ZIP files after returning them remains unresolved, currently handled via a timer job.</li>
</ul>
<p>It’s not perfect, but it works, and most importantly, it solved the customer’s problem. 😊</p>
]]></content:encoded></item><item><title>Update list to use content type</title><link>https://jeppe-spanggaard.dk/blogs/update-list-to-use-content-type/</link><pubDate>Sun, 10 Nov 2024 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/update-list-to-use-content-type/</guid><description>We've all tried creating a SharePoint list without using Content Types from the start. It seems like the quick solution, but it can lead to challenges later when restructuring the list. This post explains why using Content Types from the beginning is a smart move that can save time and hassle in the long run.</description><content:encoded><![CDATA[<h2 id="how-to-add-a-content-type-after-the-fact-in-sharepoint-">How To Add a Content Type After the Fact in SharePoint 💡</h2>
<p>We&rsquo;ve all tried creating a SharePoint list without using Content Types from the start. It seems like the quick solution, but it can lead to challenges later when restructuring the list. This post explains why using Content Types from the beginning is a smart move that can save time and hassle in the long run.</p>
<p>This has been a recurring issue for me 😅 – countless times I’ve started a SharePoint list without Content Types, only to realize later that I needed the list for tasks like search 🔍 or advanced filtering. And, of course, it usually happens when the list is already full of data 📊 without any associated Content Type, making it even more challenging 🛠️ to get everything to work smoothly.</p>
<p><strong>⚠️ Disclaimer: This will only work if the columns in the Content Type match those already present in the list.</strong></p>
<h3 id="how-do-i-fix-it-">How Do I Fix It? 🤔</h3>
<p>The first step is, of course, to create a content type. Once it’s created, I then export it as a template using the PnP PowerShell command <strong>Get-PnPSiteTemplate</strong> 💻. I make sure that only the content type is included in the XML file 📂.</p>
<p>Once the PnP template is ready, a PowerShell script I created can be used to fix this issue – though it can’t handle all scenarios. The script includes the following variables, which need to be filled out:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-powershell" data-lang="powershell"><span style="display:flex;"><span>$siteUrl = <span style="color:#e6db74">&#34;https://xxx.sharepoint.com/sites/xxx&#34;</span>
</span></span><span style="display:flex;"><span>$listName = <span style="color:#e6db74">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>$contentTypeName = <span style="color:#e6db74">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>$columnsToMap = @(<span style="color:#e6db74">&#34;...&#34;</span>)  <span style="color:#75715e"># Replace with the actual column names</span>
</span></span><span style="display:flex;"><span>$userFields = @(<span style="color:#e6db74">&#34;...&#34;</span>)  <span style="color:#75715e"># Replace with actual user field names if needed</span>
</span></span><span style="display:flex;"><span>$templatePath = <span style="color:#e6db74">&#34;..\ContentTypeTemplate.xml&#34;</span>;
</span></span></code></pre></div><p>What the script does is fairly simple 🛠️ – I load all rows with their values into memory 🧠, remove the columns (<em>$columnsToMap</em>) that need to be deleted 🗑️, then invoke the PnP template containing the content type. Finally, I reload all values into the new columns. Since they have the same names as the old columns, everything loads smoothly ✅.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-powershell" data-lang="powershell"><span style="display:flex;"><span><span style="color:#75715e"># Connect to SharePoint Online</span>
</span></span><span style="display:flex;"><span>Connect-PnPOnline -Url $siteUrl -Interactive
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Enable content type management on the list</span>
</span></span><span style="display:flex;"><span>Set-PnPList -Identity $listName -EnableContentTypes $true
</span></span><span style="display:flex;"><span>Write-Host <span style="color:#e6db74">&#34;Content types enabled on the list.&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Backup data from old columns</span>
</span></span><span style="display:flex;"><span>$items = Get-PnPListItem -List $listName -PageSize <span style="color:#ae81ff">2000</span>
</span></span><span style="display:flex;"><span>$backupData = @{}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> ($item <span style="color:#66d9ef">in</span> $items) {
</span></span><span style="display:flex;"><span>    $itemData = @{}
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Backup old column data</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> ($column <span style="color:#66d9ef">in</span> $columnsToMap) {
</span></span><span style="display:flex;"><span>        $itemData[$column] = $item[$column]
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    $backupData[$item.Id] = $itemData
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Remove old columns</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> ($column <span style="color:#66d9ef">in</span> $columnsToMap) {
</span></span><span style="display:flex;"><span>    Remove-PnPField -List $listName -Identity $column -Force
</span></span><span style="display:flex;"><span>    Write-Host <span style="color:#e6db74">&#34;Removed old column:&#34;</span> $column
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Apply PnP Provisioning Template</span>
</span></span><span style="display:flex;"><span>Invoke-PnPSiteTemplate -Path $templatePath
</span></span><span style="display:flex;"><span>Write-Host <span style="color:#e6db74">&#34;PnP template applied to the site.&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Get the content type</span>
</span></span><span style="display:flex;"><span>$contentType = Get-PnPContentType -Identity $contentTypeName
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Add the content type to the list</span>
</span></span><span style="display:flex;"><span>Add-PnPContentTypeToList -List $listName -ContentType $contentType
</span></span><span style="display:flex;"><span>Write-Host <span style="color:#e6db74">&#34;Content type added to the list.&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Update items to the new content type and restore data to new columns</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> ($item <span style="color:#66d9ef">in</span> $items) {
</span></span><span style="display:flex;"><span>    $itemId = $item.Id
</span></span><span style="display:flex;"><span>    $itemData = $backupData[$itemId]
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Handle user fields</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> ($userField <span style="color:#66d9ef">in</span> $userFields) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> ($itemData[$userField]) {
</span></span><span style="display:flex;"><span>            $UserDto = $itemData[$userField];
</span></span><span style="display:flex;"><span>            write-host <span style="color:#e6db74">&#34;User: &#34;</span> $UserDto.Email
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            $itemData[$userField] = $UserDto.Email
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Update item content type</span>
</span></span><span style="display:flex;"><span>    Set-PnPListItem -List $listName -Identity $itemId -Values @{<span style="color:#e6db74">&#34;ContentTypeId&#34;</span> = $contentType.Id }
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Restore data to new columns</span>
</span></span><span style="display:flex;"><span>    Set-PnPListItem -List $listName -Identity $itemId -Values $itemData
</span></span><span style="display:flex;"><span>    Write-Host <span style="color:#e6db74">&#34;Updated item ID:&#34;</span> $itemId
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Write-Host <span style="color:#e6db74">&#34;All items updated to the new content type and old columns removed.&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Disconnect from SharePoint Online</span>
</span></span><span style="display:flex;"><span>Disconnect-PnPOnline
</span></span></code></pre></div><p>As mentioned earlier, this script doesn’t solve everything. For example, any views created with the old columns will no longer work, even if the new columns have the same name. So, there’s definitely room for improvement in my script.</p>
<p>That said, the script has saved me quite a bit of manual work multiple times 🙌.</p>
]]></content:encoded></item><item><title>SPFx Audience targeting</title><link>https://jeppe-spanggaard.dk/blogs/role-based-rendering/</link><pubDate>Tue, 09 Apr 2024 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/role-based-rendering/</guid><description>How to easy implement audience targeting for for one's components</description><content:encoded><![CDATA[<h2 id="audience-targeting-">Audience Targeting 🎯</h2>
<p>Audience targeting allows content administrators to direct content toward specific groups of users. This is particularly useful in organizations with a wide array of users, where not all content is relevant for everyone. By using audience targeting, you can improve the relevance of the content each user sees, leading to a more personalized and focused user experience.</p>
<h2 id="audience-targeted-wrapper-">Audience Targeted Wrapper 🔧</h2>
<p>Let&rsquo;s dive into how to build an SPFx component that employs audience targeting. This component acts as a wrapper, showing or hiding content based on the user&rsquo;s membership in specific groups.</p>
<h3 id="group-check-logic-">Group Check Logic 🔍</h3>
<p>At the heart of our component is the logic that determines if the current user is a member of any of the specified groups.</p>
<p><strong>Group Check Logic</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-JSX" data-lang="JSX"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">async</span> <span style="color:#a6e22e">isMember</span>(<span style="color:#a6e22e">group</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">any</span>, <span style="color:#a6e22e">context</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">any</span>)<span style="color:#f92672">:</span> Promise&lt;<span style="color:#f92672">boolean</span>&gt; {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">_cacheName</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;CacheName_memberGroups&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">group</span> <span style="color:#f92672">==</span> <span style="color:#66d9ef">null</span> <span style="color:#f92672">||</span> <span style="color:#a6e22e">group</span> <span style="color:#f92672">==</span> <span style="color:#e6db74">&#39;&#39;</span>) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">true</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">graph</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">graphfi</span>().<span style="color:#a6e22e">using</span>(<span style="color:#a6e22e">SPFx</span>(<span style="color:#a6e22e">context</span>));
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">_cachedMemberGroups</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">sessionStorage</span>.<span style="color:#a6e22e">getItem</span>(<span style="color:#a6e22e">_cacheName</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">memberGroups</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>[] <span style="color:#f92672">=</span> [];
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">_cachedMemberGroups</span>) {
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">memberGroups</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">graph</span>.<span style="color:#a6e22e">me</span>.<span style="color:#a6e22e">getMemberGroups</span>();
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">sessionStorage</span>.<span style="color:#a6e22e">setItem</span>(<span style="color:#a6e22e">_cacheName</span>, <span style="color:#a6e22e">JSON</span>.<span style="color:#a6e22e">stringify</span>(<span style="color:#a6e22e">memberGroups</span>));
</span></span><span style="display:flex;"><span>    } <span style="color:#66d9ef">else</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">memberGroups</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">JSON</span>.<span style="color:#a6e22e">parse</span>(<span style="color:#a6e22e">_cachedMemberGroups</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// &#34;c:0o.c|federateddirectoryclaimprovider|xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx&#34;
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">groupID</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">group</span>.<span style="color:#a6e22e">id</span>.<span style="color:#a6e22e">split</span>(<span style="color:#e6db74">&#39;|&#39;</span>)[<span style="color:#ae81ff">2</span>];
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">memberGroups</span>.<span style="color:#a6e22e">filter</span>((<span style="color:#a6e22e">g</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>) =&gt; <span style="color:#a6e22e">g</span> <span style="color:#f92672">===</span> <span style="color:#a6e22e">groupID</span>).<span style="color:#a6e22e">length</span> <span style="color:#f92672">&gt;</span> <span style="color:#ae81ff">0</span>) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">true</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Error(<span style="color:#e6db74">&#39;User not found&#39;</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The wrapper component is visually minimalistic, primarily designed to function based on the logic of accessing group IDs from an array of <strong>IPropertyFieldGroupOrPerson</strong>. Its simplicity belies its utility, enabling dynamic content visibility tailored to user or group permissions with little visual footprint.</p>
<p><strong>TargetAudience wrapper</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-JSX" data-lang="JSX"><span style="display:flex;"><span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">interface</span> <span style="color:#a6e22e">ITargetAudienceProps</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">pageContext</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">PageContext</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">context</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">any</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">groupIds</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">IPropertyFieldGroupOrPerson</span>[];
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">TargetAudience</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">React</span>.<span style="color:#a6e22e">FC</span>&lt;<span style="color:#f92672">ITargetAudienceProps</span>&gt; <span style="color:#f92672">=</span> ({ <span style="color:#a6e22e">pageContext</span>, <span style="color:#a6e22e">groupIds</span>, <span style="color:#a6e22e">children</span>, <span style="color:#a6e22e">context</span> }) =&gt; {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> [<span style="color:#a6e22e">canView</span>, <span style="color:#a6e22e">setCanView</span>] <span style="color:#f92672">=</span> <span style="color:#a6e22e">useState</span>&lt;<span style="color:#f92672">boolean</span>&gt;(<span style="color:#66d9ef">false</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">useEffect</span>(() =&gt; {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">checkUserIsAllowedToViewWebpart</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">async</span> () =&gt; {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">proms</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">any</span>[] <span style="color:#f92672">=</span> [];
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">errors</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">any</span>[] <span style="color:#f92672">=</span> [];
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">groupIds</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">map</span>((<span style="color:#a6e22e">item</span> <span style="color:#f92672">:</span> <span style="color:#a6e22e">any</span>) =&gt; {
</span></span><span style="display:flex;"><span>                <span style="color:#a6e22e">proms</span>.<span style="color:#a6e22e">push</span>(
</span></span><span style="display:flex;"><span>                    <span style="color:#a6e22e">isMember</span>(<span style="color:#a6e22e">item</span>, <span style="color:#a6e22e">context</span>)
</span></span><span style="display:flex;"><span>                );
</span></span><span style="display:flex;"><span>            });
</span></span><span style="display:flex;"><span>            Promise.<span style="color:#a6e22e">race</span>(
</span></span><span style="display:flex;"><span>                <span style="color:#a6e22e">proms</span>.<span style="color:#a6e22e">map</span>(<span style="color:#a6e22e">p</span> =&gt; {
</span></span><span style="display:flex;"><span>                    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">p</span>.<span style="color:#66d9ef">catch</span>((<span style="color:#a6e22e">err</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">any</span>) =&gt; {
</span></span><span style="display:flex;"><span>                        <span style="color:#a6e22e">errors</span>.<span style="color:#a6e22e">push</span>(<span style="color:#a6e22e">err</span>);
</span></span><span style="display:flex;"><span>                        <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">errors</span>.<span style="color:#a6e22e">length</span> <span style="color:#f92672">&gt;=</span> <span style="color:#a6e22e">proms</span>.<span style="color:#a6e22e">length</span>){
</span></span><span style="display:flex;"><span>                            <span style="color:#66d9ef">throw</span> <span style="color:#a6e22e">errors</span>;
</span></span><span style="display:flex;"><span>                        } 
</span></span><span style="display:flex;"><span>                        <span style="color:#66d9ef">return</span> Promise.<span style="color:#a6e22e">race</span>(<span style="color:#66d9ef">null</span>);
</span></span><span style="display:flex;"><span>                    });
</span></span><span style="display:flex;"><span>                }))
</span></span><span style="display:flex;"><span>                .<span style="color:#a6e22e">then</span>(<span style="color:#a6e22e">val</span> =&gt; {
</span></span><span style="display:flex;"><span>                    <span style="color:#a6e22e">setCanView</span>(<span style="color:#66d9ef">true</span>);
</span></span><span style="display:flex;"><span>                });
</span></span><span style="display:flex;"><span>        };
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">checkUserIsAllowedToViewWebpart</span>();
</span></span><span style="display:flex;"><span>    }, [<span style="color:#a6e22e">groupIds</span>, <span style="color:#a6e22e">pageContext</span>]);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> (
</span></span><span style="display:flex;"><span>        &lt;<span style="color:#f92672">div</span>&gt;
</span></span><span style="display:flex;"><span>            {<span style="color:#a6e22e">groupIds</span> <span style="color:#f92672">!=</span> <span style="color:#66d9ef">null</span> <span style="color:#f92672">&amp;&amp;</span> <span style="color:#a6e22e">groupIds</span>.<span style="color:#a6e22e">length</span> <span style="color:#f92672">&gt;=</span> <span style="color:#ae81ff">1</span> <span style="color:#f92672">?</span> (<span style="color:#a6e22e">canView</span> <span style="color:#f92672">?</span> <span style="color:#a6e22e">children</span> <span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;&#39;</span>) <span style="color:#f92672">:</span> <span style="color:#a6e22e">children</span>}
</span></span><span style="display:flex;"><span>        &lt;/<span style="color:#f92672">div</span>&gt;
</span></span><span style="display:flex;"><span>    );
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">default</span> <span style="color:#a6e22e">TargetAudience</span>;
</span></span></code></pre></div><p><strong>How to use it</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-JSX" data-lang="JSX"><span style="display:flex;"><span>&lt;<span style="color:#f92672">TargetAudience</span> <span style="color:#a6e22e">groupIds</span><span style="color:#f92672">=</span>{<span style="color:#a6e22e">GroupsIDS</span>} <span style="color:#a6e22e">pageContext</span><span style="color:#f92672">=</span>{<span style="color:#a6e22e">pageContext</span>} <span style="color:#a6e22e">context</span><span style="color:#f92672">=</span>{<span style="color:#a6e22e">context</span>}&gt;
</span></span><span style="display:flex;"><span>    &lt;<span style="color:#f92672">h1</span>&gt;
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">This</span> <span style="color:#a6e22e">is</span> <span style="color:#a6e22e">top</span> <span style="color:#a6e22e">secret</span> <span style="color:#66d9ef">for</span> <span style="color:#a6e22e">non</span> <span style="color:#a6e22e">admins</span>...
</span></span><span style="display:flex;"><span>    &lt;/<span style="color:#f92672">h1</span>&gt;
</span></span><span style="display:flex;"><span>&lt;/<span style="color:#f92672">TargetAudience</span>&gt;
</span></span></code></pre></div><h2 id="final-thoughts-">Final Thoughts 💭</h2>
<p>Could the logic be extended to check that only selected users can view the content, instead of it being limited to groups? Absolutely, this is entirely possible.</p>
<p>However, I&rsquo;ve chosen to focus on the group aspect because that&rsquo;s often what my solutions revolve around. This approach emphasizes the flexibility of SPFx solutions in catering to diverse requirements, allowing for both broad group-based targeting and the precision of user-specific content visibility.</p>
<p>It highlights the potential to tailor your SharePoint solutions even more closely to your organization&rsquo;s needs, ensuring that every piece of content reaches exactly the right audience.</p>
]]></content:encoded></item><item><title>Web Api logging attribute</title><link>https://jeppe-spanggaard.dk/blogs/logging-before-execution-w-attributes/</link><pubDate>Sun, 18 Feb 2024 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/logging-before-execution-w-attributes/</guid><description>Implement logging only with a attribute on each method in a controller</description><content:encoded><![CDATA[<h2 id="have-logging-ever-irritated-you">Have logging ever irritated you?</h2>
<p>If yes, then you&rsquo;re definitely not alone! Often when I work with Web APIs in C#, logging tends to take up a lot of space, making the code quickly look &ldquo;ugly.&rdquo; I like to log what parameters the method is called with, which controller the method is in, etc. Therefore, a method often looks like this for me, in a simplified way:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#a6e22e">[HttpGet(&#34;{id}&#34;)]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> ActionResult&lt;WeatherForecast&gt; Get(<span style="color:#66d9ef">int</span> id)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    _logger.LogInformation(<span style="color:#e6db74">$&#34;Starting to find weather forecast with ID: {id}&#34;</span>);
</span></span><span style="display:flex;"><span>    { ... }
</span></span><span style="display:flex;"><span>    _logger.LogInformation(<span style="color:#e6db74">$&#34;Weather forecast with id {id} is fetched&#34;</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> WeatherForecast();
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Output</strong></p>
<pre tabindex="0"><code>Starting to find weather forecast with ID: 234
Weather forecast with id 234 is fetched
</code></pre><p>I definitely don&rsquo;t find it visually appealing, and it contributes to my classes sometimes becoming extremely long. Therefore, I&rsquo;ve been thinking that it should be possible to make it easier and also more elegant. It got me thinking about how you can do so many cool things with custom attributes, so why not create some nice logging with an attribute?</p>
<p>And, as so many times before, almost anything is possible with C#; it&rsquo;s mainly just a matter of having the idea for it. So what I&rsquo;ve come up with is just an attribute that inherits from IActionFilter, since IActionFilter already implements OnActionExecuted and OnActionExecuting.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#a6e22e">[ActionLogger]</span> <span style="color:#75715e">// &lt;-- Custom attribute</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">[HttpGet(&#34;{id}&#34;)]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> ActionResult&lt;WeatherForecast&gt; Get(<span style="color:#66d9ef">int</span> id)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> WeatherForecast();
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Output</strong></p>
<pre tabindex="0"><code>[GET][WeatherForecastController][/WeatherForecast/234] Executing with parameters: [id, 234]
[GET][WeatherForecastController][/WeatherForecast/234] Executed
</code></pre><h2 id="how-did-i-go-about-implementing-it-then">How did I go about implementing it then?</h2>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">ActionLoggerAttribute</span> : Attribute, IActionFilter
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">void</span> OnActionExecuted(ActionExecutedContext context)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        ILogger? logger = GetLogger(context, context.Controller.GetType());
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> httpMethod = context.HttpContext.Request.Method;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> routeUrl = context.HttpContext.Request.Path;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> controllerName = context.Controller.GetType().Name;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (logger == <span style="color:#66d9ef">null</span>)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        logger.LogInformation(<span style="color:#e6db74">$&#34;[{httpMethod}][{controllerName}][{routeUrl}] Executed&#34;</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">void</span> OnActionExecuting(ActionExecutingContext context)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        ILogger? logger = GetLogger(context, context.Controller.GetType());
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> parameters = context.ActionArguments;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> httpMethod = context.HttpContext.Request.Method;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> routeUrl = context.HttpContext.Request.Path;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> controllerName = context.Controller.GetType().Name;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (logger == <span style="color:#66d9ef">null</span>)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">string</span> baseMessage = <span style="color:#e6db74">$&#34;[{httpMethod}][{controllerName}][{routeUrl}] Executing&#34;</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">switch</span> (httpMethod)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">case</span> <span style="color:#e6db74">&#34;POST&#34;</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#75715e">// Get the type of body content</span>
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">var</span> bodyType = context.ActionDescriptor.Parameters
</span></span><span style="display:flex;"><span>                    .FirstOrDefault(p =&gt; p.BindingInfo?.BindingSource?.DisplayName == <span style="color:#e6db74">&#34;Body&#34;</span>)
</span></span><span style="display:flex;"><span>                    ?.Name;
</span></span><span style="display:flex;"><span>                <span style="color:#75715e">// Get the value of bodytype</span>
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">var</span> bodyContent = bodyType != <span style="color:#66d9ef">null</span> ? JsonSerializer.Serialize(parameters[bodyType]) : <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">if</span> (bodyContent != <span style="color:#66d9ef">null</span>)
</span></span><span style="display:flex;"><span>                {
</span></span><span style="display:flex;"><span>                    logger.LogInformation(<span style="color:#e6db74">$&#34;{baseMessage} with body content: {bodyContent}&#34;</span>);
</span></span><span style="display:flex;"><span>                    <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>                }
</span></span><span style="display:flex;"><span>                logger.LogInformation(<span style="color:#e6db74">$&#34;{baseMessage} without body content&#34;</span>);
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">case</span> <span style="color:#e6db74">&#34;GET&#34;</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">if</span> (parameters.Count &gt; <span style="color:#ae81ff">0</span>)
</span></span><span style="display:flex;"><span>                {
</span></span><span style="display:flex;"><span>                    logger.LogInformation(<span style="color:#e6db74">$&#34;{baseMessage} with parameters: {string.Join(&#34;</span>, <span style="color:#e6db74">&#34;, parameters)}&#34;</span>
</span></span><span style="display:flex;"><span>                    <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>                }
</span></span><span style="display:flex;"><span>                logger.LogInformation(<span style="color:#e6db74">$&#34;{baseMessage} without parameters&#34;</span>);
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">default</span>:
</span></span><span style="display:flex;"><span>                logger.LogInformation(<span style="color:#e6db74">$&#34;{baseMessage} with parameters: {string.Join(&#34;</span>, <span style="color:#e6db74">&#34;, parameters)}&#34;</span>);
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> ILogger&lt;T&gt;? GetLogger&lt;T&gt;(FilterContext context, T type)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> (ILogger&lt;T&gt;?)context.HttpContext.RequestServices.GetService(<span style="color:#66d9ef">typeof</span>(ILogger&lt;T&gt;));
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>As mentioned, I simply created an attribute that inherits from IActionFilter, allowing me to override what should happen in OnActionExecuted and OnActionExecuting. In the specific code I&rsquo;ve written for my test to see what&rsquo;s possible, I might have gone a bit overkill – but then again, it&rsquo;s just to test the limits. I don&rsquo;t think in a &ldquo;real&rdquo; setup you&rsquo;d want to log the entire received request.</p>
<h2 id="best-practice">Best practice?</h2>
<p>I know it&rsquo;s not the best practice or the least resource-intensive way to log, but regardless, I&rsquo;m thinking of using this type of attribute in some future projects because it&rsquo;s easy, quick, and doesn&rsquo;t clutter the code. Often, for me, it&rsquo;s just as good, as it doesn&rsquo;t necessarily matter much for the solutions I create whether it takes 40ms or 80ms, but rather how quickly I can pinpoint the root cause when an error occurs.</p>
<p><a href="https://github.com/jeppesc11/WebApi-ActionLoggerAttribute">[Link to code]</a></p>
]]></content:encoded></item><item><title>SQL save changes w. trigger</title><link>https://jeppe-spanggaard.dk/blogs/trigger-in-mssql-for-version-history/</link><pubDate>Sat, 03 Feb 2024 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/trigger-in-mssql-for-version-history/</guid><description>Easiest way to keep track of which changes have been made to an object, and likewise, who has carried out these changes.</description><content:encoded><![CDATA[<p>The other day at work, I had to work on an old solution (some C# web API with an SQL) that a colleague had created a long time ago. Part of this solution was to create a kind of version history of objects and who had made the individual changes.</p>
<p>As I opened up the project, I saw some of the wildest code (in my opinion) I&rsquo;ve seen in a long time to solve an otherwise simple problem😮. Without going too deep into what was done, I can say that the person who created it was very fond of reflection in C#.</p>
<h2 id="how-would-i-have-done-it">How would I have done it?</h2>
<p>Since I&rsquo;ve worked a lot with SQL, it would be obvious for me to look at how to solve this directly in the database, rather than having to create a lot of generic C# code that almost no one would be able to read afterwards. So the obvious choice would be to create an SQL trigger on the exact table that should have a version history.</p>
<p><strong>So let&rsquo;s take an example</strong></p>
<p>Let&rsquo;s say we have a Person table like this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">TABLE</span> [dbo].[Persons](
</span></span><span style="display:flex;"><span>	[ID] [int] <span style="color:#66d9ef">IDENTITY</span>(<span style="color:#ae81ff">1</span>,<span style="color:#ae81ff">1</span>) <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>	[Firstname] [nvarchar](<span style="color:#ae81ff">255</span>) <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>	[Lastname] [nvarchar](<span style="color:#ae81ff">255</span>) <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>	[Email] [nvarchar](<span style="color:#ae81ff">255</span>) <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>	[Phone] [int] <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>	[Address] [nvarchar](<span style="color:#ae81ff">255</span>) <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>	[ModifiedAt] [datetime] <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>	[ModifiedBy] [nvarchar](<span style="color:#ae81ff">255</span>) <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">CONSTRAINT</span> [PK_ID] <span style="color:#66d9ef">PRIMARY</span> <span style="color:#66d9ef">KEY</span> CLUSTERED 
</span></span><span style="display:flex;"><span> ( [ID] <span style="color:#66d9ef">ASC</span> )
</span></span><span style="display:flex;"><span>) <span style="color:#66d9ef">ON</span> [<span style="color:#66d9ef">PRIMARY</span>]
</span></span></code></pre></div><p>From this table, we want to log every time a change occurs, not just that a change happens, but also what individual value changes from and to. This should, of course, be stored in a table as well, and such a table could look like this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">TABLE</span> [dbo].[Persons_Changes](
</span></span><span style="display:flex;"><span>	[TriggerID] [int] <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>	[Firstname_Old] [nvarchar](<span style="color:#ae81ff">255</span>) <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>	[Firstname_New] [nvarchar](<span style="color:#ae81ff">255</span>) <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>	[Lastname_Old] [nvarchar](<span style="color:#ae81ff">255</span>) <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>	[Lastname_New] [nvarchar](<span style="color:#ae81ff">255</span>) <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>	[Email_Old] [nvarchar](<span style="color:#ae81ff">255</span>) <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>	[Email_New] [nvarchar](<span style="color:#ae81ff">255</span>) <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>	[Phone_Old] [int] <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>	[Phone_New] [int] <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>	[Address_Old] [nvarchar](<span style="color:#ae81ff">255</span>) <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>	[Address_New] [nvarchar](<span style="color:#ae81ff">255</span>) <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>	[ModifiedBy] [nvarchar](<span style="color:#ae81ff">255</span>) <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>	[ModifiedAt] [datetime] <span style="color:#66d9ef">NULL</span>
</span></span><span style="display:flex;"><span>) <span style="color:#66d9ef">ON</span> [<span style="color:#66d9ef">PRIMARY</span>]
</span></span></code></pre></div><p>Now for the fun part🎉 - namely, the logic that triggers each time a row in the Person table is updated. The trigger is designed to only save the values that are actually changed; the others that remain unchanged are simply set to NULL.</p>
<p><strong>Trigger code</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">TRIGGER</span> TriggerPerson_AfterUpdate
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ON</span> dbo.Persons
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">AFTER</span> <span style="color:#66d9ef">UPDATE</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">AS</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">BEGIN</span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">DECLARE</span> <span style="color:#f92672">@</span>ColumnOrdinalTotal INT <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>;
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">SELECT</span> <span style="color:#f92672">@</span>ColumnOrdinalTotal <span style="color:#f92672">=</span> <span style="color:#f92672">@</span>ColumnOrdinalTotal 
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">+</span> POWER (
</span></span><span style="display:flex;"><span>                <span style="color:#ae81ff">2</span> 
</span></span><span style="display:flex;"><span>                , COLUMNPROPERTY(t.object_id,<span style="color:#66d9ef">c</span>.name,<span style="color:#e6db74">&#39;ColumnID&#39;</span>) <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>            )
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">FROM</span> sys.schemas s
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">INNER</span> <span style="color:#66d9ef">JOIN</span> sys.tables t <span style="color:#66d9ef">ON</span> s.schema_id <span style="color:#f92672">=</span> t.schema_id
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">INNER</span> <span style="color:#66d9ef">JOIN</span> sys.columns <span style="color:#66d9ef">c</span> <span style="color:#66d9ef">ON</span> t.object_id <span style="color:#f92672">=</span> <span style="color:#66d9ef">c</span>.object_id
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">WHERE</span> s.name <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;dbo&#39;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">AND</span> t.name <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;Persons&#39;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">AND</span> <span style="color:#66d9ef">c</span>.name <span style="color:#66d9ef">IN</span> (
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#39;Firstname&#39;</span>,
</span></span><span style="display:flex;"><span>			<span style="color:#e6db74">&#39;Lastname&#39;</span>,
</span></span><span style="display:flex;"><span>			<span style="color:#e6db74">&#39;Email&#39;</span>,
</span></span><span style="display:flex;"><span>			<span style="color:#e6db74">&#39;Phone&#39;</span>,
</span></span><span style="display:flex;"><span>			<span style="color:#e6db74">&#39;Address&#39;</span>,
</span></span><span style="display:flex;"><span>			<span style="color:#e6db74">&#39;ModifiedAt&#39;</span>,
</span></span><span style="display:flex;"><span>			<span style="color:#e6db74">&#39;ModifiedBy&#39;</span>
</span></span><span style="display:flex;"><span>        );
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#66d9ef">IF</span> (COLUMNS_UPDATED() <span style="color:#f92672">&amp;</span> <span style="color:#f92672">@</span>ColumnOrdinalTotal) <span style="color:#f92672">&gt;</span> <span style="color:#ae81ff">0</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">BEGIN</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">INSERT</span> <span style="color:#66d9ef">INTO</span> dbo.Persons_Changes
</span></span><span style="display:flex;"><span>        (
</span></span><span style="display:flex;"><span>			dbo.Persons_Changes.TriggerID,
</span></span><span style="display:flex;"><span>			dbo.Persons_Changes.Firstname_Old,
</span></span><span style="display:flex;"><span>			dbo.Persons_Changes.Firstname_New,
</span></span><span style="display:flex;"><span>			dbo.Persons_Changes.Lastname_Old,
</span></span><span style="display:flex;"><span>			dbo.Persons_Changes.Lastname_New,
</span></span><span style="display:flex;"><span>			dbo.Persons_Changes.Email_Old,
</span></span><span style="display:flex;"><span>			dbo.Persons_Changes.Email_New,
</span></span><span style="display:flex;"><span>			dbo.Persons_Changes.ModifiedBy,
</span></span><span style="display:flex;"><span>			dbo.Persons_Changes.ModifiedAt
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">SELECT</span> d.ID
</span></span><span style="display:flex;"><span>            , <span style="color:#66d9ef">CASE</span> <span style="color:#66d9ef">WHEN</span> d.Firstname <span style="color:#f92672">=</span> i.Firstname <span style="color:#66d9ef">THEN</span> <span style="color:#66d9ef">NULL</span> <span style="color:#66d9ef">ELSE</span> d.Firstname <span style="color:#66d9ef">END</span>
</span></span><span style="display:flex;"><span>			, <span style="color:#66d9ef">CASE</span> <span style="color:#66d9ef">WHEN</span> d.Firstname <span style="color:#f92672">=</span> i.Firstname <span style="color:#66d9ef">THEN</span> <span style="color:#66d9ef">NULL</span> <span style="color:#66d9ef">ELSE</span> i.Firstname <span style="color:#66d9ef">END</span>
</span></span><span style="display:flex;"><span>			, <span style="color:#66d9ef">CASE</span> <span style="color:#66d9ef">WHEN</span> d.Lastname <span style="color:#f92672">=</span> i.Lastname <span style="color:#66d9ef">THEN</span> <span style="color:#66d9ef">NULL</span> <span style="color:#66d9ef">ELSE</span> d.Lastname <span style="color:#66d9ef">END</span>
</span></span><span style="display:flex;"><span>			, <span style="color:#66d9ef">CASE</span> <span style="color:#66d9ef">WHEN</span> d.Lastname <span style="color:#f92672">=</span> i.Lastname <span style="color:#66d9ef">THEN</span> <span style="color:#66d9ef">NULL</span> <span style="color:#66d9ef">ELSE</span> i.Lastname <span style="color:#66d9ef">END</span>
</span></span><span style="display:flex;"><span>			, <span style="color:#66d9ef">CASE</span> <span style="color:#66d9ef">WHEN</span> d.Email <span style="color:#f92672">=</span> i.Email <span style="color:#66d9ef">THEN</span> <span style="color:#66d9ef">NULL</span> <span style="color:#66d9ef">ELSE</span> d.Email <span style="color:#66d9ef">END</span>
</span></span><span style="display:flex;"><span>			, <span style="color:#66d9ef">CASE</span> <span style="color:#66d9ef">WHEN</span> d.Email <span style="color:#f92672">=</span> i.Email <span style="color:#66d9ef">THEN</span> <span style="color:#66d9ef">NULL</span> <span style="color:#66d9ef">ELSE</span> i.Email <span style="color:#66d9ef">END</span>
</span></span><span style="display:flex;"><span>			, i.ModifiedBy
</span></span><span style="display:flex;"><span>			, i.ModifiedAt
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">FROM</span> inserted i 
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">LEFT</span> <span style="color:#66d9ef">JOIN</span> deleted d <span style="color:#66d9ef">ON</span> i.ID <span style="color:#f92672">=</span> d.ID;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">END</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">END</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">GO</span>
</span></span></code></pre></div><p>Now there should be a row like these every time a Person is updated</p>
<table>
  <thead>
      <tr>
          <th>TriggerID</th>
          <th>Firstname_Old</th>
          <th>Firstname_New</th>
          <th>Lastname_Old</th>
          <th>Lastname_New</th>
          <th>&hellip;</th>
          <th>ModifiedBy</th>
          <th>ModifiedAt</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>Jeppe1</td>
          <td>Jeppe</td>
          <td>NULL</td>
          <td>NULL</td>
          <td>&hellip;</td>
          <td>JSC</td>
          <td>2024-02-03 11:30:19</td>
      </tr>
      <tr>
          <td>1</td>
          <td>NULL</td>
          <td>NULL</td>
          <td>Spanggaard</td>
          <td>Christensen</td>
          <td>&hellip;</td>
          <td>JSC</td>
          <td>2024-02-03 11:35:23</td>
      </tr>
  </tbody>
</table>
<p><strong>And now just for fun - how can this be used in C#?</strong>
First of all, we need to create the DTO classes, one for the Persons table and one for Persons_Changes. Unlike many, I hardly ever use scaffold with Entity Framework; I create the models myself.</p>
<p><strong>Persons class:</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#a6e22e">    [Table(&#34;Persons&#34;, Schema = &#34;dbo&#34;)]</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">internal</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Person</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">        [Column(&#34;ID&#34;)]</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">int</span> Id { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">        [Column(&#34;Firstname&#34;)]</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> Firstname { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">        [Column(&#34;Lastname&#34;)]</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> Lastname { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">        [Column(&#34;Email&#34;)]</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> Email { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">        [Column(&#34;Phone&#34;)]</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">int?</span> Phone { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">        [Column(&#34;Address&#34;)]</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> Address { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">        [Column(&#34;ModifiedAt&#34;)]</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> DateTime? ModifiedAt { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">        [Column(&#34;ModifiedBy&#34;)]</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> ModifiedBy { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>    }
</span></span></code></pre></div><p><strong>Persons_Changes:</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#a6e22e">    [Table(&#34;Persons_Changes&#34;, Schema = &#34;dbo&#34;)]</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">internal</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">PersonChange</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">        [Column(&#34;TriggerID&#34;)]</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">int</span> Id { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">        [Column(&#34;Firstname_Old&#34;)]</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> FirstnameOld { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">        [Column(&#34;Firstname_New&#34;)]</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> FirstnameNew { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">        [Column(&#34;Lastname_Old&#34;)]</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> LastnameOld { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">        [Column(&#34;Lastname_New&#34;)]</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> LastnameNew { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">        [Column(&#34;Email_Old&#34;)]</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> EmailOld { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">        [Column(&#34;Email_New&#34;)]</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> EmailNew { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">        [Column(&#34;Phone_Old&#34;)]</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">int?</span> PhoneOld { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">        [Column(&#34;Phone_New&#34;)]</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">int?</span> PhoneNew { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">        [Column(&#34;Address_Old&#34;)]</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> AddressOld { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">        [Column(&#34;Address_New&#34;)]</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> AddressNew { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">        [Column(&#34;ModifiedAt&#34;)]</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> DateTime? ModifiedAt { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">        [Column(&#34;ModifiedBy&#34;)]</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> ModifiedBy { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>    }
</span></span></code></pre></div><p><strong>DbContext:</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span>    <span style="color:#66d9ef">internal</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">TestDbContext</span> : DbContext
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> DbSet&lt;Person&gt; Persons { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> DbSet&lt;PersonChange&gt; PersonChanges { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">protected</span> <span style="color:#66d9ef">override</span> <span style="color:#66d9ef">void</span> OnConfiguring(DbContextOptionsBuilder optionsBuilder)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            optionsBuilder.UseSqlServer(<span style="color:#e6db74">&#34;CONNECTION_STRING&#34;</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">protected</span> <span style="color:#66d9ef">void</span> OnModelCreation(ModelBuilder modelBuilder)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">base</span>.OnModelCreating(modelBuilder);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span></code></pre></div><p>Once we have this in place, we can start playing with the data in C#, and for fun, I&rsquo;m thinking of creating a &rsquo;nice&rsquo; display of who and when the individual values were changed. And even though I started joking about reflection, I use it myself (when it makes sense)😜.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#66d9ef">internal</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">string</span> FormatChange&lt;T&gt;(T changeObj)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        List&lt;Tuple&lt;<span style="color:#66d9ef">string</span>,<span style="color:#66d9ef">string?</span>,<span style="color:#66d9ef">string?</span>&gt;&gt; changes = <span style="color:#66d9ef">new</span> List&lt;Tuple&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">string?</span>, <span style="color:#66d9ef">string?</span>&gt;&gt;();
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> properties = <span style="color:#66d9ef">typeof</span>(T).GetProperties();
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> filteredProperties = properties
</span></span><span style="display:flex;"><span>            .Where(x =&gt; x.Name.Contains(<span style="color:#e6db74">&#34;Old&#34;</span>) || x.Name.Contains(<span style="color:#e6db74">&#34;New&#34;</span>));
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> distinctProperties = filteredProperties
</span></span><span style="display:flex;"><span>            .Select(x =&gt; x.Name.Substring(<span style="color:#ae81ff">0</span>, x.Name.Length - <span style="color:#ae81ff">3</span>))
</span></span><span style="display:flex;"><span>            .Distinct();
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> property <span style="color:#66d9ef">in</span> distinctProperties)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> oldValueProperty = <span style="color:#66d9ef">typeof</span>(T).GetProperty(<span style="color:#e6db74">$&#34;{property}Old&#34;</span>);
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">var</span> newValueProperty = <span style="color:#66d9ef">typeof</span>(T).GetProperty(<span style="color:#e6db74">$&#34;{property}New&#34;</span>);
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> (oldValueProperty != <span style="color:#66d9ef">null</span> &amp;&amp; newValueProperty != <span style="color:#66d9ef">null</span>)
</span></span><span style="display:flex;"><span>            {
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">var</span> oldValue = oldValueProperty.GetValue(changeObj);
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">var</span> newValue = newValueProperty.GetValue(changeObj);
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">if</span> (oldValue != <span style="color:#66d9ef">null</span> &amp;&amp; newValue != <span style="color:#66d9ef">null</span>)
</span></span><span style="display:flex;"><span>                {
</span></span><span style="display:flex;"><span>                    changes.Add(
</span></span><span style="display:flex;"><span>                        <span style="color:#66d9ef">new</span> Tuple&lt;<span style="color:#66d9ef">string</span>, <span style="color:#66d9ef">string?</span>, <span style="color:#66d9ef">string?</span>&gt;(
</span></span><span style="display:flex;"><span>                            property, 
</span></span><span style="display:flex;"><span>                            oldValue?.ToString(), 
</span></span><span style="display:flex;"><span>                            newValue?.ToString()
</span></span><span style="display:flex;"><span>                        ));
</span></span><span style="display:flex;"><span>                }
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        StringBuilder sb = <span style="color:#66d9ef">new</span> StringBuilder();
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">foreach</span> (<span style="color:#66d9ef">var</span> change <span style="color:#66d9ef">in</span> changes)
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> (change.Equals(changes.Last()))
</span></span><span style="display:flex;"><span>            {
</span></span><span style="display:flex;"><span>                sb.Append(<span style="color:#e6db74">$&#34;{change.Item1} from &#39;{change.Item2}&#39; to &#39;{change.Item3}&#39;&#34;</span>);
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>            sb.Append(<span style="color:#e6db74">$&#34;{change.Item1} from &#39;{change.Item2}&#39; to &#39;{change.Item3}&#39; and &#34;</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> sb.ToString();
</span></span><span style="display:flex;"><span>    }
</span></span></code></pre></div><p>When we bring it all together in a test application like this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#66d9ef">static</span> <span style="color:#66d9ef">void</span> Main(<span style="color:#66d9ef">string</span>[] args)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    Console.WriteLine(<span style="color:#e6db74">&#34;Hello, World!&#34;</span>);
</span></span><span style="display:flex;"><span>    TestDbContext dbContext = <span style="color:#66d9ef">new</span> TestDbContext();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    List&lt;Person&gt; persons = dbContext.Persons.ToList();
</span></span><span style="display:flex;"><span>    Person person1 = persons.First();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    List&lt;PersonChange&gt; personChanges = dbContext.PersonChanges
</span></span><span style="display:flex;"><span>        .Where(x =&gt; x.Id == person1.Id)
</span></span><span style="display:flex;"><span>        .OrderBy(x =&gt; x.ModifiedAt)
</span></span><span style="display:flex;"><span>        .AsNoTracking()
</span></span><span style="display:flex;"><span>        .ToList();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>   <span style="color:#66d9ef">foreach</span> (PersonChange change <span style="color:#66d9ef">in</span> personChanges)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        Console.WriteLine(
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">$&#34;[{change.ModifiedAt}] {change.ModifiedBy} &#34;</span> +
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;updated the the properties &#34;</span> + 
</span></span><span style="display:flex;"><span>            FormatChange&lt;PersonChange&gt;(change));
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>We get this output:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span>[<span style="color:#ae81ff">2024</span><span style="color:#f92672">-</span><span style="color:#ae81ff">02</span><span style="color:#f92672">-</span><span style="color:#ae81ff">03</span> <span style="color:#ae81ff">11</span>:<span style="color:#ae81ff">30</span>:<span style="color:#ae81ff">19</span>] JSC updated the the properties Firstname <span style="color:#66d9ef">from</span> <span style="color:#e6db74">&#39;Jeppe1&#39;</span> <span style="color:#66d9ef">to</span> <span style="color:#e6db74">&#39;Jeppe&#39;</span>
</span></span><span style="display:flex;"><span>[<span style="color:#ae81ff">2024</span><span style="color:#f92672">-</span><span style="color:#ae81ff">02</span><span style="color:#f92672">-</span><span style="color:#ae81ff">03</span> <span style="color:#ae81ff">11</span>:<span style="color:#ae81ff">35</span>:<span style="color:#ae81ff">23</span>] JSC updated the the properties Lastname <span style="color:#66d9ef">from</span> <span style="color:#e6db74">&#39;Spanggaard&#39;</span> <span style="color:#66d9ef">to</span> <span style="color:#e6db74">&#39;Christensen&#39;</span>
</span></span></code></pre></div><h2 id="is-it-then-a-better-solution">Is it then a better solution?</h2>
<p>I can&rsquo;t answer that, but for me, it is - and that&rsquo;s because I believe something like this belongs in a database and not in code. Also, for me, it&rsquo;s much easier to read a trigger than a bunch of reflection code😏.</p>
]]></content:encoded></item><item><title>EF for SharePoint</title><link>https://jeppe-spanggaard.dk/blogs/entity-framework-for-sharepoint/</link><pubDate>Sun, 23 Jul 2023 00:00:00 +0000</pubDate><author>Jeppe</author><guid>https://jeppe-spanggaard.dk/blogs/entity-framework-for-sharepoint/</guid><description>Why and how? Wouldn’t it be great to have the ability to retrieve data from SharePoint in the same manner you are familiar with from Entity Framework? :face_with_monocle: At least that’s what I thought when I began working with SharePoint, having worked with EF for several years.
I thought there must be a way to retrieve data from SharePoint without having to work with dictionaries and create custom parsers to convert it into class models.</description><content:encoded><![CDATA[<h2 id="why-and-how">Why and how?</h2>
<p>Wouldn&rsquo;t it be great to have the ability to retrieve data from SharePoint in the same manner you are familiar with from Entity Framework? :face_with_monocle:
At least that&rsquo;s what I thought when I began working with SharePoint, having worked with EF for several years.</p>
<p>I thought there must be a way to retrieve data from SharePoint without having to work with dictionaries and create custom parsers to convert it into class models.</p>
<p>And then it hit me! Why not attempt to replicate the approach used by Entity Framework? EF is user-friendly, easily extensible, and does not require numerous custom parsers.</p>
<p>So that&rsquo;s why I&rsquo;ve started a Proof of Concept (POC), where I aim to implement the functionality from EF. I have made my repository public, so everyone is welcome to provide input and contribute ideas.</p>
<p><a href="https://github.com/jeppesc11/ListItemDynamicMapper">See GitHub repository</a></p>
<h2 id="how-does-it-work">How does it work?</h2>
<p>To make it work, you need to add a number of custom attributes to its class models.</p>
<ul>
<li>SPList</li>
<li>SPField</li>
</ul>
<p><strong>Example class</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#a6e22e">[SPList(listGuid: &#34;128b4e17-691a-4e28-9934-7bd399ba907a&#34;, listTitle: &#34;EmployeeTasks&#34;)]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">internal</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">EmployeeTaskModel</span> {
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">  [SPField(internalName: &#34;ID&#34;)]</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> Id { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">  [SPField(internalName: &#34;Title&#34;)]</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">string?</span> Title { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">  [SPField(internalName: &#34;EmployeeTask_Done&#34;)]</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">bool?</span> Done { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">  [SPField(internalName: &#34;EmployeeTask_Employeer&#34;)]</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">public</span> FieldLookupValue? Employeer { <span style="color:#66d9ef">get</span>; <span style="color:#66d9ef">set</span>; }
</span></span><span style="display:flex;"><span>} 
</span></span></code></pre></div><p><strong>SPList</strong> - It should be thought of as similar to the Table-attribute (e.g [Table(&ldquo;StudentMaster&rdquo;)]) from EF, but it accepts both a GUID and a title. First, the code will attempt to find the list using the GUID, and if not found, it will try to find it using the title.</p>
<p><strong>SPField</strong> - It should be regarded as the Column-attribute from EF, and similarly, it accepts the internal name of the list field. This will be expanded significantly in the future. One of the ideas I have is to allow you to define whether it should be converted to an enum as well.</p>
<h2 id="how-to-use-it">How to use it</h2>
<p>To use this, it doesn&rsquo;t take much effort as it is neatly packed away. Therefore, only a few methods are exposed/displayed.
However, it must be noted that it is still only a POC, and therefore, not everything is thoroughly tested or handled for errors very gracefully.</p>
<p>A prerequisite for using this is to have the <a href="https://pnp.github.io/pnpcore/">PnP Core SDK</a> installed, as all extensions, etc., are built on PnP&rsquo;s web context and models.</p>
<h3 id="get-list-items">Get list items</h3>
<p>Normally, data would need to be extracted in the following way.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> list = context.Web.Lists.GetById(<span style="color:#66d9ef">new</span> Guid(...), p =&gt; p.Title, p =&gt; p.Items, p =&gt; p.Fields);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> listItems = list.Items.AsRequested();
</span></span></code></pre></div><p>Now, it is neatly packed away, so data is extracted in this manner. Unlike the previous method, this one parses the data to the specified class model.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span>IEnumerable&lt;EmployeeTaskModel&gt; items = context.Web.GetItems&lt;EmployeeTaskModel&gt;(p =&gt; ...);
</span></span></code></pre></div><h3 id="update-a-list-item">Update a list item</h3>
<p>Updating a list item can be done incredibly easily and quickly. Just take a look at this method:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-cs" data-lang="cs"><span style="display:flex;"><span><span style="color:#66d9ef">internal</span> EmployeeTaskModel UpdateEmployeeTask(EmployeeTaskModel employeeTask){
</span></span><span style="display:flex;"><span>  EmployeeTaskModel updatedTask = context.Web.UpdateItem(employeeTask);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> updatedTask;
</span></span><span style="display:flex;"><span>} 
</span></span></code></pre></div><h2 id="to-summarize">To summarize</h2>
<p>This POC was created to determine if it was feasible to extract data from SharePoint in an easy and efficient manner, similar to what we are accustomed to with EF.</p>
<p>I will continue working on this POC with the hope that one day it will evolve beyond just a proof of concept. Perhaps, in the future, it could become a part of the PnP Core SDK? :star_struck:</p>
]]></content:encoded></item></channel></rss>