<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:a10="http://www.w3.org/2005/Atom" version="2.0">
  <channel xmlns:media="http://search.yahoo.com/mrss/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <title>ABP.IO Stories</title>
    <link>https://abp.io/community/articles</link>
    <description>A hub for ABP Framework, .NET, and software development. Access articles, tutorials, news, and contribute to the ABP community.</description>
    <lastBuildDate>Wed, 30 Sep 2026 17:49:33 Z</lastBuildDate>
    <generator>Community - ABP.IO</generator>
    <image>
      <url>https://abp.io/assets/favicon.ico/favicon-32x32.png</url>
      <title>ABP.IO Stories</title>
      <link>https://abp.io/community/articles</link>
    </image>
    <a10:link rel="self" type="application/rss+xml" title="self" href="https://abp.io/community/rss?member=salih" />
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/quickgrid-in-.net-11-sorting-and-paging-that-live-in-the-url-hypzmikw</guid>
      <link>https://abp.io/community/posts/quickgrid-in-.net-11-sorting-and-paging-that-live-in-the-url-hypzmikw</link>
      <a10:author>
        <a10:name>salih</a10:name>
        <a10:uri>https://abp.io/community/members/salih</a10:uri>
      </a10:author>
      <category>blazor</category>
      <category>asp.net-core</category>
      <category>new-features</category>
      <category>.net</category>
      <title>QuickGrid in .NET 11: Sorting and Paging That Live in the URL</title>
      <description>.NET 11 puts QuickGrid's sort and page state in the URL (?sort=Name&amp;direction=desc&amp;page=2), so sorting and paging finally work on statically rendered Blazor pages — and every grid state becomes a link you can share, bookmark, or go Back to. A working static-SSR sample, tested edge cases, and the RC1 caveats to know before adopting it.</description>
      <pubDate>Tue, 29 Sep 2026 11:51:55 Z</pubDate>
      <a10:updated>2026-09-30T15:51:22Z</a10:updated>
      <content:encoded><![CDATA[<h1>QuickGrid in .NET 11: Sorting and Paging That Live in the URL</h1>
<blockquote>
<p><strong>Release status.</strong> Everything in this article was built and tested on <strong>.NET 11 RC1</strong> (SDK <code>11.0.100-rc.1.26425.128</code>, <code>Microsoft.AspNetCore.Components.QuickGrid</code> <code>11.0.0-rc.1.26425.128</code>, released September 8, 2026). RC1 ships with a go-live license, but it is not the final release. Query parameter names and defaults described here are the RC1 behavior — they were renamed once during the previews (details below), so recheck them when you move to RC2 or GA, planned for .NET Conf in November 2026.</p>
</blockquote>
<p><code>QuickGrid</code> has always been a good data grid with one structural limitation: its sort column, sort direction, and page index lived only in component memory, and its controls were <code>&lt;button&gt;</code>s wired to <code>@onclick</code>. On statically server-side rendered pages — pages with no interactive render mode — sorting and paging simply did nothing. On interactive pages, the state was still unshareable: you couldn't bookmark page 4, send a colleague a link to &quot;the error logs sorted by time, descending&quot;, or press Back and land where you were.</p>
<p>.NET 11 moves that state into the query string. Sortable column headers and the <code>Paginator</code> now render as ordinary <code>&lt;a&gt;</code> links whose <code>href</code> is the URL of the next state — <code>?sort=Name&amp;direction=desc&amp;page=2</code>. Because the state is in the request, it works identically in static SSR, during prerendering, and after the circuit or WebAssembly runtime takes over. I built a small static-SSR-only Blazor app to try it, and this article walks through what actually renders — including a few rough edges I hit on RC1.</p>
<p><strong>TL;DR</strong></p>
<ul>
<li><strong>Static SSR works.</strong> Sorting and paging are functional on pages with no interactivity at all — headers and the paginator are <code>&lt;a&gt;</code> links carrying <code>sort</code>, <code>direction</code>, and <code>page</code> query parameters.</li>
<li><strong>The URL is the state store.</strong> Refresh, Back/Forward, bookmarks, and &quot;copy link&quot; all preserve the grid state because the state is the address.</li>
<li><strong>Your own parameters coexist.</strong> A <code>[SupplyParameterFromQuery]</code> filter like <code>?country=Germany</code> is preserved inside QuickGrid's generated links; your own links decide whether to keep the grid's.</li>
<li><strong>Multiple grids need distinct parameter names.</strong> The new <code>QueryParameterNameOptions</code> parameter renames <code>sort</code>/<code>direction</code>/<code>page</code> per grid (e.g. <code>?c_sort=Name&amp;c_page=2</code>). Two grids sharing the defaults fight over the same keys.</li>
<li><strong><code>page</code> is 1-based in the URL</strong> even though <code>PaginationState.CurrentPageIndex</code> stays 0-based.</li>
<li><strong>Renamed during previews.</strong> Preview 5–6 shipped <code>QueryParameterNamePrefix</code> and an <code>order</code> parameter; Preview 7 replaced it with <code>QueryParameterNameOptions</code> and renamed <code>order</code> → <code>direction</code>. Code written against Preview 5/6 articles won't compile unchanged on RC1.</li>
<li><strong>Breaking markup change:</strong> <code>button.col-title</code> becomes <code>a.col-title</code>. CSS and E2E selectors targeting <code>button</code> need updating. An <code>AppContext</code> switch reverts to buttons — but then the controls are dead in SSR again.</li>
<li><strong>RC1 rough edge:</strong> with <code>GridItemsProvider</code>, a <code>?page=</code> request invokes the provider <strong>twice</strong> — the second call is a duplicate. Details in <a href="#server-side-paging-with-griditemsprovider">Server-side paging</a>.</li>
</ul>
<h2>Why the URL, and why it matters architecturally</h2>
<p>Before the markup, it's worth being clear about what problem this solves. A data grid has three pieces of mutable view state — <em>which column sorts</em>, <em>which direction</em>, <em>which page</em> — and only a few places that state can live:</p>
<p>| Where state lives | Shareable/bookmarkable | Survives refresh | Back/Forward | Works in static SSR | Server resources |
|---|---|---|---|---|---|
| Component memory (.NET ≤ 10) | No | No | No | No (controls dead) | Circuit per user |
| <code>Session</code>/<code>TempData</code> | No | Yes | No | Yes, but per-user | Server store + cookies |
| <strong>Query string (.NET 11)</strong> | <strong>Yes</strong> | <strong>Yes</strong> | <strong>Yes</strong> | <strong>Yes</strong> | <strong>None</strong> |</p>
<p>The query string is the only option that's shareable, refresh-safe, history-friendly, SSR-compatible, and stateless on the server all at once. Every state transition is just a GET: no session affinity, no circuit to hold open, no memory to expire. It's also what the rest of the web already does for list views — search results and e-commerce category pages have encoded <code>?sort=&amp;page=</code> in the address bar for decades.</p>
<p>The mechanism, per request:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-09-23-quickgrid-url-state-static-ssr/images/url-state-loop.png" alt="Request lifecycle: the server reads the query string, applies state to the data, renders controls as links; the browser navigates to a new URL on click" /></p>
<ol>
<li>On each request the grid reads <code>sort</code>, <code>direction</code>, and <code>page</code> from the query string.</li>
<li>It applies them to the data — sorting the <code>IQueryable</code> itself for <code>Items</code>, or passing <code>StartIndex</code>/<code>Count</code>/sort descriptors to your <code>GridItemsProvider</code>.</li>
<li>It renders every state-changing control as an <code>&lt;a&gt;</code> whose <code>href</code> is the URL that produces the <em>next</em> state. Clicking &quot;Name&quot; while it sorts descending produces the <code>direction=asc</code> URL; clicking &quot;next&quot; produces <code>page=3</code>.</li>
</ol>
<p>In the browser, <code>blazor.web.js</code> intercepts these clicks as <strong>enhanced navigations</strong> — a fetch plus a DOM patch plus a <code>pushState</code>, so the address bar updates without a full reload. With JavaScript disabled or absent, the same <code>href</code> performs an ordinary full-page GET. Both paths land on identical markup, which is what makes the feature progressive enhancement rather than a JS dependency.</p>
<h2>The sample app</h2>
<p>The sample is a Blazor Web App whose main pages are <strong>static SSR — no interactive render mode</strong> — the scenario that was impossible before .NET 11. (One page later opts into <code>InteractiveServer</code> to compare behavior.)</p>
<pre><code class="language-bash">dotnet new blazor -n QuickGridUrlState --interactivity None --empty
cd QuickGridUrlState
dotnet add package Microsoft.AspNetCore.Components.QuickGrid --version 11.0.0-rc.1.26425.128
dotnet run --urls http://localhost:5055
</code></pre>
<p>The runnable project, including the integration tests, is <a href="https://github.com/salihozkara/quickgrid-url-state">salihozkara/quickgrid-url-state</a>. Clone that repository to run the pages and <code>dotnet test</code> without reassembling the snippets below.</p>
<p>The data layer is 247 deterministically generated <code>Person</code> rows (fixed seed, so results are reproducible), exposed through a singleton store:</p>
<pre><code class="language-csharp">public sealed record Person(int Id, string Name, string Country, int Age, DateOnly StartDate);

public sealed class PersonStore
{
    private static readonly string[] FirstNames = [ &quot;Amelia&quot;, &quot;Oliver&quot;, &quot;Salih&quot;, /* ... */ ];
    private static readonly string[] LastNames  = [ &quot;Smith&quot;, &quot;Yilmaz&quot;, &quot;Tiurina&quot;, /* ... */ ];
    private static readonly string[] Countries  = [ &quot;United Kingdom&quot;, &quot;Türkiye&quot;, &quot;Germany&quot;, /* ... */ ];

    public IReadOnlyList&lt;Person&gt; People { get; }

    public PersonStore()
    {
        var rng = new Random(Seed: 20260923); // fixed seed: same 247 rows on every run
        People = Enumerable.Range(1, 247)
            .Select(id =&gt; new Person(
                Id: id,
                Name: $&quot;{FirstNames[rng.Next(FirstNames.Length)]} {LastNames[rng.Next(LastNames.Length)]}&quot;,
                Country: Countries[rng.Next(Countries.Length)],
                Age: rng.Next(21, 68),
                StartDate: DateOnly.FromDayNumber(rng.Next(
                    DateOnly.FromDateTime(DateTime.Today.AddYears(-6)).DayNumber,
                    DateOnly.FromDateTime(DateTime.Today).DayNumber))))
            .ToList();
    }
}
</code></pre>
<p><code>Program.cs</code> is the stock template plus one registration and — for the interactive page used later — the usual interactive-server lines:</p>
<pre><code class="language-csharp">builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();
builder.Services.AddSingleton&lt;PersonStore&gt;();
// ...
app.MapRazorComponents&lt;App&gt;()
    .AddInteractiveServerRenderMode();
</code></pre>
<p>The page that matters is <code>Components/Pages/People.razor</code>:</p>
<pre><code class="language-razor">@page &quot;/people&quot;
@using Microsoft.AspNetCore.Components.QuickGrid
@using QuickGridUrlState.Data
@inject PersonStore Store
@inject NavigationManager Nav

&lt;PageTitle&gt;People — QuickGrid URL state&lt;/PageTitle&gt;

&lt;h1&gt;People&lt;/h1&gt;

&lt;p class=&quot;lede&quot;&gt;
    Sortable columns and the paginator are ordinary &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; links.
    Current address: &lt;code&gt;@Nav.Uri&lt;/code&gt;
&lt;/p&gt;

&lt;div class=&quot;filters&quot;&gt;
    Filter:
    &lt;a href=&quot;@FilterHref(null)&quot;&gt;All&lt;/a&gt;
    @foreach (var c in Store.People.Select(p =&gt; p.Country).Distinct().Order())
    {
        &lt;a href=&quot;@FilterHref(c)&quot; class=&quot;@(c == Country ? &quot;active&quot; : null)&quot;&gt;@c&lt;/a&gt;
    }
&lt;/div&gt;

&lt;QuickGrid Items=&quot;@FilteredPeople&quot; Pagination=&quot;@pagination&quot; Class=&quot;people-grid&quot;&gt;
    &lt;PropertyColumn Property=&quot;@(p =&gt; p.Id)&quot; Sortable=&quot;true&quot; Title=&quot;Id&quot; /&gt;
    &lt;PropertyColumn Property=&quot;@(p =&gt; p.Name)&quot; Sortable=&quot;true&quot; Title=&quot;Name&quot; /&gt;
    &lt;PropertyColumn Property=&quot;@(p =&gt; p.Country)&quot; Sortable=&quot;true&quot; Title=&quot;Country&quot; /&gt;
    &lt;PropertyColumn Property=&quot;@(p =&gt; p.Age)&quot; Sortable=&quot;true&quot; Title=&quot;Age&quot; /&gt;
    &lt;PropertyColumn Property=&quot;@(p =&gt; p.StartDate)&quot; Sortable=&quot;true&quot; Title=&quot;Start date&quot; Format=&quot;yyyy-MM-dd&quot; /&gt;
&lt;/QuickGrid&gt;

&lt;Paginator State=&quot;@pagination&quot; /&gt;

@code {
    private readonly PaginationState pagination = new() { ItemsPerPage = 10 };

    [SupplyParameterFromQuery(Name = &quot;country&quot;)]
    public string? Country { get; set; }

    private IQueryable&lt;Person&gt; FilteredPeople =&gt; Store.People
        .Where(p =&gt; Country is null || p.Country == Country)
        .AsQueryable();

    private string FilterHref(string? country) =&gt;
        country is null ? &quot;/people&quot; : $&quot;/people?country={Uri.EscapeDataString(country)}&quot;;
}
</code></pre>
<p>Nothing in this page is new code — that's the point. The same <code>QuickGrid</code>/<code>Paginator</code>/<code>PaginationState</code> trio that needed interactivity in .NET 10 now works on a static page in .NET 11, because the controls render as links and the state arrives via the URL.</p>
<h2>The URL contract</h2>
<p>Here's a real URL the app produces, with the parts labeled:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-09-23-quickgrid-url-state-static-ssr/images/url-anatomy.png" alt="Anatomy of the URL: your own parameters like country=Germany sit next to QuickGrid's sort, direction, and page parameters, which can be renamed via QueryParameterNameOptions" /></p>
<p>| Parameter | Default name | Values | Notes |
|---|---|---|---|
| Sort column | <code>sort</code> | The column's <strong><code>Title</code></strong>, URL-encoded | Case-<strong>sensitive</strong> match against <code>Title</code>. <code>?sort=Start%20date</code> sorts the &quot;Start date&quot; column; <code>?sort=name</code> matches nothing and is ignored. |
| Sort direction | <code>direction</code> | <code>asc</code>, <code>desc</code> | Case-<strong>insensitive</strong> (<code>DESC</code> works). <code>asc</code>/<code>desc</code> are the only valid values — <code>Ascending</code>, <code>none</code>, or empty are ignored. |
| Page | <code>page</code> | 1-based integer | <code>?page=2</code> is the second page. Page 1 links <strong>omit</strong> the parameter entirely, so the canonical first-page URL stays clean. Out-of-range values clamp; garbage falls back to page 1. |</p>
<p>A few rules worth remembering:</p>
<ul>
<li><strong>The sort key is the column <code>Title</code>, not the property name.</strong> <code>Title</code> is also what users see, which keeps URLs readable (<code>?sort=Start%20date</code>). The cost: renaming a column title invalidates existing bookmarks, and a <code>TemplateColumn</code> with <code>SortBy</code> but no <code>Title</code> renders a non-linked, unsortable header in URL mode — give sortable template columns a <code>Title</code>.</li>
<li><strong><code>PropertyColumn</code> infers <code>Title</code> from the member name</strong> when you don't set one, so <code>Property=&quot;p =&gt; p.Name&quot;</code> is already URL-sortable as <code>?sort=Name</code>.</li>
<li><strong>Both <code>sort</code> and <code>direction</code> must be present</strong> for a URL sort to take effect. <code>?sort=Name</code> alone is ignored (the grid treats it as &quot;no sort instruction&quot;), and <code>?direction=desc</code> alone does nothing. QuickGrid's own links always emit the pair.</li>
</ul>
<h2>What actually renders</h2>
<p>Requesting <code>/people?sort=Name&amp;direction=desc&amp;page=2</code> produces this markup (abridged — Blazor's internal <code>b-*</code> attributes omitted):</p>
<pre><code class="language-html">&lt;table theme=&quot;default&quot; aria-rowcount=&quot;11&quot; class=&quot;quickgrid people-grid&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th class=&quot;col-justify-start&quot; aria-sort=&quot;none&quot; scope=&quot;col&quot;&gt;
        &lt;div class=&quot;col-header-content&quot;&gt;
          &lt;a class=&quot;col-title&quot; href=&quot;http://localhost:5055/people?sort=Id&amp;amp;direction=asc&amp;amp;page=2&quot;&gt;
            &lt;div class=&quot;col-title-text&quot;&gt;Id&lt;/div&gt;
            &lt;div class=&quot;sort-indicator&quot; aria-hidden=&quot;true&quot;&gt;&lt;/div&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;/th&gt;
      &lt;th class=&quot;col-justify-start col-sort-desc&quot; aria-sort=&quot;descending&quot; scope=&quot;col&quot;&gt;
        &lt;div class=&quot;col-header-content&quot;&gt;
          &lt;a class=&quot;col-title&quot; href=&quot;http://localhost:5055/people?sort=Name&amp;amp;direction=asc&amp;amp;page=2&quot;&gt;
            &lt;div class=&quot;col-title-text&quot;&gt;Name&lt;/div&gt;
            &lt;div class=&quot;sort-indicator&quot; aria-hidden=&quot;true&quot;&gt;&lt;/div&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;/th&gt;
      &lt;!-- ... --&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;!-- tbody rows: Zeynep Brown, Yunus Thomas, ... --&gt;
&lt;/table&gt;

&lt;div class=&quot;paginator&quot;&gt;
  &lt;div class=&quot;summary&quot;&gt;&lt;strong&gt;247&lt;/strong&gt; items&lt;/div&gt;
  &lt;nav role=&quot;navigation&quot;&gt;
    &lt;a class=&quot;go-first&quot;    href=&quot;.../people?sort=Name&amp;amp;direction=desc&quot;          aria-label=&quot;Go to first page&quot;&gt;«&lt;/a&gt;
    &lt;a class=&quot;go-previous&quot; href=&quot;.../people?sort=Name&amp;amp;direction=desc&quot;          aria-label=&quot;Go to previous page&quot;&gt;‹&lt;/a&gt;
    &lt;div class=&quot;pagination-text&quot;&gt;Page &lt;strong&gt;2&lt;/strong&gt; of &lt;strong&gt;25&lt;/strong&gt;&lt;/div&gt;
    &lt;a class=&quot;go-next&quot;     href=&quot;.../people?sort=Name&amp;amp;direction=desc&amp;amp;page=3&quot;  aria-label=&quot;Go to next page&quot;&gt;›&lt;/a&gt;
    &lt;a class=&quot;go-last&quot;     href=&quot;.../people?sort=Name&amp;amp;direction=desc&amp;amp;page=25&quot; aria-label=&quot;Go to last page&quot;&gt;»&lt;/a&gt;
  &lt;/nav&gt;
&lt;/div&gt;
</code></pre>
<p>A few things stand out in that markup:</p>
<ul>
<li>The hrefs are <strong>absolute</strong> (<code>http://localhost:5055/people?...</code>), built from the current <code>NavigationManager</code> URI.</li>
<li><strong>Every control preserves the rest of the state.</strong> On page 2 sorted by name descending, the &quot;Id&quot; header links to <code>?sort=Id&amp;direction=asc&amp;page=2</code> — changing the sort does <strong>not</strong> reset the page. Paginator links likewise keep <code>sort</code>/<code>direction</code>.</li>
<li><strong>The active column's link toggles.</strong> Sorted <code>desc</code>, the Name header offers <code>direction=asc</code>. The cycle is asc → desc → asc…; there's no &quot;unsorted&quot; link target — removing the sort means editing the URL.</li>
<li><strong><code>aria-sort</code> is correct</strong> on the <code>&lt;th&gt;</code> (<code>descending</code> here, <code>none</code> on the others), plus <code>scope=&quot;col&quot;</code>, an <code>aria-hidden</code> indicator glyph, <code>aria-label</code>/<code>title</code> on paginator links, and <code>aria-disabled=&quot;true&quot;</code> + <code>tabindex=&quot;-1&quot;</code> on the disabled first/previous links.</li>
<li><strong>&quot;Go to first/previous&quot; from page 2 omit <code>page</code></strong> — the default state needs no parameter.</li>
</ul>
<p>And in the browser:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-09-23-quickgrid-url-state-static-ssr/images/screenshot-sorted-paged.png" alt="The /people page at ?sort=Name&amp;direction=desc&amp;page=2 — Name column sorted descending with the sort indicator, paginator showing Page 2 of 25, and the address visible in-page" /></p>
<p><em>Figure: <code>/people?sort=Name&amp;direction=desc&amp;page=2</code>. The in-page &quot;Current address&quot; line is the sample's <code>NavigationManager.Uri</code> — the same string a user can copy and share.</em></p>
<h2>Your own parameters are first-class citizens</h2>
<p>The filter chips in the sample set <code>?country=</code> — a parameter QuickGrid knows nothing about, bound via <code>[SupplyParameterFromQuery]</code> on the page. Two behaviors make this work nicely:</p>
<ol>
<li><strong>QuickGrid links preserve foreign parameters.</strong> On <code>/people?country=Germany</code>, the Name header renders <code>?country=Germany&amp;sort=Name&amp;direction=asc</code> — the grid merges its state into the existing query string rather than replacing it.</li>
<li><strong>Your links can drop grid state on purpose.</strong> The filter chips link to <code>/people?country=X</code> with no <code>sort</code>/<code>direction</code>/<code>page</code>, which resets the grid to page 1 unsorted — usually what you want when the result set changes. If you wanted the filter to keep sorting, you'd copy the grid params into your own links the same way.</li>
</ol>
<p><code>/people?country=Germany&amp;sort=Age&amp;direction=asc&amp;page=2</code>:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-09-23-quickgrid-url-state-static-ssr/images/screenshot-filtered.png" alt="Filtered view: country=Germany active chip, 36 items, sorted by Age ascending, page 2 of 4" /></p>
<p><em>Figure: app-owned <code>country</code> filter combined with grid-owned <code>sort</code>/<code>direction</code>/<code>page</code> in one URL — fully bookmarkable.</em></p>
<p>That composability is the quiet win here: QuickGrid owns three parameters, you own the rest of the query string, and nobody needs a shared state container or an event bus to coordinate them.</p>
<h2>Two grids on one page</h2>
<p>Default parameter names are only safe when there is exactly one grid. The <code>/multi</code> page mounts a second grid with a prefix:</p>
<pre><code class="language-razor">&lt;QuickGrid Items=&quot;@Countries&quot; Pagination=&quot;@countryPagination&quot;
           QueryParameterNameOptions=&quot;@(new QueryParameterNameOptions(&quot;c_&quot;))&quot; Class=&quot;people-grid&quot;&gt;
    &lt;PropertyColumn Property=&quot;@(c =&gt; c.Name)&quot; Sortable=&quot;true&quot; Title=&quot;Name&quot; /&gt;
    &lt;PropertyColumn Property=&quot;@(c =&gt; c.People)&quot; Sortable=&quot;true&quot; Title=&quot;People&quot; /&gt;
&lt;/QuickGrid&gt;
&lt;Paginator State=&quot;@countryPagination&quot; /&gt;
</code></pre>
<p>The constructor argument is the <strong>prefix including its own separator</strong> — <code>new QueryParameterNameOptions(&quot;c_&quot;)</code> yields <code>c_sort</code>, <code>c_direction</code>, <code>c_page</code>. Each name is also settable independently:</p>
<pre><code class="language-csharp">new QueryParameterNameOptions { Page = &quot;p2&quot; }   // sort, direction stay default; page becomes p2
</code></pre>
<p>Both grids operate independently in one URL — <code>/multi?sort=Name&amp;direction=desc&amp;page=2&amp;c_sort=People&amp;c_direction=desc&amp;c_page=2</code>:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-09-23-quickgrid-url-state-static-ssr/images/screenshot-multi-grid.png" alt="Two QuickGrids on /multi with independent sort and page state in prefixed query parameters" /></p>
<p>Two traps to know about:</p>
<ul>
<li><strong>No prefix = shared state.</strong> Two grids with the default names both read <code>?page=</code>/<code>?sort=</code> and interfere with each other (the <a href="https://github.com/dotnet/aspnetcore/issues/66830">API proposal</a> calls this out as a pit-of-failure). If you add a second grid to a page, prefix it.</li>
<li><strong><code>QueryParameterNameOptions</code> is per-<code>QuickGrid</code>.</strong> The <code>Paginator</code> picks up the page parameter name through its linked <code>PaginationState</code>, so the <code>&lt;Paginator&gt;</code> tag needs no extra attribute — as long as it's bound to that grid's <code>PaginationState</code>.</li>
</ul>
<h2>Server-side paging with <code>GridItemsProvider</code></h2>
<p><code>Items</code> keeps everything in memory; real apps page at the database. With <code>ItemsProvider</code>, the resolved state arrives per request:</p>
<pre><code class="language-csharp">peopleProvider = async request =&gt;
{
    Logger.LogInformation(
        &quot;ItemsProvider called: StartIndex={StartIndex} Count={Count} Sort=[{Sort}]&quot;,
        request.StartIndex, request.Count,
        string.Join(&quot;, &quot;, request.GetSortByProperties().Select(s =&gt; $&quot;{s.PropertyName} {s.Direction}&quot;)));

    var sorted = request.ApplySorting(Store.People.AsQueryable());
    var slice = sorted.Skip(request.StartIndex).Take(request.Count ?? 10).ToList();
    await Task.Delay(150, request.CancellationToken); // pretend this was a database
    return GridItemsProviderResult.From(slice, Store.People.Count);
};
</code></pre>
<p>In an EF Core app, <code>ApplySorting</code> + <code>Skip</code>/<code>Take</code> translate to <code>ORDER BY</code>/<code>OFFSET</code>/<code>FETCH</code>, so the URL state reaches the database query directly. Requesting four URLs in a row produced this log — and one surprise:</p>
<pre><code class="language-text">GET /provider                                      → StartIndex=0  Count=10 Sort=[]
GET /provider?page=2                               → StartIndex=10 Count=10 Sort=[]
                                                     StartIndex=10 Count=10 Sort=[]   ← again
GET /provider?sort=Name&amp;direction=asc              → StartIndex=0  Count=10 Sort=[Name Ascending]
GET /provider?sort=Name&amp;direction=desc&amp;page=4      → StartIndex=30 Count=10 Sort=[]
                                                     StartIndex=30 Count=10 Sort=[Name Descending]
</code></pre>
<p><strong>On RC1, any request with <code>page</code> in the URL invokes the provider twice.</strong> With <code>page</code> alone the second call is an identical duplicate; with <code>sort</code>+<code>page</code>, the first call has the page applied but <strong>not</strong> the sort, and the second adds it. A sort-only URL issues a single call with the sort already applied, so the double fetch is specific to the pagination path. (Probably related to <a href="https://github.com/dotnet/aspnetcore/issues/69381">dotnet/aspnetcore#69381</a>, which tracks duplicate <code>ItemsProvider</code> lifecycles elsewhere in QuickGrid on RC1.) In practice that means every paged SSR request currently runs the provider twice — two count + page queries if that's how your provider is written. Until it's fixed: use <code>Items</code> for modest tables, or cache inside the provider.</p>
<h2>Interactive pages keep the same contract</h2>
<p><code>PeopleInteractive.razor</code> is the same grid with <code>@rendermode InteractiveServer</code>. Two things worth noting:</p>
<ul>
<li><strong>The prerendered HTML already reflects the URL.</strong> <code>/people-interactive?sort=Name&amp;direction=asc</code> renders <code>aria-sort=&quot;ascending&quot;</code> and the sorted rows in the initial document — no flash of unsorted content while the circuit connects, because the static pass reads the URL the same way.</li>
<li><strong>Controls stay <code>&lt;a&gt;</code> links once interactive.</strong> Clicks go through Blazor's router/enhanced navigation instead of a full GET, but the state still round-trips through the address bar — so Back/Forward, refresh, and link sharing keep working interactively too.</li>
</ul>
<p>That second point is the real upgrade: even in interactive apps, grid state isn't trapped in the component anymore. A user can lose the circuit, reload, and land on the same sorted page — something in-memory state could never give you.</p>
<h2>Edge cases I tested</h2>
<p>All by plain HTTP requests against the running RC1 app:</p>
<p>| Request | Result |
|---|---|
| <code>?sort=Name&amp;direction=desc</code> | Name desc, <code>aria-sort=&quot;descending&quot;</code> |
| <code>?page=3</code> | Rows 21–30 — <code>page</code> is 1-based |
| <code>?page=999</code> | Last page (25), HTTP 200 — clamps, no redirect |
| <code>?page=0</code>, <code>?page=-1</code>, <code>?page=abc</code> | Page 1 — invalid values fall back silently |
| <code>?sort=BogusColumn</code> | Ignored — grid renders unsorted |
| <code>?sort=name</code> | Ignored — <code>sort</code> matching is case-<strong>sensitive</strong> on <code>Title</code> |
| <code>?sort=Name</code> (no <code>direction</code>) | Ignored — the pair is required |
| <code>?direction=desc</code> (no <code>sort</code>) | Ignored |
| <code>?direction=DESC</code> | Works — direction matching is case-insensitive |
| <code>?direction=Ascending</code> / <code>none</code> / empty | Ignored — only <code>asc</code>/<code>desc</code> are valid |
| <code>?sort=Age&amp;sort=Name&amp;direction=asc</code> | First value wins (Age asc) |
| <code>?sort=Start%20date&amp;direction=desc</code> | Works — URL-decoded <code>Title</code> matches |
| <code>?page=2</code> on a grid without <code>Pagination</code> | Ignored, no error |
| Bare URL on a grid with <code>IsDefaultSortColumn</code> | Default sort applies; an explicit <code>?sort=</code> overrides it |</p>
<p>None of these throw — the grid treats the query string as untrusted input and falls back to defaults, which is the right behavior for parameters anyone can paste into the address bar.</p>
<h2>Accessibility and SEO</h2>
<p>Because everything is real markup, most of the accessibility story comes free:</p>
<ul>
<li><strong><code>&lt;a&gt;</code> vs <code>&lt;button&gt;</code>.</strong> Sort headers are true links now (they navigate), which is the right element for the job — screen readers announce &quot;link&quot;, and open-in-new-tab works. Disabled paginator links carry <code>aria-disabled=&quot;true&quot;</code> and <code>tabindex=&quot;-1&quot;</code>.</li>
<li><strong><code>aria-sort</code></strong> shows up on the sorted <code>&lt;th&gt;</code> (<code>ascending</code>/<code>descending</code>, <code>none</code> elsewhere) and <code>scope=&quot;col&quot;</code> on all headers — table semantics are intact.</li>
<li><strong>Focus stays put.</strong> <code>FocusOnNavigate</code> only moves focus to <code>h1</code> when the <em>path</em> changes (it compares origin + pathname, per RC1's <code>blazor.web.js</code>), and sort/page links only change the query string — so focus stays on the clicked link instead of being yanked to the heading on every click. That's what keyboard users want here; just keep the link text/<code>aria-label</code>s meaningful, since there's no separate announcement of the new state.</li>
<li><strong>SEO/no-JS.</strong> The full sorted, paged table is in the HTML response — crawlers and link unfurlers see real rows, and every sort/page permutation is a crawlable URL. Two things to watch: <code>?page=999</code> returns <strong>200 with clamped content</strong> rather than a redirect (add a <code>canonical</code> link if soft-duplicates worry you), and <code>IsDefaultSortColumn</code> makes the bare URL and <code>?sort=X&amp;direction=Y</code> render identical content, so pick one canonical form for links you publish.</li>
</ul>
<h2>Opting out</h2>
<p>URL navigation is on by default. This reverts to the .NET 10-style <code>&lt;button&gt;</code> controls:</p>
<pre><code class="language-csharp">AppContext.SetSwitch(
    &quot;Microsoft.AspNetCore.Components.QuickGrid.EnableUrlBasedQuickGridNavigationAndSorting&quot;,
    false);
</code></pre>
<p>I verified this on RC1: with the switch off, headers and paginator render <code>&lt;button type=&quot;button&quot;&gt;</code> again — <strong>but the URL state is still read</strong>. <code>/people?sort=Name&amp;direction=desc&amp;page=2</code> still renders sorted page 2; the buttons just can't change it in SSR. So the switch is a compatibility escape hatch for interactive apps with custom <code>button.col-title</code>/<code>nav button</code> CSS or JS, not a way to turn URL state off — and it doesn't undo the <code>Paginator</code> lifecycle change covered below. In static SSR it leaves you with dead controls.</p>
<h2>.NET 10 vs .NET 11, and the preview trail</h2>
<p>| | .NET 10 | .NET 11 RC1 |
|---|---|---|
| Sort/page state | Component memory only | URL query string (default) |
| Controls | <code>&lt;button&gt;</code> + <code>@onclick</code> | <code>&lt;a href&gt;</code> — <code>&lt;button&gt;</code> via AppContext opt-out |
| Static SSR sorting/paging | Not functional | Works |
| Shareable/bookmarkable grid state | No | Yes |
| Back/Forward restores grid | No | Yes |
| Multi-grid param isolation | n/a | <code>QueryParameterNameOptions</code> per grid |</p>
<p>Feature timeline, checked against the published NuGet packages:</p>
<p>| Preview | API |
|---|---|
| Preview 5 (PR <a href="https://github.com/dotnet/aspnetcore/pull/65451">#65451</a>) | Feature lands: <code>QueryParameterNamePrefix</code> string parameter, <code>?sort</code>/<code>?order</code>/<code>?page</code> |
| Preview 7 (PR <a href="https://github.com/dotnet/aspnetcore/pull/67733">#67733</a>) | <code>QueryParameterNamePrefix</code> → <code>QueryParameterNameOptions</code>; <code>order</code> → <code>direction</code>; per-name overrides |
| RC1 | Same surface as Preview 7 — <code>QueryParameterNameOptions</code>, <code>direction</code> |</p>
<p>The public surface on RC1, dumped from the assembly:</p>
<pre><code class="language-text">Microsoft.AspNetCore.Components.QuickGrid 11.0.0-rc.1.26425.128

QuickGrid&lt;TGridItem&gt;
  [Parameter] QueryParameterNameOptions QueryParameterNameOptions   // new
  [Parameter] PaginationState Pagination
  ... (Items, ItemsProvider, Virtualize, AnchorMode, ItemKey, RowClass, OnRowClick, ...)

QueryParameterNameOptions (sealed)
  ctor(string prefix = &quot;&quot;)
  string Sort, Direction, Page                                      // settable
</code></pre>
<blockquote>
<p><strong>Reading older preview-era content?</strong> The <a href="https://learn.microsoft.com/en-us/aspnet/core/blazor/components/quickgrid">QuickGrid docs</a> were updated mid-September and describe the current API — but blog posts written against Preview 5/6 still show <code>QueryParameterNamePrefix</code> and <code>?order=</code>. On RC1 that parameter doesn't exist and <code>order</code> is ignored — use <code>QueryParameterNameOptions</code> and <code>direction</code>.</p>
</blockquote>
<h2>Known issues and production readiness</h2>
<ul>
<li><strong>Go-live, not GA.</strong> RC1 is production-supported under Microsoft's go-live license (window ends October 13, 2026 — plan to move to RC2/GA). .NET 11 is an <strong>STS</strong> release (support through November 9, 2028); .NET 10 remains <strong>LTS</strong> (November 14, 2028). The support windows end in the same month, so choose on features and risk, not longevity.</li>
<li><strong>Duplicate provider calls on <code>?page=</code> requests</strong> (<a href="#server-side-paging-with-griditemsprovider">observed above</a>) — budget for it or use <code>Items</code> for small tables. See also <a href="https://github.com/dotnet/aspnetcore/issues/69381">dotnet/aspnetcore#69381</a>.</li>
<li><strong>Markup breaking change.</strong> <code>button.col-title</code> → <code>a.col-title</code> and paginator <code>button</code> → <code>a</code>. CSS selectors and Playwright/Selenium locators targeting <code>button</code> need updating; <code>aria-disabled</code> replaces <code>:disabled</code> for the disabled paginator state.</li>
<li><strong><code>Paginator</code>'s own work moved into <code>OnParametersSetAsync</code>.</strong> Sync <code>OnParametersSet</code> overrides in subclasses still run — <code>ComponentBase</code> invokes both callbacks on every parameter set — but they now run <em>before</em> the paginator reads <code>page</code> from the URL, so ordering changes if your override depends on <code>CurrentPageIndex</code>. And if you override <code>OnParametersSetAsync</code>, call <code>await base.OnParametersSetAsync()</code> or the URL sync is skipped entirely.</li>
<li><strong><code>sort</code> uses <code>Title</code>.</strong> Renaming a column title breaks published/bookmarked URLs; give <code>TemplateColumn</code>s a <code>Title</code> to make them URL-sortable.</li>
<li><strong>Sorting does not reset the page</strong> — sorting while on page 12 keeps page 12 (with clamping). If your UX expects &quot;back to page 1 on re-sort,&quot; note the behavior.</li>
</ul>
<h2>Build and verification results</h2>
<p>Everything above ran against the RC1 SDK and QuickGrid package from the release note at the top — the sample is the <code>dotnet new blazor --interactivity None</code> app described earlier, plus one <code>InteractiveServer</code> page and a small <code>WebApplicationFactory</code> test suite.</p>
<p>| Check | Result |
|---|---|
| <code>dotnet build</code> (app + tests) | 0 warnings, 0 errors |
| <code>dotnet test</code> — 12 integration tests | <strong>12/12 passed</strong> |
| Manual HTTP matrix (sort/page/malformed/multi-grid/opt-out) | Every row in the edge-case table verified |</p>
<p>The tests assert the contract directly: sort headers are <code>&lt;a&gt;</code> links, <code>?sort=Name&amp;direction=desc&amp;page=2</code> renders descending order with <code>aria-sort=&quot;descending&quot;</code> and &quot;Page 2 of 25&quot;, the <code>country</code> filter survives inside generated links, <code>?page=999</code> clamps to the last page, prefixed parameters drive the second grid, and the interactive page's prerendered HTML already carries URL state.</p>
<h2>Adoption checklist</h2>
<ul>
<li>[ ] On .NET 11 RC1+: add/update <code>Microsoft.AspNetCore.Components.QuickGrid</code> to <code>11.0.0-rc.1.*</code> — no code changes needed for single grids.</li>
<li>[ ] Verify sortable columns have meaningful <code>Title</code>s — they become the public <code>?sort=</code> values and bookmark identifiers.</li>
<li>[ ] Set <code>QueryParameterNameOptions</code> prefixes on every grid after the first on a page.</li>
<li>[ ] Update CSS/JS/E2E selectors from <code>button.col-title</code> / <code>nav button</code> to <code>a.col-title</code> / <code>nav a</code>; check <code>aria-disabled</code> instead of <code>:disabled</code>.</li>
<li>[ ] If you subclass <code>Paginator</code>: sync <code>OnParametersSet</code> overrides still run, but before the URL sync — override <code>OnParametersSetAsync</code> and <code>await base</code> when you need the resolved page.</li>
<li>[ ] Decide deliberately whether sorting should reset the page — it doesn't, out of the box.</li>
<li>[ ] Keep filter/search params in the query string (<code>[SupplyParameterFromQuery]</code>) — the grid preserves them for free.</li>
<li>[ ] For <code>GridItemsProvider</code>, measure the duplicate <code>?page=</code> invocation on RC1 and re-check after upgrading.</li>
<li>[ ] Only use the <code>EnableUrlBasedQuickGridNavigationAndSorting</code> opt-out for interactive pages with a concrete compatibility need.</li>
<li>[ ] Add <code>rel=&quot;canonical&quot;</code> (or equivalent) if out-of-range pages and default-sort duplicates matter for SEO.</li>
</ul>
<h2>References</h2>
<ul>
<li><a href="https://learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-11">What's new in ASP.NET Core in .NET 11</a></li>
<li><a href="https://learn.microsoft.com/en-us/aspnet/core/blazor/components/quickgrid">QuickGrid component</a> — current docs describe <code>QueryParameterNameOptions</code>; beware pre-Preview-7 blog posts showing <code>QueryParameterNamePrefix</code>/<code>order</code></li>
<li><a href="https://learn.microsoft.com/en-us/aspnet/core/blazor/fundamentals/routing">ASP.NET Core Blazor routing</a> and <a href="https://learn.microsoft.com/en-us/aspnet/core/blazor/fundamentals/navigation#enhanced-navigation-and-form-handling">enhanced navigation</a></li>
<li>dotnet/aspnetcore: PR <a href="https://github.com/dotnet/aspnetcore/pull/65451">#65451</a> (SSR support), issue <a href="https://github.com/dotnet/aspnetcore/issues/66830">#66830</a> (API proposal), PR <a href="https://github.com/dotnet/aspnetcore/pull/67733">#67733</a> (API rename), issue <a href="https://github.com/dotnet/aspnetcore/issues/69381">#69381</a> (duplicate provider requests)</li>
<li><a href="https://dotnet.microsoft.com/en-us/download/dotnet/11.0">.NET 11 downloads</a> and the <a href="https://dotnet.microsoft.com/platform/support/policy/dotnet-core">.NET support policy</a></li>
<li>Sample application: <a href="https://github.com/salihozkara/quickgrid-url-state">quickgrid-url-state</a></li>
</ul>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/api/posts/cover-picture-source/3a23ff2e-ad9c-5651-c8dd-26d5c7b18d2e" />
      <media:content url="https://abp.io/api/posts/cover-picture-source/3a23ff2e-ad9c-5651-c8dd-26d5c7b18d2e" medium="image" />
    </item>
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/building-a-vendor-onboarding-workflow-with-abp-lowcode-1wx0ckzc</guid>
      <link>https://abp.io/community/posts/building-a-vendor-onboarding-workflow-with-abp-lowcode-1wx0ckzc</link>
      <a10:author>
        <a10:name>salih</a10:name>
        <a10:uri>https://abp.io/community/members/salih</a10:uri>
      </a10:author>
      <category>workflow</category>
      <category>abp</category>
      <category>react</category>
      <category>low-code</category>
      <title>Building a Vendor Onboarding Workflow with ABP Low-Code</title>
      <description>Build a vendor onboarding workflow with ABP Low-Code: model vendor applications in the Designer, let the React runtime render the grid and form, then add a custom endpoint and a typed ABP code bridge for process-level counts.</description>
      <pubDate>Mon, 13 Jul 2026 12:51:56 Z</pubDate>
      <a10:updated>2026-09-30T12:59:02Z</a10:updated>
      <content:encoded><![CDATA[<h1>Building a Vendor Onboarding Workflow with ABP Low-Code</h1>
<p>Vendor onboarding usually starts with a few familiar steps.</p>
<p>A company sends its details, someone checks the documents, another person reviews the score, and the team either approves the vendor or asks for more information. After a while, the process turns into a mix of spreadsheets, uploaded files, status notes, and &quot;who is waiting on this one?&quot; messages.</p>
<p>In this article, we'll build that workflow with the <a href="https://abp.io/docs/latest/low-code/index">Low-Code System</a>. We'll model the data in the <a href="https://abp.io/docs/latest/low-code/designer">Low-Code Designer</a>, let the <a href="https://abp.io/docs/latest/low-code/react-runtime">React runtime</a> render the page, and then add one <a href="https://abp.io/docs/latest/low-code/custom-endpoints">custom endpoint</a> for a summary that does not belong to normal CRUD.</p>
<p>The example is an internal operations page where a team receives vendor applications, reviews compliance documents, tracks deadlines, and follows rejected or priority vendors from one place.</p>
<p>That is a good place to try ABP Low-Code, because the first version of the workflow is mostly data, screens, validation rules, and a few process-specific actions. You do not need to hand-write a React page only to list vendor applications, upload a compliance document, or show a rejection reason when the status is rejected.</p>
<p>We will start from an already running ABP React + EF Core application with Low-Code enabled, so the article can stay focused on the Admin Console, the Designer, and the runtime flow.</p>
<h2>What We Are Building</h2>
<p>The workflow has one main record: <code>VendorApplication</code>.</p>
<p>A reviewer should be able to:</p>
<ul>
<li>Create a vendor application with company and contact details.</li>
<li>Track whether the vendor is <code>Submitted</code>, <code>InReview</code>, <code>Approved</code>, or <code>Rejected</code>.</li>
<li>Set the requested date and approval deadline.</li>
<li>Mark priority vendors.</li>
<li>Assign a category such as <code>Software</code>, <code>Services</code>, or <code>Hardware</code>.</li>
<li>Upload a logo and a compliance document.</li>
<li>Fill in a rejection reason only when the application is rejected.</li>
<li>Filter the generated grid by status, requested date, priority, and category.</li>
<li>Call a summary endpoint that returns counts for dashboard-like use.</li>
</ul>
<p>We'll also touch two extra pieces around that main record. <code>VendorReviewTemplate</code> comes from C# so you can see how code-defined metadata appears in the Designer. Later, a <code>VendorEscalation</code> model is added while the Designer is switched to <code>Runtime JSON</code>. You could build the whole workflow with one entry point, but using these three entry points makes the hybrid model visible without turning the article into three separate implementations.</p>
<h2>A Quick Note on How Low-Code Fits Together</h2>
<p>The Low-Code Designer is where you describe the model and the UI metadata. In this article we use four areas:</p>
<ul>
<li><code>Data</code> for enums and entities.</li>
<li><code>Pages</code> for the generated grid route.</li>
<li><code>Forms</code> for the create/edit form layout.</li>
<li><code>Actions</code> for the custom HTTP endpoint.</li>
</ul>
<p>The Designer stores metadata. The React runtime reads that metadata and renders the page at runtime. That is the important mental model: when we add a field to the entity, the field can become a grid column, a filter, a validation rule, or a form input depending on how we configure the metadata around it.</p>
<p>There is also one database detail to keep in mind. Metadata that comes from C# code or from <code>Dev JSON</code> is source-controlled application metadata. When it introduces or changes a persisted entity, run the normal EF Core migration and database update flow before using the generated runtime page. In the validated demo for this article I used SQLite, so the migration updated the local SQLite database. <code>Runtime JSON</code> is different: it is authored at runtime, so I do not run a C# migration in that section.</p>
<h2>Add a Code-Defined Review Template</h2>
<p>Let's start with one model that does not come from the Designer.</p>
<p>In this workflow, vendor reviewers can use review templates. The template itself is not the center of the workflow, so I kept it focused on the review rules:</p>
<pre><code class="language-csharp">[DynamicEnum]
public enum VendorReviewTemplateType
{
    Standard = 0,
    Security = 1,
    Finance = 2
}

[DynamicEntity(DefaultDisplayPropertyName = nameof(Name))]
[DynamicEntityUI(DisplayName = &quot;Vendor Review Templates&quot;)]
public class VendorReviewTemplate : DynamicEntityBase
{
    [Required]
    [StringLength(128)]
    [DynamicPropertyUnique]
    public string Name { get; set; }

    public VendorReviewTemplateType TemplateType { get; set; }
    public int MinimumComplianceScore { get; set; }
    public bool RequiresDocumentReview { get; set; }
    public string? Notes { get; set; }
}
</code></pre>
<p>Then include the entity in your EF Core DbContext. This is the part that makes the migration create a real backing table for the code-defined model:</p>
<pre><code class="language-csharp">public DbSet&lt;VendorReviewTemplate&gt; VendorReviewTemplates { get; set; }

builder.Entity&lt;VendorReviewTemplate&gt;(b =&gt;
{
    b.ToTable(
        VendorOnboardingLowCodeConsts.DbTablePrefix + &quot;VendorReviewTemplates&quot;,
        VendorOnboardingLowCodeConsts.DbSchema
    );
    b.ConfigureByConvention();
    b.Property(x =&gt; x.Name).IsRequired().HasMaxLength(128);
    b.Property(x =&gt; x.Notes).HasMaxLength(512);
    b.HasIndex(x =&gt; x.Name).IsUnique();
});
</code></pre>
<p>Because this model is defined in C#, treat it like the rest of your application schema changes: add the entity, add the DbSet/mapping, create/apply the EF Core migration, and then start the application.</p>
<p>After the app starts, open <strong>Admin Console &gt; Low-Code Designer &gt; Data</strong>. The model is visible there, but it is read-only because it was defined in code.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-code-layer.png" alt="Code-defined VendorReviewTemplate shown as read-only in the Low-Code Designer" /></p>
<p>Open the <strong>Properties</strong> tab and you can see the fields that came from the C# class. They are available to the Low-Code System, but the Designer marks them as code-owned.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-review-template-properties.png" alt="The Properties tab for the code-defined VendorReviewTemplate entity" /></p>
<p>That is useful in real projects. Some metadata can be shipped with the application, while the rest of the workflow can still be designed through the Admin Console.</p>
<h2>Create the Vendor Enums</h2>
<p>Now move to the part we actually build in the Designer.</p>
<p>The animation below shows the Designer path in one pass. The next sections slow it down and explain the enum, entity, page, and form steps.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/gifs/designer-hybrid-flow.gif" alt="Creating the Designer metadata for the vendor onboarding workflow" /></p>
<p>Open <code>Data &gt; Enums</code> and create the status enum:</p>
<pre><code class="language-text">VendorApplicationStatus
Submitted
InReview
Approved
Rejected
</code></pre>
<p>Before saving, the enum modal should contain the name and the four values:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-enum-status-modal.png" alt="The VendorApplicationStatus enum creation modal before saving" /></p>
<p>Then create the category enum:</p>
<pre><code class="language-text">VendorCategory
Software
Services
Hardware
</code></pre>
<p>The order of the status values matters for the custom endpoint later, because the script checks the enum values by their numeric indexes. In this example <code>Submitted</code> is <code>0</code>, <code>Approved</code> is <code>2</code>, and <code>Rejected</code> is <code>3</code>.</p>
<p>After saving, the enum detail page shows the numeric values that the runtime and scripts will use:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-enum-status-saved.png" alt="The saved VendorApplicationStatus enum values in the Designer" /></p>
<h2>Create the VendorApplication Entity</h2>
<p>Go to <code>Data &gt; Entities</code> and create <code>VendorApplication</code>.</p>
<p>This is the model that drives the rest of the article. Add these fields:</p>
<p>| Field | Type | Configuration |
| --- | --- | --- |
| <code>CompanyName</code> | <code>String</code> | Required and unique |
| <code>ContactEmail</code> | <code>String</code> | Required, email validation |
| <code>Status</code> | <code>Enum</code> | <code>VendorApplicationStatus</code> |
| <code>RequestedOn</code> | <code>Date</code> | Application date |
| <code>ApprovalDeadline</code> | <code>Date</code> | Review deadline |
| <code>IsPriority</code> | <code>Boolean</code> | Priority flag |
| <code>Category</code> | <code>Enum</code> | <code>VendorCategory</code> |
| <code>ComplianceScore</code> | <code>Int</code> | Review score |
| <code>Logo</code> | <code>Image</code> | Logo upload |
| <code>ComplianceDocument</code> | <code>File</code> | Document upload |
| <code>RejectionReason</code> | <code>String</code> | Optional |</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-entity.png" alt="VendorApplication entity definition in the Designer" /></p>
<p>The <strong>Properties</strong> tab is where the entity becomes more than a name. The table shows the field types, enum bindings, and source layer. Scroll down and the upload-related fields are visible with their <code>Image</code> and <code>File</code> types:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-vendor-application-properties.png" alt="The VendorApplication properties table in the Designer" /></p>
<p>There is no React code yet, but we already have a lot of behavior described: required fields, uniqueness, email validation, enum fields, upload fields, and the data shape that the runtime will use.</p>
<p>The <code>Image</code> and <code>File</code> types are worth calling out. They are not plain strings with a path. In the generated form they become upload controls, which is exactly what we need for vendor logos and compliance documents.</p>
<p>Since <code>VendorApplication</code> is authored in the <code>Dev JSON</code> layer, it also belongs to the source-controlled model. After saving the entity metadata, create/apply the EF Core migration before you open the generated page in the runtime. This is the step that creates the backing table for the low-code entity in the database.</p>
<h2>Generate a Grid Page</h2>
<p>The reviewers need a page where they can work with applications, so go to <code>Pages</code> and create a <code>dataGrid</code> page named <code>vendor-onboarding</code>.</p>
<p>Bind it to <code>VendorApplication</code>.</p>
<p>Before saving the page, the modal connects the route name, title, icon, and entity:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-page-create-before-save.png" alt="The vendor-onboarding data grid page modal before saving" /></p>
<p>After the page is created, set <code>RequestedOn</code> as the default sort field, keep it descending, adjust the icon if you want, and assign <code>vendor-application-form</code> as the create/edit form:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-page.png" alt="The vendor-onboarding data grid page bound to VendorApplication" /></p>
<p>For the review workflow, keep the configured columns focused on the fields reviewers use most:</p>
<ul>
<li>Company name</li>
<li>Status</li>
<li>Requested date</li>
<li>Priority</li>
<li>Category</li>
</ul>
<p>Then configure the filters you want reviewers to use most often. In this workflow, the important filters are company, status, requested date, priority, and category. Depending on the runtime defaults, the generated grid may still expose additional fields such as contact email; the workflow is still driven by the focused page metadata above.</p>
<p>Once the page is saved, the React runtime can resolve the route from the page metadata. The grid is generated from the entity and page configuration rather than from a hand-written React component.</p>
<h2>Build the Create/Edit Form</h2>
<p>A grid is not enough. We also need a form that feels like the workflow.</p>
<p>Go to <code>Forms</code> and create <code>vendor-application-form</code> for <code>VendorApplication</code>. Split the fields into three tabs:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-form-create-modal.png" alt="The vendor-application-form creation modal before saving" /></p>
<ul>
<li><strong>Company</strong>: <code>CompanyName</code>, <code>ContactEmail</code>, <code>Category</code>, <code>IsPriority</code></li>
<li><strong>Review</strong>: <code>Status</code>, <code>RequestedOn</code>, <code>ApprovalDeadline</code>, <code>ComplianceScore</code>, <code>RejectionReason</code></li>
<li><strong>Documents</strong>: <code>Logo</code>, <code>ComplianceDocument</code></li>
</ul>
<p>Now add the conditional behavior for <code>RejectionReason</code>. In this demo I used two complementary rules: one rule shows the field when <code>Status = Rejected</code>, and the other hides it for non-rejected statuses.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-form.png" alt="The vendor-application-form with tabs and a conditional rule" /></p>
<p>This is one of the places where Low-Code becomes more than &quot;generate a CRUD page&quot;. The runtime does more than render a static form; it evaluates the rule while the user edits the record.</p>
<h2>Apply the Migration Before Opening the Runtime</h2>
<p>Before opening the generated page, apply the database migration for the <code>Dev JSON</code> changes. We used <code>Dev JSON</code> for <code>VendorApplication</code>, so the Designer wrote source-controlled descriptor files under <code>_Dynamic</code>. The entity shape is now part of the application model, and the database needs the matching backing table before the React runtime can save records.</p>
<p>That is why <code>Dev JSON</code> is a good fit during development: the metadata files and the EF Core migration can be reviewed, committed, and reproduced in another environment. If the same entity had been created in the <code>Runtime JSON</code> layer, you would not create a C# migration for that runtime edit; the metadata change would be stored in the database instead. In practice, use <code>Dev JSON</code> for development-time, source-controlled changes, and use <code>Runtime JSON</code> when you want production-time changes to be managed from the Admin Console and persisted in the database.</p>
<h2>Try It in the React Runtime</h2>
<p>Open the generated <code>vendor-onboarding</code> page in the React runtime and create a vendor application.</p>
<p>On the <code>Documents</code> tab, the <code>Logo</code> and <code>ComplianceDocument</code> fields are rendered as upload fields:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-form-documents.png" alt="The generated Documents tab rendering image and file upload fields" /></p>
<p>Now edit a record and change the status to <code>Rejected</code>. The <code>RejectionReason</code> field becomes available on the <code>Review</code> tab:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-review-rejected.png" alt="The Review tab showing the conditional RejectionReason field" /></p>
<p>After saving a few records, use the generated filters to narrow the list to rejected vendors. Depending on the runtime configuration, the filter panel can expose more fields than the small set you configured for the workflow; here we only use the <code>Status = Rejected</code> filter:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-grid-filtered.png" alt="The generated grid filtered by Status = Rejected" /></p>
<p>The short animation below gives a quick pass through the same runtime states: upload fields, the conditional rejection reason, and the filtered grid.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/gifs/runtime-workflow.gif" alt="Generated upload fields, conditional review field, and grid filters in the React runtime" /></p>
<p>At this point we have a working page, form, validation, uploads, and filters. The important part is that all of it came from the metadata we configured in the Designer.</p>
<h2>Add a Custom Summary Endpoint</h2>
<p>Generated CRUD is enough for day-to-day record editing, but teams often need one operation that is specific to their process.</p>
<p>For vendor onboarding, a summary endpoint is a good example:</p>
<pre><code class="language-text">GET /api/custom/vendor-onboarding/summary
</code></pre>
<p>In the Designer, open <code>Actions</code> and create a custom HTTP action with that route. The script can use the <a href="https://abp.io/docs/latest/low-code/scripting-api">Scripting API</a> to query the same <code>VendorApplication</code> data that the generated grid uses.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-endpoint.png" alt="The custom HTTP action configured under Actions in the Designer" /></p>
<p>Here is the script used in the demo:</p>
<pre><code class="language-js">var entityName = 'Acme.VendorOnboardingLowCode.Procurement.VendorApplication';
var vendorQuery = await db.query(entityName);
var totalVendors = await db.count(entityName);
var submittedVendors = await vendorQuery.where(x =&gt; x.Status === 0).count();
var approvedVendors = await vendorQuery.where(x =&gt; x.Status === 2).count();
var today = query.today || new Date().toISOString().slice(0, 10);
var overdueReviews = await vendorQuery
  .where(x =&gt; x.ApprovalDeadline != null &amp;&amp; x.ApprovalDeadline &lt; today &amp;&amp; x.Status !== 2)
  .count();

return ok({
  totalVendors: totalVendors,
  submittedVendors: submittedVendors,
  approvedVendors: approvedVendors,
  overdueReviews: overdueReviews,
  evaluatedOn: today
});
</code></pre>
<p>Use the entity name shown in your Designer. In the screenshots, it is <code>Acme.VendorOnboardingLowCode.Procurement.VendorApplication</code>.</p>
<p>When the endpoint runs, it returns the current counts from the low-code records:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/custom-endpoint-summary.png" alt="The JSON response of the custom summary endpoint" /></p>
<p>That is the bridge I like here. The page and form stay metadata-driven, but the process-specific summary is a short script exposed as a custom endpoint.</p>
<h2>Add One Runtime Model</h2>
<p>Now switch the Designer layer to <code>Runtime JSON</code> and add one more entity: <code>VendorEscalation</code>.</p>
<p>This model represents the items that need extra attention. It could have been created in the same place as <code>VendorApplication</code>; I am adding it here only to show that runtime-authored metadata participates in the same Low-Code System.</p>
<p>Unlike the code and <code>Dev JSON</code> examples above, this runtime-authored model is not part of the source-controlled migration flow in this walkthrough.</p>
<p>The create modal is the same Designer experience, but the selected layer is now <code>Runtime JSON</code>:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-vendor-escalation-save-modal.png" alt="The VendorEscalation entity creation modal in the Runtime JSON layer" /></p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-runtime-entity.png" alt="The VendorEscalation entity authored in the Runtime JSON layer" /></p>
<p>Create a data grid page for it and open it in the React runtime:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-vendor-escalations.png" alt="The runtime-generated Vendor Escalations page" /></p>
<p>From the user's point of view, it behaves like the first generated page. From the metadata point of view, we have now seen code-defined metadata, Designer-authored metadata, and runtime-authored metadata in the same application.</p>
<h2>Read the Same Data from ABP Code</h2>
<p>The last bridge is application code.</p>
<p>Sometimes the generated page is not the only consumer. You may want a typed application service, a scheduled job, or another API to read the same low-code records. The code below shows the idea by returning a backlog summary:</p>
<pre><code class="language-csharp">private readonly IRepository&lt;DynamicEntity, Guid&gt; _vendorApplicationRepository;
private readonly IAsyncQueryableExecuter _queryableExecuter;

public async Task&lt;VendorBacklogDto&gt; GetBacklogAsync()
{
    var entityDescriptor = DynamicModelManager.Instance.Find(
        &quot;Acme.VendorOnboardingLowCode.Procurement.VendorApplication&quot;
    );

    if (entityDescriptor == null)
    {
        throw new UserFriendlyException(&quot;VendorApplication model was not found.&quot;);
    }

    var query = await _vendorApplicationRepository
        .SetEntityName(entityDescriptor.Name)
        .GetQueryableAsync();
    var today = DateOnly.FromDateTime(Clock.Now);
    var priorityQuery = query.Where(vendor =&gt;
        vendor.Data[&quot;IsPriority&quot;] != null &amp;&amp;
        (bool?)vendor.Data[&quot;IsPriority&quot;] == true);

    var nextPriorityVendor = await _queryableExecuter.FirstOrDefaultAsync(
        priorityQuery.OrderByDescending(vendor =&gt;
            (DateOnly?)vendor.Data[&quot;RequestedOn&quot;]));

    return new VendorBacklogDto
    {
        TotalVendors = checked((int)await _queryableExecuter.LongCountAsync(query)),
        PriorityVendors = checked((int)await _queryableExecuter.LongCountAsync(priorityQuery)),
        RejectedVendors = checked((int)await _queryableExecuter.LongCountAsync(
            query.Where(vendor =&gt;
                vendor.Data[&quot;Status&quot;] != null &amp;&amp;
                (int?)vendor.Data[&quot;Status&quot;] == 3))),
        OverdueReviews = checked((int)await _queryableExecuter.LongCountAsync(
            query.Where(vendor =&gt;
                vendor.Data[&quot;ApprovalDeadline&quot;] != null &amp;&amp;
                (DateOnly?)vendor.Data[&quot;ApprovalDeadline&quot;] &lt; today &amp;&amp;
                vendor.Data[&quot;Status&quot;] != null &amp;&amp;
                (int?)vendor.Data[&quot;Status&quot;] != 2))),
        NextPriorityVendor = nextPriorityVendor?.GetData&lt;string&gt;(&quot;CompanyName&quot;)
    };
}
</code></pre>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/code-backlog-summary.png" alt="The typed backlog summary returned by the application service" /></p>
<p>The important detail is that the aggregate operations stay on <code>IQueryable</code>; the code does not load every vendor into memory just to count them. This is not a replacement for the generated page. It is the other direction: use the generated page for the admin experience, then read the same records from normal ABP code when another part of the application needs them.</p>
<h2>Going Further</h2>
<p>The workflow we built is intentionally focused, but the same shape can grow in a few directions:</p>
<ul>
<li>Add permissions around the generated pages and custom endpoint.</li>
<li>Add more form rules for review-specific fields.</li>
<li>Add an approval notification after a vendor is accepted.</li>
<li>Add a scheduled job that checks overdue applications.</li>
<li>Build a dashboard widget on top of the summary endpoint.</li>
</ul>
<p>The main pattern stays the same: model the data in the Low-Code Designer, let the React runtime render the operational page, and add code or scripting only for the parts that are specific to your business process.</p>
<h2>Conclusion</h2>
<p>ABP Low-Code is useful when the first version of a business workflow is mostly metadata: entities, fields, filters, forms, validation, uploads, and a few custom actions.</p>
<p>In this vendor onboarding example, the <code>VendorApplication</code> model gave us a generated grid and form, the runtime handled upload fields and conditional UI, and a custom endpoint added the summary that CRUD would not provide by itself. We also saw that low-code metadata can come from the Designer, from runtime JSON, or from C# code when you need that bridge.</p>
<p>That is the part worth remembering: you can start with a working admin experience quickly, then extend the workflow where the generated behavior stops being enough.</p>
<h3>Further Reading</h3>
<ul>
<li><a href="https://abp.io/docs/latest/low-code/index">Low-Code System Overview</a></li>
<li><a href="https://abp.io/docs/latest/low-code/designer">Low-Code Designer</a></li>
<li><a href="https://abp.io/docs/latest/low-code/react-runtime">React Runtime</a></li>
<li><a href="https://abp.io/docs/latest/low-code/custom-endpoints">Custom Endpoints</a></li>
<li><a href="https://abp.io/docs/latest/low-code/scripting-api">Scripting API</a></li>
</ul>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/api/posts/cover-picture-source/3a226db5-9821-0368-d9c8-a289d40ef59e" />
      <media:content url="https://abp.io/api/posts/cover-picture-source/3a226db5-9821-0368-d9c8-a289d40ef59e" medium="image" />
    </item>
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/introducing-abp-lowcode-build-real-abp-apps-in-minutes-647ymozi</guid>
      <link>https://abp.io/community/posts/introducing-abp-lowcode-build-real-abp-apps-in-minutes-647ymozi</link>
      <a10:author>
        <a10:name>salih</a10:name>
        <a10:uri>https://abp.io/community/members/salih</a10:uri>
      </a10:author>
      <category>entities</category>
      <category>code-first</category>
      <category>abp</category>
      <category>application-development</category>
      <category>csharp</category>
      <category>low-code</category>
      <title>Introducing ABP Low-Code: Build Real ABP Apps in Minutes</title>
      <description>Discover how ABP Low-Code blends runtime page building with code-first entities, C# queries, and extensible application logic.</description>
      <pubDate>Mon, 13 Jul 2026 12:44:16 Z</pubDate>
      <a10:updated>2026-09-30T14:33:05Z</a10:updated>
      <content:encoded><![CDATA[<h1>Introducing ABP Low-Code: Build Real ABP Apps in Minutes</h1>
<p><strong>Create runtime-managed pages, generated React screens, code-first C# entities, and Script API extensions without leaving the ABP application model.</strong></p>
<p>The opening loop below is the outcome this article is proving: one ABP application moving from runtime editing to generated operational screens and then into code-backed extension points.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-hero-loop.gif" alt="ABP Low-Code runtime loop — grids, forms, calendars, and pipelines inside one ABP app" /></p>
<blockquote>
<p><strong>Want to try the same path?</strong> Start from ABP Studio, enable the Low-Code runtime and designer, define pages in the Admin Console, and see them resolve inside the running ABP app.</p>
</blockquote>
<hr />
<h2>ABP Low-Code at a glance</h2>
<p>| Runtime authoring | Generated screens | ABP-native extensibility | One application model |
| :---: | :---: | :---: | :---: |
| Define and update pages in the Admin Console | Grid, Form, Calendar, Kanban, Gallery, Dashboard | Code-first entities, Script API actions, and C# query paths | Runtime metadata, generated UI, and application code stay together |</p>
<hr />
<h2>Built into the ABP Platform</h2>
<p>Low-code is most useful when speed does not create a separate stack to maintain later.</p>
<p>That is where many low-code products start to strain. They move quickly at the beginning, then force a second implementation track when the app needs permissions, auditability, custom logic, or tighter integration with existing application code.</p>
<p>ABP Low-Code takes a different path. It runs <strong>inside the ABP Platform</strong>, so runtime-managed pages are part of an application foundation that already includes identity, permissions, audit logging, APIs, and code-level extensibility.</p>
<hr />
<h2>Edit at runtime. See it in the app.</h2>
<p>In the Low-Code Designer inside the Admin Console, you update a runtime-managed page. A few seconds later, the same application surface is visible in the live app. No rebuild loop. No parallel front-end implementation. No &quot;we will wire it later&quot; gap between authoring and runtime.</p>
<p>ABP Low-Code shortens the cycle from model change to running screen while keeping the output grounded in the same ABP application.</p>
<p>The screenshot below shows that authoring step directly: a runtime page is being configured in the Admin Console's Low-Code Designer, where low-code defines the grid, form, actions, and view composition that the live application will resolve.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/admin-console-lowcode.png" alt="ABP Low-Code designer workspace for a runtime page" /></p>
<blockquote>
<p><strong>What this shows:</strong> authoring and runtime are connected. Pages are defined in the designer and resolved in the running application.</p>
</blockquote>
<hr />
<h2>CRUD is table stakes</h2>
<p>If low-code only saves you from drawing a table and a form, it is not enough. Business applications need richer operational surfaces.</p>
<p>In the generated app, the <code>Events</code> screen ships with search, actions, filters, and form-driven editing. The form structure already understands tabs, relations, validation, and business-shaped input instead of leaving you with a blank shell to finish by hand.</p>
<p>The next GIF shows the actual runtime page produced from that model: first the generated <code>Events</code> grid with operational actions, then the generated form with structured inputs instead of a blank CRUD shell.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-grid-form-flow.gif" alt="Generated event grid and generated event form" /></p>
<p>The point is not just generated CRUD. It is generated CRUD that already looks like the operational screens teams maintain in real applications.</p>
<hr />
<h2>One model, multiple operational screens</h2>
<p>Business users do not think in one view. Operators want a calendar for scheduling, a kanban board for workflow, a grid for bulk operations, a gallery when media matters, and a dashboard when they need the state of the business at a glance.</p>
<p>ABP Low-Code keeps those surfaces attached to the same underlying model. The same <code>Session</code> model can appear as a <strong>calendar</strong> for planning and a <strong>kanban pipeline</strong> for operational flow. The same generated app can also include a <strong>speaker gallery</strong> and an <strong>overview dashboard</strong> for metrics.</p>
<p>The next GIF keeps the same <code>Session</code> model but changes how the team works with it: calendar for planning, then kanban for operational flow, without rebuilding a second screen by hand.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-calendar-kanban-flow.gif" alt="The same runtime model shown as calendar and kanban views" /></p>
<p>The dashboard screenshot below continues that same application story. It is another surface generated around the same underlying data, this time optimized for KPIs, counts, and current operational status.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/overview-dashboard.png" alt="Overview dashboard in the live runtime app" /></p>
<p>This is where ABP Low-Code starts to feel less like a form generator and more like a runtime application layer: one model, many working screens, no second implementation track for each view type.</p>
<hr />
<h2>When low-code needs code</h2>
<p>The real differentiator is not that ABP Low-Code can go fast. It is that <strong>speed does not require isolation from the application foundation</strong>.</p>
<p>When generated CRUD is not enough, you extend the same app instead of throwing the low-code layer away.</p>
<p>ABP Low-Code exposes a server-side <strong>Script API</strong> inside the same application model. That scripting surface can back:</p>
<ul>
<li><strong>Custom endpoints</strong> when the UI needs an API-shaped response.</li>
<li><strong>Interceptors</strong> when create or update commands need validation or mutation.</li>
<li><strong>Event handlers</strong> when logic should react to runtime events.</li>
<li><strong>Background jobs</strong> when work should continue asynchronously.</li>
<li><strong>Background workers</strong> when operational logic should run on a schedule.</li>
</ul>
<p>In this article, the visible proof happens to be <code>GET /api/custom/eventflow/highlights</code>. The next GIF focuses on an endpoint because it is the easiest proof surface to read. But the broader point is that endpoints are only one consumer of the same low-code scripting layer.</p>
<p>That hybrid model matters in both directions:</p>
<ul>
<li><strong>Code-first ABP entities can be surfaced in low-code flows and runtime pages.</strong></li>
<li><strong>Low-code-managed data and screens stay reachable from Script API actions, application services, repository queries, and custom endpoints.</strong></li>
<li><strong>Teams do not lose architectural control just because they gained a faster authoring layer.</strong></li>
</ul>
<p>The next GIF steps into that Script API surface. In the same Admin Console, a script-backed low-code endpoint is opened, executed from the built-in test area, and its returned payload is shown immediately below so you can see runtime data flowing through an API-shaped contract.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-custom-endpoint-flow.gif" alt="Script API endpoint definition and executed dry-run result inside the Admin Console" /></p>
<p>The actual capability is the shared ABP application model behind it: script when runtime logic is enough, C# when typed application services and repository queries are the better fit.</p>
<p>This is the difference between &quot;low-code as a shortcut&quot; and &quot;low-code as part of your application platform.&quot;</p>
<hr />
<h2>From code-first entity to generated page</h2>
<p>The first direction is code-first to low-code. A <strong>code-first</strong> <code>SponsorActivation</code> entity checked into the ASP.NET Core project can still become a working runtime page without forking into a separate low-code-only model.</p>
<p>The code-first entity carries the same metadata that ABP Low-Code uses to generate the page:</p>
<pre><code class="language-csharp">[DynamicEntity(DefaultDisplayPropertyName = nameof(CompanyName))]
[DynamicEntityUI(&quot;Sponsor Activations&quot;)]
[DynamicEntityAttachments(&quot;application/pdf&quot;, &quot;image/*&quot;, MaxFileCount = 4)]
public class SponsorActivation : DynamicEntityBase
{
    [Required]
    [DynamicPropertyUI(DisplayName = &quot;Sponsor&quot;)]
    public string CompanyName { get; private set; }

    [Required]
    [EmailAddress]
    [DynamicPropertyUI(DisplayName = &quot;Contact Email&quot;)]
    public string ContactEmail { get; private set; }

    public SponsorActivationStatus Status { get; set; }

    [DynamicForeignKey(&quot;EventFlow.Events.Event&quot;, &quot;Title&quot;)]
    public Guid? EventId { get; set; }

    [DynamicForeignKey(&quot;Volo.Abp.Identity.IdentityUser&quot;, nameof(IdentityUser.UserName), ForeignAccess.View)]
    public Guid? OwnerUserId { get; set; }

    [DynamicPropertyType(EntityPropertyType.Money)]
    public decimal ActivationBudget { get; set; }

    [DynamicPropertyImageOptions(&quot;image/png&quot;, &quot;image/jpeg&quot;)]
    public string? BrandLogo { get; set; }

    [DynamicPropertyFileOptions(&quot;application/pdf&quot;, &quot;.pptx&quot;, &quot;.docx&quot;)]
    public string? ActivationBrief { get; set; }
}
</code></pre>
<p>That class lives as normal C# source, gets migrated like the rest of the application, and is seeded with real records so the runtime page does not open as an empty shell.</p>
<p>Inside the designer, selecting the <code>SponsorActivation</code> entity auto-generates the page identity, binds the grid to the entity, and lands on a real runtime route at <code>/dynamic/sponsor-activation</code>. The generated surface includes sponsor, email, event lookup, owner lookup, budget, image, and file fields directly from the C# model.</p>
<p>The next GIF shows that bridge in action: a new code-first <code>SponsorActivation</code> entity is selected inside low-code, a page is generated from its metadata, and the resulting runtime route opens with the modeled fields already wired in.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-page-builder.gif" alt="ABP Low-Code selecting the SponsorActivation C# entity, generating the page, and opening the resulting runtime surface" /></p>
<p>The screenshot after that is the resulting page, not a placeholder. You are looking at the generated form that came from the C# entity definition, including lookups, budget handling, image upload, and file upload fields.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/sponsor-activation-form.png" alt="Generated SponsorActivation form showing lookups, budget, image, and file fields coming directly from the C# entity" /></p>
<p>That is the distinction that matters: code-first ABP entities can move through low-code without becoming throwaway artifacts, and low-code-generated surfaces remain part of the same application story.</p>
<hr />
<h2>Low-code data stays reachable from C#</h2>
<p>The bridge also works in the other direction. A normal ABP application service can query a low-code model through <code>IRepository&lt;DynamicEntity, Guid&gt;</code>, apply real filters, and combine that result with code-first aggregates.</p>
<p>The service behind the endpoint in the previous section looks like this:</p>
<pre><code class="language-csharp">public async Task&lt;EventFlowLowCodeProofDto&gt; GetHybridSummaryAsync()
{
    var liveSessionQuery = (await _dynamicEntityRepository
            .SetEntityName(&quot;EventFlow.Events.Session&quot;)
            .GetQueryableAsync())
        .Where(&quot;int(it[\&quot;Status\&quot;]) == @0&quot;, 2);

    var publicSessionQuery = liveSessionQuery
        .Where(&quot;bool(it[\&quot;IsPublic\&quot;]) == @0&quot;, true);

    var sponsorQuery = (await _sponsorActivationRepository.GetQueryableAsync())
        .Where(activation =&gt;
            activation.Status == SponsorActivationStatus.Approved ||
            activation.Status == SponsorActivationStatus.Live);

    var liveSessionCount = await AsyncExecuter.CountAsync(liveSessionQuery);
    var publicSessionCount = await AsyncExecuter.CountAsync(publicSessionQuery);
    var activeSponsorActivationCount = await AsyncExecuter.CountAsync(sponsorQuery);

    return new EventFlowLowCodeProofDto
    {
        LiveSessionCount = liveSessionCount,
        PublicSessionCount = publicSessionCount,
        ActiveSponsorActivationCount = activeSponsorActivationCount
    };
}
</code></pre>
<p>Here, low-code-managed <code>Session</code> rows are filtered from C# with real <code>Where(...)</code> clauses, then combined with the typed <code>SponsorActivation</code> repository. The endpoint and dashboard are just one presentation surface for that shared ABP query path.</p>
<p>That is the ABP difference: low-code data stays reachable from code, and code-first entities stay reachable from low-code.</p>
<hr />
<h2>Why ABP Low-Code matters</h2>
<p>The value is not novelty. It is a faster way to build real business applications without separating speed from the application foundation.</p>
<ul>
<li><strong>Speed without replatforming.</strong> Runtime-managed screens reduce delivery time without moving the team onto a separate application stack.</li>
<li><strong>Governance without friction.</strong> Permissions, identity, auditability, and ABP platform foundations stay part of the story from day one.</li>
<li><strong>Extensibility without rewrite pressure.</strong> When custom behavior shows up, the same application can be extended instead of replacing the low-code output.</li>
</ul>
<p>That is the core ABP Low-Code promise: faster delivery, still inside the application model you can extend.</p>
<hr />
<h2>Try it yourself</h2>
<p>The public starting point for ABP Low-Code is <strong>ABP Studio</strong>.</p>
<p>The screenshot below is the exact toggle in the ABP Studio solution wizard where low-code runtime and designer support are enabled for a new ABP solution.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/abp-studio-lowcode-system.png" alt="ABP Studio new solution wizard with the Low-Code runtime and designer option enabled" /></p>
<ol>
<li>Open <strong>ABP Studio</strong> and create a new solution.</li>
<li>In the solution wizard, enable <strong>Include Low-Code runtime and designer</strong>.</li>
<li>Complete the wizard, then run the generated backend and React UI from the solution.</li>
<li>Sign in with the administrator account created for that solution.</li>
<li>Open <strong>Admin Console</strong> to define runtime-managed entities, forms, pages, permissions, endpoints, and script actions.</li>
<li>Switch to the application side to see those changes resolve live in the running app.</li>
</ol>
<hr />
<h2>Further reading</h2>
<ul>
<li><a href="https://abp.io/docs/latest/low-code/designer">ABP Low-Code Designer Documentation</a></li>
<li><a href="https://abp.io/docs/latest/low-code/fluent-api">ABP Low-Code Configuration &amp; Fluent API</a></li>
<li><a href="https://abp.io/docs/latest/low-code/scripting-api">ABP Low-Code Scripting API</a></li>
<li><a href="https://abp.io/docs/latest/low-code/script-actions">ABP Low-Code Script Actions</a></li>
<li><a href="https://abp.io/docs/latest/low-code/interceptors">ABP Low-Code Interceptors</a></li>
<li><a href="https://abp.io/docs/latest/studio">ABP Studio Documentation</a></li>
<li><a href="https://abp.io/docs/latest/get-started/layered-web-application">Get Started with ABP: Creating a Layered Web Application</a></li>
</ul>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/api/posts/cover-picture-source/3a226dae-926f-d4b7-d856-752f7c7d2435" />
      <media:content url="https://abp.io/api/posts/cover-picture-source/3a226dae-926f-d4b7-d856-752f7c7d2435" medium="image" />
    </item>
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/building-dynamic-xml-sitemaps-with-abp-framework-n3q6schd</guid>
      <link>https://abp.io/community/posts/building-dynamic-xml-sitemaps-with-abp-framework-n3q6schd</link>
      <a10:author>
        <a10:name>salih</a10:name>
        <a10:uri>https://abp.io/community/members/salih</a10:uri>
      </a10:author>
      <category>abp-framework</category>
      <category>abp</category>
      <category>tutorial</category>
      <category>module</category>
      <title>Building Dynamic XML Sitemaps with ABP Framework</title>
      <description>Learn how to use the ABP Sitemap module for automatic XML sitemap generation in your ABP Framework applications.</description>
      <pubDate>Mon, 15 Dec 2025 11:58:47 Z</pubDate>
      <a10:updated>2026-09-30T10:32:56Z</a10:updated>
      <content:encoded><![CDATA[<h1>Building Dynamic XML Sitemaps with ABP Framework</h1>
<p>Search Engine Optimization (SEO) is crucial for any web application that wants to be discovered by users. One of the most fundamental SEO practices is providing a comprehensive XML sitemap that helps search engines crawl and index your website efficiently. In this article, we'll use a reusable ABP module that automatically generates dynamic XML sitemaps for both static Razor Pages and dynamic content from your database.</p>
<p>By the end of this tutorial, you'll have a production-ready sitemap solution that discovers your pages automatically, includes dynamic content like blog posts or products, and regenerates sitemaps in the background without impacting performance.</p>
<h2>What is an XML Sitemap?</h2>
<p>An XML sitemap is a file that lists all important pages of your website in a structured format that search engines can easily read. It acts as a roadmap for crawlers like Google, Bing, and others, telling them which pages exist, when they were last updated, and how they relate to each other.</p>
<p>For modern web applications with dynamic content, manually maintaining sitemap files quickly becomes impractical. A dynamic sitemap solution that automatically discovers and updates URLs is essential for:</p>
<ul>
<li><strong>Large content sites</strong> with frequently changing blog posts, articles, or products</li>
<li><strong>Multi-tenant applications</strong> where each tenant may have different content</li>
<li><strong>Enterprise applications</strong> with complex page hierarchies</li>
<li><strong>E-commerce platforms</strong> with thousands of product pages</li>
</ul>
<h2>Why Build a Custom Sitemap Module?</h2>
<p>While there are general-purpose sitemap libraries available, building a custom module for ABP Framework provides several advantages:</p>
<p>✅ <strong>Deep ABP Integration</strong>: Leverages ABP's dependency injection, background workers, and module system
✅ <strong>Automatic Discovery</strong>: Uses ASP.NET Core's Razor Page infrastructure to automatically find pages
✅ <strong>Type-Safe Configuration</strong>: Strongly-typed attributes and options for configuration
✅ <strong>Multi-Group Support</strong>: Organize sitemaps by logical groups (main, blog, products, etc.)
✅ <strong>Background Generation</strong>: Non-blocking sitemap regeneration using ABP's background worker system
✅ <strong>Repository Integration</strong>: Direct integration with ABP repositories for database entities</p>
<h2>Project Architecture Overview</h2>
<p>Before using the module, let's understand its architecture:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2025-12-13-Building-Dynamic-XML-Sitemaps-With-ABP-Framework/images/sitemap-architecture.svg" alt="Architecture Diagram" /></p>
<p>The sitemap module consists of several key components:</p>
<ol>
<li><strong>Discovery Layer</strong>: Discovers Razor Pages and their metadata using reflection</li>
<li><strong>Source Layer</strong>: Defines contracts for providing sitemap items (static pages and dynamic content)</li>
<li><strong>Collection Layer</strong>: Collects items from all registered sources</li>
<li><strong>Generation Layer</strong>: Transforms collected items into XML format</li>
<li><strong>Management Layer</strong>: Orchestrates file generation and background workers</li>
</ol>
<h2>Installation</h2>
<p>To get started, clone the demo repository which includes the sitemap module:</p>
<pre><code class="language-bash">git clone https://github.com/salihozkara/AbpSitemapDemo
cd AbpSitemapDemo
</code></pre>
<p>The repository contains the sitemap module in the <code>Modules/abp.sitemap/</code> directory. To use it in your own project, add a project reference:</p>
<pre><code class="language-xml">&lt;ProjectReference Include=&quot;../Modules/abp.sitemap/Abp.Sitemap.Web/Abp.Sitemap.Web.csproj&quot; /&gt;
</code></pre>
<h2>Module Configuration</h2>
<p>After installing the package, add the module to your ABP application's module class:</p>
<pre><code class="language-csharp">using Abp.Sitemap.Web;

[DependsOn(
    typeof(SitemapWebModule), // 👈 Add sitemap module
    // ... other dependencies
)]
public class YourProjectWebModule : AbpModule
{
    public override void ConfigureServices(ServiceConfigurationContext context)
    {
        // Configure sitemap options
        Configure&lt;SitemapOptions&gt;(options =&gt;
        {
            options.BaseUrl = &quot;https://yourdomain.com&quot;; // 👈 Your website URL
            options.FolderPath = &quot;Sitemaps&quot;; // 👈 Where XML files are stored
            options.WorkerPeriod = 3600000; // 👈 Regenerate every hour (in milliseconds)
        });
    }
}
</code></pre>
<blockquote>
<p><strong>Note:</strong> In ABP applications, BaseUrl can be resolved from AppUrlOptions to stay consistent with environment configuration.</p>
</blockquote>
<p>That's it! The module is now integrated and will automatically:</p>
<ul>
<li>Discover your Razor Pages</li>
<li>Generate sitemap XML files on application startup</li>
<li>Regenerate sitemaps in the background every hour</li>
</ul>
<h2>Usage Examples</h2>
<p>Let's explore practical examples of using the sitemap module. You can see complete working examples in the <a href="https://github.com/salihozkara/AbpSitemapDemo">AbpSitemapDemo repository</a>.</p>
<h3>Example 1: Mark Static Pages</h3>
<p>The simplest way to include pages in your sitemap is using attributes:</p>
<pre><code class="language-csharp">using Abp.Sitemap.Web.Sitemap.Sources.Page.Attributes;

namespace YourProject.Pages;

[IncludeSitemapXml] // 👈 Include in default &quot;Main&quot; group
public class IndexModel : PageModel
{
    public void OnGet()
    {
        // Your page logic
    }
}

[IncludeSitemapXml(Group = &quot;Help&quot;)]
public class FaqModel : PageModel
{
    public void OnGet()
    {
        // Your page logic
    }
}
</code></pre>
<p>These pages will be automatically discovered and included in the sitemap XML files.</p>
<h3>Example 2: Add Dynamic Content from Database</h3>
<p>For dynamic content like blog posts, products, or articles, create a custom sitemap source. Here's a complete example using a Book entity:</p>
<pre><code class="language-csharp">using Abp.Sitemap.Web.Sitemap.Core;
using Abp.Sitemap.Web.Sitemap.Sources.Group;
using Volo.Abp.DependencyInjection;

namespace YourProject.Sitemaps;

public class BookSitemapSource : GroupedSitemapItemSource&lt;Book&gt;, ITransientDependency
{
    public BookSitemapSource(
        IReadOnlyRepository&lt;Book&gt; repository,
        IAsyncQueryableExecuter executer)
        : base(repository, executer, group: &quot;Books&quot;) // 👈 Creates sitemap-Books.xml
    {
        Filter = x =&gt; x.IsPublished; // 👈 Only published books
    }

    protected override Expression&lt;Func&lt;Book, SitemapItem&gt;&gt; Selector =&gt;
        book =&gt; new SitemapItem(
            book.Id.ToString(), // 👈 Unique identifier
            $&quot;/Books/Detail/{book.Id}&quot;, // 👈 URL pattern matching your route
            book.LastModificationTime ?? book.CreationTime // 👈 Last modified date
        )
        {
            ChangeFrequency = &quot;weekly&quot;,
            Priority = 0.7
        };
}
</code></pre>
<p>Key points:</p>
<ul>
<li>Inherits from <code>GroupedSitemapItemSource&lt;TEntity&gt;</code></li>
<li>Specifies the entity type (<code>Book</code>)</li>
<li>Defines a group name (&quot;Books&quot;) which creates <code>sitemap-Books.xml</code></li>
<li>Uses <code>Filter</code> to include only published books</li>
<li>Maps entity properties to sitemap URLs using <code>Selector</code></li>
<li>Automatically registered via <code>ITransientDependency</code></li>
</ul>
<h3>Example 3: Category-Based Dynamic Content</h3>
<p>For content with categories, you can build more complex URL patterns:</p>
<pre><code class="language-csharp">using Abp.Sitemap.Web.Sitemap.Core;
using Abp.Sitemap.Web.Sitemap.Sources.Group;

namespace YourProject.Sitemaps;

public class ArticleSitemapSource : GroupedSitemapItemSource&lt;Article&gt;, ITransientDependency
{
    public ArticleSitemapSource(
        IReadOnlyRepository&lt;Article&gt; repository,
        IAsyncQueryableExecuter executer)
        : base(repository, executer, &quot;Articles&quot;)
    {
        // Multiple filter conditions
        Filter = x =&gt; x.IsPublished &amp;&amp; 
                     !x.IsDeleted &amp;&amp; 
                     x.PublishDate &lt;= DateTime.Now;
    }

    protected override Expression&lt;Func&lt;Article, SitemapItem&gt;&gt; Selector =&gt;
        article =&gt; new SitemapItem(
            article.Id.ToString(),
            $&quot;/blog/{article.Category.Slug}/{article.Slug}&quot;, // 👈 Category-based URL
            article.LastModificationTime ?? article.CreationTime
        );
}
</code></pre>
<p>This example demonstrates:</p>
<ul>
<li>Multiple filter conditions for complex business logic</li>
<li>Building URLs with category slugs</li>
</ul>
<h2>Testing Your Sitemaps</h2>
<p>After configuring the module, test your sitemap generation:</p>
<h3>1. Run Your Application</h3>
<pre><code class="language-bash">dotnet run
</code></pre>
<p>The sitemaps are automatically generated on application startup.</p>
<h3>2. Check Generated Files</h3>
<p>Navigate to <code>{WebProject}/Sitemaps/</code> directory (at the root of your web project):</p>
<pre><code>{WebProject}
└── Sitemaps/
    ├── sitemap.xml           # Main group (static pages)
    ├── sitemap-Books.xml     # Books from database
    ├── sitemap-Articles.xml  # Articles from database
    └── sitemap-Help.xml      # Help pages
</code></pre>
<h3>3. Verify XML Content</h3>
<p>Open <code>sitemap-Books.xml</code> and verify the structure:</p>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;urlset xmlns=&quot;http://www.sitemaps.org/schemas/sitemap/0.9&quot;&gt;
  &lt;url&gt;
    &lt;loc&gt;https://yourdomain.com/Books/Detail/3a071e39-12c9-48d7-8c1e-3b4f5c6d7e8f&lt;/loc&gt;
    &lt;lastmod&gt;2025-12-13&lt;/lastmod&gt;
  &lt;/url&gt;
  &lt;url&gt;
    &lt;loc&gt;https://yourdomain.com/Books/Detail/7b8c9d0e-1f2a-3b4c-5d6e-7f8g9h0i1j2k&lt;/loc&gt;
    &lt;lastmod&gt;2025-12-10&lt;/lastmod&gt;
  &lt;/url&gt;
&lt;/urlset&gt;
</code></pre>
<h3>4. Test in Browser</h3>
<p>Visit the sitemap URLs directly (the module serves them from the root path):</p>
<ul>
<li>Main sitemap: <code>https://localhost:5001/sitemap.xml</code></li>
<li>Books sitemap: <code>https://localhost:5001/sitemap-Books.xml</code></li>
</ul>
<blockquote>
<p><strong>Note:</strong> The sitemaps are stored in <code>{WebProject}/Sitemaps/</code> directory and served directly from the root URL.</p>
</blockquote>
<h2>Advanced Configuration</h2>
<h3>Custom Regeneration Schedule</h3>
<p>Control when sitemaps are regenerated using cron expressions:</p>
<pre><code class="language-csharp">public override void ConfigureServices(ServiceConfigurationContext context)
{
    Configure&lt;SitemapOptions&gt;(options =&gt;
    {
        options.BaseUrl = &quot;https://yourdomain.com&quot;;
        options.WorkerCronExpression = &quot;0 0 2 * * ?&quot;; // 👈 Every day at 2 AM
        // Or use period in milliseconds:
        // options.WorkerPeriod = 7200000; // 2 hours
    });
}
</code></pre>
<h3>Environment-Specific Configuration</h3>
<p>Use different settings for development and production:</p>
<pre><code class="language-csharp">public override void ConfigureServices(ServiceConfigurationContext context)
{
    var configuration = context.Services.GetConfiguration();
    var hostingEnvironment = context.Services.GetHostingEnvironment();

    Configure&lt;SitemapOptions&gt;(options =&gt;
    {
        if (hostingEnvironment.IsDevelopment())
        {
            options.BaseUrl = &quot;https://localhost:5001&quot;;
            options.WorkerPeriod = 300000; // 5 minutes for testing
        }
        else
        {
            options.BaseUrl = configuration[&quot;App:SelfUrl&quot;]!;
            options.WorkerPeriod = 3600000; // 1 hour in production
        }
        
        options.FolderPath = &quot;Sitemaps&quot;;
    });
}
</code></pre>
<h3>Manual Sitemap Generation</h3>
<p>Trigger sitemap generation manually (useful for admin panels):</p>
<pre><code class="language-csharp">using Abp.Sitemap.Web.Sitemap.Management;

public class SitemapManagementService : ITransientDependency
{
    private readonly SitemapFileGenerator _generator;

    public SitemapManagementService(SitemapFileGenerator generator)
    {
        _generator = generator;
    }

    [Authorize(&quot;Admin&quot;)]
    public async Task RegenerateSitemapsAsync()
    {
        await _generator.GenerateAsync(); // 👈 Manual regeneration
    }
}
</code></pre>
<h2>Real-World Use Cases</h2>
<p>Here are practical scenarios where the sitemap module excels:</p>
<h3>E-Commerce Platform</h3>
<pre><code class="language-csharp">// Products grouped by category
public class ProductSitemapSource : GroupedSitemapItemSource&lt;Product&gt;
{
    // Automatically includes all active products with stock
}

// Separate sitemap for categories
public class CategorySitemapSource : GroupedSitemapItemSource&lt;Category&gt;
{
    // All browsable categories
}

// Brand pages
public class BrandSitemapSource : GroupedSitemapItemSource&lt;Brand&gt;
{
    // All active brands
}
</code></pre>
<p>Result: <code>sitemap-Products.xml</code>, <code>sitemap-Categories.xml</code>, <code>sitemap-Brands.xml</code></p>
<h3>Content Management System</h3>
<pre><code class="language-csharp">// Blog posts by date
public class BlogPostSitemapSource : GroupedSitemapItemSource&lt;BlogPost&gt;
{
    // Filter by published date, priority based on view count
}

// Static CMS pages
[IncludeSitemapXml]
public class AboutUsModel : PageModel { }
</code></pre>
<h2>Best Practices</h2>
<h3>1. Group Related Content</h3>
<p>Organize your sitemaps logically:</p>
<pre><code class="language-csharp">// ✅ Good: Logical grouping
&quot;Products&quot;, &quot;Categories&quot;, &quot;Brands&quot;, &quot;Blog&quot;, &quot;Help&quot;

// ❌ Bad: Everything in one group
&quot;Main&quot; // Contains 50,000 mixed URLs
</code></pre>
<h3>2. Use Filters Wisely</h3>
<pre><code class="language-csharp">// ✅ Good: Only published, non-deleted content
Filter = x =&gt; x.IsPublished &amp;&amp; 
             !x.IsDeleted &amp;&amp; 
             x.PublishDate &lt;= DateTime.Now

// ❌ Bad: Including draft content
Filter = x =&gt; true // Everything included
</code></pre>
<h3>3. Keep URLs Clean</h3>
<pre><code class="language-csharp">// ✅ Good: SEO-friendly URLs
$&quot;/products/{product.Slug}&quot;
$&quot;/blog/{year}/{month}/{article.Slug}&quot;

// ❌ Bad: Technical IDs exposed
$&quot;/product-detail?id={product.Id}&quot;
</code></pre>
<h2>Troubleshooting</h2>
<h3>Sitemap Not Generated</h3>
<p><strong>Problem:</strong> No XML files in <code>{WebProject}/Sitemaps/</code></p>
<p><strong>Solutions:</strong></p>
<ol>
<li>Check module is added to dependencies</li>
<li>Verify <code>SitemapOptions.BaseUrl</code> is configured</li>
<li>Check application logs for errors</li>
<li>Ensure the web project directory has write permissions</li>
</ol>
<h3>Pages Not Appearing</h3>
<p><strong>Problem:</strong> Some pages missing from sitemap</p>
<p><strong>Solutions:</strong></p>
<ol>
<li>Verify <code>[IncludeSitemapXml]</code> attribute is present</li>
<li>Check namespace imports: <code>using Abp.Sitemap.Web.Sitemap.Sources.Page.Attributes;</code></li>
<li>Ensure PageModel classes are public</li>
<li>Check filter conditions in custom sources</li>
</ol>
<h3>Background Worker Not Running</h3>
<p><strong>Problem:</strong> Sitemaps not regenerating automatically</p>
<p><strong>Solutions:</strong></p>
<ol>
<li>Check <code>SitemapOptions.WorkerPeriod</code> is set</li>
<li>Verify background workers are enabled in ABP configuration</li>
<li>Check application logs for worker errors</li>
</ol>
<h2>Performance Considerations</h2>
<h3>Caching Strategy</h3>
<p>Consider adding caching for frequently accessed sitemaps:</p>
<pre><code class="language-csharp">public class CachedSitemapFileGenerator : ITransientDependency
{
    private readonly SitemapFileGenerator _generator;
    private readonly IDistributedCache _cache;

    public async Task&lt;string&gt; GetOrGenerateAsync(string group)
    {
        var cacheKey = $&quot;Sitemap:{group}&quot;;
        var cached = await _cache.GetStringAsync(cacheKey);
        
        if (cached != null)
            return cached;
        
        await _generator.GenerateAsync();
        // Read and cache...
    }
}
</code></pre>
<h2>Conclusion</h2>
<p>The ABP Sitemap module provides a production-ready solution for dynamic sitemap generation in ABP Framework applications. By leveraging ABP's architecture—dependency injection, repository pattern, and background workers—the module automatically discovers pages, includes dynamic content, and regenerates sitemaps without manual intervention.</p>
<p>Key benefits:
✅ <strong>Zero Configuration</strong> for basic scenarios
✅ <strong>Type-Safe</strong> attribute-based configuration
✅ <strong>Extensible</strong> for complex business logic
✅ <strong>Performance</strong> optimized with background processing
✅ <strong>SEO-Friendly</strong> following XML sitemap standards</p>
<p>Whether you're building a blog, e-commerce platform, or enterprise application, this module provides a solid foundation for search engine optimization.</p>
<h2>Additional Resources</h2>
<h3>Documentation</h3>
<ul>
<li><a href="https://abp.io/docs/latest/">ABP Framework Documentation</a></li>
<li><a href="https://abp.io/docs/latest/framework/infrastructure/background-workers">ABP Background Workers</a></li>
<li><a href="https://abp.io/docs/latest/framework/architecture/domain-driven-design/repositories">ABP Repository Pattern</a></li>
<li><a href="https://abp.io/docs/latest/framework/fundamentals/dependency-injection">ABP Dependency Injection</a></li>
</ul>
<h3>Source Code</h3>
<ul>
<li><a href="https://github.com/salihozkara/AbpSitemapDemo">Complete Working Demo</a> - Full implementation with examples
<ul>
<li><a href="https://github.com/salihozkara/AbpSitemapDemo/blob/master/AbpSitemapDemo/Pages/Books/Index.cshtml.cs#L23">BookSitemapSource</a> - Entity-based source example</li>
<li><a href="https://github.com/salihozkara/AbpSitemapDemo/blob/master/AbpSitemapDemo/Pages/Index.cshtml#L9">Index.cshtml</a> - Page attribute usage</li>
</ul>
</li>
</ul>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/api/posts/cover-picture-source/3a1e340d-75f2-fdb2-7abe-fe0a5a3f5176" />
      <media:content url="https://abp.io/api/posts/cover-picture-source/3a1e340d-75f2-fdb2-7abe-fe0a5a3f5176" medium="image" />
    </item>
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/implement-automatic-methodlevel-caching-in-abp-framework-4uzd3wx8</guid>
      <link>https://abp.io/community/posts/implement-automatic-methodlevel-caching-in-abp-framework-4uzd3wx8</link>
      <a10:author>
        <a10:name>salih</a10:name>
        <a10:uri>https://abp.io/community/members/salih</a10:uri>
      </a10:author>
      <category>event-bus</category>
      <category>abp-framework</category>
      <category>metrics</category>
      <category>interceptors</category>
      <category>caching</category>
      <title>Implement Automatic Method-Level Caching in ABP Framework</title>
      <description>Learn how to implement automatic method-level caching in ABP Framework using attributes and interceptors. This comprehensive guide covers building a reusable cache infrastructure with attribute-based caching, intelligent cache invalidation when entities change, support for multiple cache scopes (Global, CurrentUser, AuthenticatedUser, and Entity), seamless integration with ABP's dynamic proxy system and event bus, and built-in performance metrics for monitoring cache effectiveness in production applications</description>
      <pubDate>Mon, 08 Dec 2025 07:16:29 Z</pubDate>
      <a10:updated>2026-09-30T16:28:09Z</a10:updated>
      <content:encoded><![CDATA[<h1>Implement Automatic Method-Level Caching in ABP Framework</h1>
<p>Caching is one of the most effective ways to improve application performance, but implementing it manually for every method can be tedious and error-prone. What if you could cache method results automatically with just an attribute? In this article, we'll explore how to build an automatic method-level caching system in ABP Framework that handles cache invalidation, supports multiple scopes, and integrates seamlessly with your existing application.</p>
<p>By the end of this guide, you'll understand how to implement attribute-based caching that automatically invalidates when entities change, supports user-specific and global caching scopes, and provides built-in metrics for monitoring cache performance.</p>
<blockquote>
<p>💡 <strong>Complete Implementation Available</strong>: This article is based on a working demo project. You can find the complete implementation in the <a href="https://github.com/salihozkara/AbpAutoCacheDemo">AbpAutoCacheDemo repository</a>, with the core AutoCache library implementation available in <a href="https://github.com/salihozkara/AbpAutoCacheDemo/commit/946df1fc07de6eddd26eb14013a09968cd59329b">this commit</a>.</p>
</blockquote>
<h2>What is Automatic Method-Level Caching?</h2>
<p>Automatic method-level caching is a technique that intercepts method calls and caches their results without requiring manual cache management code. Instead of writing cache logic in every method, you simply decorate methods with attributes that define caching behavior.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2025-12-06-Implement-Automatic-Method-Level-Caching-in-ABP-Framework/images/automatic-caching-flow.svg" alt="Automatic Caching Flow" /></p>
<p>The key benefits include:</p>
<ul>
<li><strong>Reduced Boilerplate:</strong> No repetitive cache management code in your business logic</li>
<li><strong>Consistent Caching Strategy:</strong> Centralized cache configuration and behavior</li>
<li><strong>Smart Invalidation:</strong> Automatic cache clearing when related entities change</li>
<li><strong>Multiple Scopes:</strong> Support for global, user-specific, and entity-specific caching</li>
<li><strong>Built-in Monitoring:</strong> Track cache hits, misses, and performance metrics</li>
</ul>
<h2>Architecture Overview</h2>
<p>The automatic caching system consists of several key components working together:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2025-12-06-Implement-Automatic-Method-Level-Caching-in-ABP-Framework/images/architecture-diagram.svg" alt="Architecture Diagram" /></p>
<p><strong>Core Components:</strong></p>
<ol>
<li><strong>CacheAttribute:</strong> The attribute you apply to methods to enable automatic caching</li>
<li><strong>AutoCacheInterceptor:</strong> Intercepts method calls and handles cache operations</li>
<li><strong>AutoCacheManager:</strong> Manages cache storage, retrieval, and key generation</li>
<li><strong>IAutoCacheKeyManager:</strong> Handles cache key mapping and invalidation</li>
<li><strong>AutoCacheInvalidationHandler:</strong> Listens to entity changes and clears related caches</li>
</ol>
<p>This architecture leverages ABP's dynamic proxy system and event bus to provide seamless caching without modifying your business logic.</p>
<h2>Prerequisites</h2>
<p>Before implementing automatic caching, ensure you have:</p>
<ul>
<li>ABP Framework 10.0 or later</li>
</ul>
<h2>Implementation</h2>
<blockquote>
<p>📦 <strong>Repository Structure</strong>: The complete implementation is available in the <a href="https://github.com/salihozkara/AbpAutoCacheDemo">AbpAutoCacheDemo repository</a>. The AutoCache library is located in the <code>src/AutoCache</code> folder, making it easy to extract and reuse in your own projects.</p>
</blockquote>
<h3>Step - 1: Create the AutoCache Module</h3>
<p>First, let's create a separate module for our caching infrastructure. This makes it reusable across projects.</p>
<h3>Step - 1: Create the AutoCache Module</h3>
<p>First, let's create a separate module for our caching infrastructure. This makes it reusable across projects.</p>
<p>Create <code>AutoCache.csproj</code>:</p>
<pre><code class="language-xml">&lt;Project Sdk=&quot;Microsoft.NET.Sdk&quot;&gt;
    &lt;PropertyGroup&gt;
        &lt;TargetFramework&gt;net10.0&lt;/TargetFramework&gt;
        &lt;Nullable&gt;enable&lt;/Nullable&gt;
    &lt;/PropertyGroup&gt;

    &lt;ItemGroup&gt;
        &lt;PackageReference Include=&quot;Volo.Abp.Caching.StackExchangeRedis&quot; Version=&quot;10.0.0&quot; /&gt;
        &lt;PackageReference Include=&quot;Volo.Abp.Core&quot; Version=&quot;10.0.0&quot; /&gt;
        &lt;PackageReference Include=&quot;Volo.Abp.Ddd.Domain&quot; Version=&quot;10.0.0&quot; /&gt;
    &lt;/ItemGroup&gt;
&lt;/Project&gt;
</code></pre>
<p>Create the module class <code>AutoCacheModule.cs</code>:</p>
<pre><code class="language-csharp">using Microsoft.Extensions.DependencyInjection;
using Volo.Abp.Caching.StackExchangeRedis;
using Volo.Abp.Domain;
using Volo.Abp.Modularity;

namespace AutoCache;

[DependsOn(typeof(AbpDddDomainModule), typeof(AbpCachingStackExchangeRedisModule))]
public class AutoCacheModule : AbpModule
{
    public override void PreConfigureServices(ServiceConfigurationContext context)
    {
        context.Services.OnRegistered(AutoCacheRegister.RegisterInterceptorIfNeeded); // 👈 Register interceptor
    }
}
</code></pre>
<p>This module automatically registers the cache interceptor for any class that uses the <code>CacheAttribute</code>.</p>
<h3>Step - 2: Define the Cache Attribute</h3>
<p>The <code>CacheAttribute</code> is the core of our automatic caching system. It specifies which entities affect the cache and what scope to use.</p>
<p>Create <code>CacheAttribute.cs</code>:</p>
<pre><code class="language-csharp">using System;
using Volo.Abp.Domain.Entities;

namespace AutoCache;

[AttributeUsage(AttributeTargets.Method)]
public class CacheAttribute : Attribute
{
    /// &lt;summary&gt;
    /// Entity types that affect this cache. When these entities change, the cache will be invalidated.
    /// &lt;/summary&gt;
    public Type[] InvalidateOnEntities { get; set; }

    /// &lt;summary&gt;
    /// Scope of the cache (Global, CurrentUser, AuthenticatedUser, or Entity)
    /// &lt;/summary&gt;
    public AutoCacheScope Scope { get; set; } = AutoCacheScope.Global;

    /// &lt;summary&gt;
    /// Absolute expiration time relative to now in milliseconds (0 = use default, -1 = disabled)
    /// &lt;/summary&gt;
    public long AbsoluteExpirationRelativeToNow { get; set; }

    /// &lt;summary&gt;
    /// Sliding expiration time in milliseconds (0 = use default, -1 = disabled)
    /// &lt;/summary&gt;
    public long SlidingExpiration { get; set; }

    public bool ConsiderUow { get; set; }

    public string AdditionalCacheKey { get; set; }

    public CacheAttribute(params Type[] invalidateOnEntities) // 👈 Specify entities that trigger cache invalidation
    {
        foreach (var entityType in invalidateOnEntities)
        {
            ArgumentNullException.ThrowIfNull(entityType);
            if (!typeof(IEntity).IsAssignableFrom(entityType))
            {
                throw new ArgumentException($&quot;Type {entityType.FullName} must implement IEntity interface.&quot;);
            }
        }
        InvalidateOnEntities = invalidateOnEntities;
    }
}
</code></pre>
<p><strong>Key Properties:</strong></p>
<ul>
<li><strong>InvalidateOnEntities:</strong> Array of entity types that, when modified, will clear this cache</li>
<li><strong>Scope:</strong> Determines cache visibility (Global, CurrentUser, AuthenticatedUser, Entity)</li>
<li><strong>AbsoluteExpirationRelativeToNow / SlidingExpiration:</strong> Control cache lifetime</li>
</ul>
<h3>Step - 3: Define Cache Scopes</h3>
<p>Cache scopes determine how cache entries are partitioned. Create <code>AutoCacheScope.cs</code>:</p>
<pre><code class="language-csharp">using System;

namespace AutoCache;

[Flags]
public enum AutoCacheScope
{
    /// &lt;summary&gt;
    /// Cache is shared globally across all users
    /// &lt;/summary&gt;
    Global,

    /// &lt;summary&gt;
    /// Cache is scoped to the current user (based on user ID)
    /// &lt;/summary&gt;
    CurrentUser,

    /// &lt;summary&gt;
    /// Cache is scoped to authenticated vs unauthenticated users
    /// &lt;/summary&gt;
    AuthenticatedUser,

    /// &lt;summary&gt;
    /// Cache is scoped to the primary key of the entity involved
    /// &lt;/summary&gt;
    Entity
}
</code></pre>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2025-12-06-Implement-Automatic-Method-Level-Caching-in-ABP-Framework/images/cache-scoping-diagram.svg" alt="Cache Scoping Strategy" /></p>
<p><strong>When to Use Each Scope:</strong></p>
<ul>
<li><strong>Global:</strong> For data that's the same for all users (e.g., configuration, public lists)</li>
<li><strong>CurrentUser:</strong> For user-specific data (e.g., user profile, user's orders)</li>
<li><strong>AuthenticatedUser:</strong> For data that differs between authenticated and anonymous users</li>
<li><strong>Entity:</strong> For data tied to a specific entity instance (e.g., book details by ID)</li>
</ul>
<h3>Step - 4: Implement the Cache Interceptor</h3>
<p>The interceptor is the heart of automatic caching. It intercepts method calls, checks the cache, and stores results. Create <code>AutoCacheInterceptor.cs</code>:</p>
<pre><code class="language-csharp">using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Volo.Abp.DependencyInjection;
using Volo.Abp.DynamicProxy;

namespace AutoCache;

public class AutoCacheInterceptor : AbpInterceptor, ITransientDependency
{
    private readonly ILogger&lt;AutoCacheInterceptor&gt; _logger;
    private readonly AutoCacheOptions _options;
    private static readonly MethodInfo GetOrAddCacheAsyncMethod;
    private readonly AutoCacheManager _autoCacheManager;
    private static readonly ConcurrentDictionary&lt;Type, MethodInfo&gt; MethodCache = new();

    static AutoCacheInterceptor()
    {
        GetOrAddCacheAsyncMethod = typeof(AutoCacheInterceptor).GetMethod(
            nameof(GetOrAddCacheAsync),
            BindingFlags.NonPublic | BindingFlags.Instance
        )!;
    }

    public AutoCacheInterceptor(
        ILogger&lt;AutoCacheInterceptor&gt; logger,
        IOptions&lt;AutoCacheOptions&gt; options, 
        AutoCacheManager autoCacheManager)
    {
        _logger = logger;
        _autoCacheManager = autoCacheManager;
        _options = options.Value;
    }

    public override async Task InterceptAsync(IAbpMethodInvocation invocation)
    {
        // Check if caching is enabled and method has [Cache] attribute
        if(!_options.Enabled || 
           invocation.Method.GetCustomAttributes(typeof(CacheAttribute), true).FirstOrDefault() 
           is not CacheAttribute attribute)
        {
            await invocation.ProceedAsync(); // 👈 No caching, proceed normally
            return;
        }
        
        var proceeded = false;

        try
        {
            // Create generic method based on return type
            var genericMethod = MethodCache.GetOrAdd(invocation.Method.ReturnType, t =&gt;
            {
                var isGenericTask = t.IsGenericType &amp;&amp; t.GetGenericTypeDefinition() == typeof(Task&lt;&gt;);
                var resultType = isGenericTask ? t.GetGenericArguments()[0] : t;
                return GetOrAddCacheAsyncMethod.MakeGenericMethod(resultType);
            });
            
            // Execute cache logic
            (var result, proceeded) = await (Task&lt;(object, bool)&gt;)genericMethod.Invoke(this, [invocation, attribute])!;
            invocation.ReturnValue = result; // 👈 Set cached or fresh result
        }
        catch (Exception e)
        {
            _logger.LogError(e, &quot;Error occurred while caching method {MethodName}&quot;, invocation.Method.Name);
            
            if(e is AutoCacheExceptionWrapper exceptionWrapper)
            {
                if (_options.ThrowOnError)
                {
                    throw exceptionWrapper.OriginalException;
                }
                
                _logger.LogWarning(
                    &quot;Cache operation failed, falling back to method execution for {MethodName}&quot;,
                    invocation.Method.Name
                );
            }

            if (!proceeded &amp;&amp; invocation.ReturnValue == null)
            {
                await invocation.ProceedAsync(); // 👈 Fallback to actual method execution
            }
        }
    }

    private async Task&lt;(object?, bool)&gt; GetOrAddCacheAsync&lt;TResult&gt;(
        IAbpMethodInvocation invocation, 
        CacheAttribute attribute)
    {
        var proceeded = false;
        var result = await _autoCacheManager.GetOrAddAsync(
            invocation.TargetObject, 
            Factory, 
            invocation.Arguments, 
            () =&gt; new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = GetExpiration(
                    attribute.AbsoluteExpirationRelativeToNow, 
                    _options.DefaultAbsoluteExpirationRelativeToNow),
                SlidingExpiration = GetExpiration(
                    attribute.SlidingExpiration, 
                    _options.DefaultSlidingExpiration)
            }, 
            attribute.InvalidateOnEntities, 
            attribute.Scope, 
            attribute.ConsiderUow, 
            attribute.AdditionalCacheKey, 
            invocation.Method.Name);
        
        return (result, proceeded);

        async Task&lt;TResult&gt; Factory()
        {
            await invocation.ProceedAsync(); // 👈 Execute actual method on cache miss
            proceeded = true;
            return (TResult)invocation.ReturnValue;
        }
    }
    
    private static TimeSpan? GetExpiration(long milliseconds, long defaultValue)
    {
        return milliseconds switch
        {
            0 =&gt; defaultValue &gt; 0 ? TimeSpan.FromMilliseconds(defaultValue) : null,
            &lt; 0 =&gt; null,
            _ =&gt; TimeSpan.FromMilliseconds(milliseconds)
        };
    }
}
</code></pre>
<p>The interceptor intelligently determines whether to serve cached data or execute the actual method.</p>
<h3>Step - 5: Implement the Cache Manager</h3>
<p>The <code>AutoCacheManager</code> handles the actual cache operations. Create a simplified version:</p>
<pre><code class="language-csharp">using System;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;
using Volo.Abp.DependencyInjection;
using Volo.Abp.DynamicProxy;
using Volo.Abp.Users;

namespace AutoCache;

public class AutoCacheManager : IScopedDependency
{
    private readonly IAutoCacheKeyManager _autoCacheKeyManager;
    private readonly ICurrentUser _currentUser;
    private readonly ILogger&lt;AutoCacheManager&gt; _logger;
    private readonly IAutoCacheMetrics _metrics;
    private readonly AutoCacheOptions _options;

    public AutoCacheManager(
        IAutoCacheKeyManager autoCacheKeyManager, 
        ICurrentUser currentUser,
        ILogger&lt;AutoCacheManager&gt; logger,
        IAutoCacheMetrics metrics,
        IOptions&lt;AutoCacheOptions&gt; options)
    {
        _autoCacheKeyManager = autoCacheKeyManager;
        _currentUser = currentUser;
        _logger = logger;
        _metrics = metrics;
        _options = options.Value;
    }

    public async Task&lt;TResult&gt; GetOrAddAsync&lt;TResult&gt;(
        object? caller,
        Func&lt;Task&lt;TResult&gt;&gt; func,
        object?[]? parameters = null,
        Func&lt;DistributedCacheEntryOptions&gt;? optionsFactory = null,
        Type[]? invalidateOnEntities = null,
        AutoCacheScope scope = AutoCacheScope.Global,
        bool considerUow = false,
        string? additionalCacheKey = null,
        [CallerMemberName] string methodName = &quot;&quot;)
    {
        if (!_options.Enabled)
        {
            return await func(); // 👈 Caching disabled, execute directly
        }
        
        var callerType = caller != null ? ProxyHelper.GetUnProxiedType(caller) : GetType();
        parameters ??= [];
        
        // Generate unique cache key based on method, parameters, and scope
        var cacheKey = GenerateCacheKey&lt;TResult&gt;(
            callerType.Name, 
            additionalCacheKey, 
            methodName, 
            parameters, 
            scope);
        
        var (cachedResult, exception, wasHit) = await GetOrAddCacheAsync(
            cacheKey,
            func,
            optionsFactory,
            considerUow
        );
        
        // Record metrics
        if (wasHit)
        {
            _metrics.RecordHit(cacheKey);
        }
        else
        {
            _metrics.RecordMiss(cacheKey);
        }
        
        if (exception != null)
        {
            _metrics.RecordError(cacheKey, exception);
            
            if (_options.ThrowOnError)
            {
                throw exception;
            }
        }
        
        return cachedResult;
    }

    private string GenerateCacheKey&lt;TResult&gt;(
        string callerTypeName,
        string? additionalCacheKey,
        string methodName,
        object?[] parameters,
        AutoCacheScope scope)
    {
        var keyBuilder = new StringBuilder();
        keyBuilder.Append($&quot;{callerTypeName}:{methodName}&quot;);
        
        // Add parameters to key
        foreach (var param in parameters)
        {
            keyBuilder.Append($&quot;:{param}&quot;);
        }
        
        // Add scope-specific segments
        if (scope.HasFlag(AutoCacheScope.CurrentUser) &amp;&amp; _currentUser.Id.HasValue)
        {
            keyBuilder.Append($&quot;:user:{_currentUser.Id}&quot;); // 👈 User-specific cache key
        }
        
        if (scope.HasFlag(AutoCacheScope.AuthenticatedUser))
        {
            keyBuilder.Append($&quot;:auth:{_currentUser.IsAuthenticated}&quot;);
        }
        
        if (!string.IsNullOrEmpty(additionalCacheKey))
        {
            keyBuilder.Append($&quot;:{additionalCacheKey}&quot;);
        }
        
        return keyBuilder.ToString();
    }

    // Additional methods for cache retrieval and storage...
}
</code></pre>
<p>The manager generates unique cache keys based on method signatures, parameters, and scope settings.</p>
<h3>Step - 6: Implement Cache Invalidation</h3>
<p>When entities change, related caches must be cleared. Create <code>AutoCacheInvalidationHandler.cs</code>:</p>
<pre><code class="language-csharp">using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Volo.Abp.Domain.Entities;
using Volo.Abp.Domain.Entities.Events;
using Volo.Abp.EventBus;
using Volo.Abp.Uow;

namespace AutoCache;

public class AutoCacheInvalidationHandler&lt;TEntity&gt; : 
    ILocalEventHandler&lt;EntityChangedEventData&lt;TEntity&gt;&gt; 
    where TEntity : class, IEntity
{
    private readonly IAutoCacheKeyManager _autoCacheKeyManager;
    private readonly ILogger&lt;AutoCacheInvalidationHandler&lt;TEntity&gt;&gt; _logger;
    private readonly IUnitOfWorkManager _unitOfWorkManager;
    
    public AutoCacheInvalidationHandler(
        IAutoCacheKeyManager autoCacheKeyManager, 
        ILogger&lt;AutoCacheInvalidationHandler&lt;TEntity&gt;&gt; logger,
        IUnitOfWorkManager unitOfWorkManager)
    {
        _autoCacheKeyManager = autoCacheKeyManager;
        _logger = logger;
        _unitOfWorkManager = unitOfWorkManager;
    }

    public async Task HandleEventAsync(EntityChangedEventData&lt;TEntity&gt; eventData)
    {
        try
        {
            var entityType = typeof(TEntity);
            var context = new RemoveCacheKeyContext 
            { 
                Keys = eventData.Entity.GetKeys()! 
            };
            
            // Clear cache after unit of work completes
            if(_unitOfWorkManager.Current != null)
            {
                _unitOfWorkManager.Current.OnCompleted(async () =&gt;
                {
                    await _autoCacheKeyManager.RemoveCacheAndCacheKeys(entityType, context); // 👈 Invalidate cache
                });
            }
            else
            {
                await _autoCacheKeyManager.RemoveCacheAndCacheKeys(entityType, context);
            }
        }
        catch (Exception e)
        {
            _logger.LogError(
                e, 
                &quot;Error occurred while clearing cache for entity type {EntityType}&quot;, 
                typeof(TEntity).FullName
            );
        }
    }
}
</code></pre>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2025-12-06-Implement-Automatic-Method-Level-Caching-in-ABP-Framework/images/cache-invalidation-flow.svg" alt="Cache Invalidation Flow" /></p>
<p>This handler listens to entity change events and automatically clears related caches. The invalidation happens after the unit of work completes to ensure data consistency.</p>
<h3>Step - 7: Configure AutoCache in Your Application</h3>
<p>Add the <code>AutoCacheModule</code> to your application module dependencies:</p>
<pre><code class="language-csharp">[DependsOn(
    typeof(AutoCacheModule), // 👈 Add AutoCache module
    typeof(AbpCachingStackExchangeRedisModule),
    // ... other modules
)]
public class YourApplicationModule : AbpModule
{
    public override void ConfigureServices(ServiceConfigurationContext context)
    {
        Configure&lt;AutoCacheOptions&gt;(options =&gt;
        {
            options.Enabled = true; // 👈 Enable caching
            options.DefaultAbsoluteExpirationRelativeToNow = 3600000; // 1 hour
            options.DefaultSlidingExpiration = 600000; // 10 minutes
            options.ThrowOnError = false; // Fallback to method execution on cache errors
        });
        
        // Configure Redis (if using distributed cache)
        Configure&lt;AbpDistributedCacheOptions&gt;(options =&gt;
        {
            options.KeyPrefix = &quot;YourApp:&quot;;
        });
    }
}
</code></pre>
<h3>Step - 8: Use Automatic Caching in Application Services</h3>
<p>Now comes the easy part - using automatic caching! Simply add the <code>[Cache]</code> attribute to your methods:</p>
<pre><code class="language-csharp">using AutoCache;

[Authorize(AutoCacheDemoPermissions.Books.Default)]
public class BookAppService : ApplicationService, IBookAppService
{
    private readonly IRepository&lt;Book, Guid&gt; _repository;
    private readonly AutoCacheManager _autoCacheManager;

    public BookAppService(IRepository&lt;Book, Guid&gt; repository, AutoCacheManager autoCacheManager)
    {
        _repository = repository;
        _autoCacheManager = autoCacheManager;
    }

    // Cache this method, invalidate when Book entity changes
    [Cache(typeof(Book), Scope = AutoCacheScope.Global)]
    public virtual async Task&lt;BookDto&gt; GetAsync(Guid id)
    {
        // You can also use AutoCacheManager directly for nested caching
        var book = await _autoCacheManager.GetOrAddAsync(
            this, 
            async () =&gt; await _repository.GetAsync(id), 
            [id], // 👈 Method parameters
            invalidateOnEntities: [typeof(Book)], 
            scope: AutoCacheScope.Entity);
            
        return ObjectMapper.Map&lt;Book, BookDto&gt;(book!);
    }

    // Cache book list, invalidate when any Book changes
    [Cache(typeof(Book))]
    public virtual async Task&lt;PagedResultDto&lt;BookDto&gt;&gt; GetListAsync(PagedAndSortedResultRequestDto input)
    {
        var queryable = await _repository.GetQueryableAsync();
        var query = queryable
            .OrderBy(input.Sorting.IsNullOrWhiteSpace() ? &quot;Name&quot; : input.Sorting)
            .Skip(input.SkipCount)
            .Take(input.MaxResultCount);

        var books = await AsyncExecuter.ToListAsync(query);
        var totalCount = await AsyncExecuter.CountAsync(queryable);

        return new PagedResultDto&lt;BookDto&gt;(
            totalCount,
            ObjectMapper.Map&lt;List&lt;Book&gt;, List&lt;BookDto&gt;&gt;(books)
        );
    }

    // No caching on write operations
    [Authorize(AutoCacheDemoPermissions.Books.Create)]
    public async Task&lt;BookDto&gt; CreateAsync(CreateUpdateBookDto input)
    {
        var book = ObjectMapper.Map&lt;CreateUpdateBookDto, Book&gt;(input);
        await _repository.InsertAsync(book); // 👈 This will trigger cache invalidation
        return ObjectMapper.Map&lt;Book, BookDto&gt;(book);
    }
}
</code></pre>
<p><strong>What Happens Here:</strong></p>
<ol>
<li>When <code>GetAsync</code> is called, the interceptor checks the cache</li>
<li>On cache miss, the actual method executes and the result is cached</li>
<li>When <code>CreateAsync</code> inserts a <code>Book</code>, the invalidation handler clears all caches related to <code>Book</code></li>
<li>Next call to <code>GetAsync</code> will fetch fresh data</li>
</ol>
<h2>Advanced Features</h2>
<h3>User-Specific Caching</h3>
<p>For user-specific data, use <code>AutoCacheScope.CurrentUser</code>:</p>
<pre><code class="language-csharp">[Cache(typeof(Order), Scope = AutoCacheScope.CurrentUser)]
public virtual async Task&lt;List&lt;OrderDto&gt;&gt; GetMyOrdersAsync()
{
    var orders = await _orderRepository.GetListAsync(x =&gt; x.UserId == CurrentUser.Id);
    return ObjectMapper.Map&lt;List&lt;Order&gt;, List&lt;OrderDto&gt;&gt;(orders);
}
</code></pre>
<p>Each user gets their own cache entry, automatically invalidated when their orders change.</p>
<h3>Custom Cache Keys</h3>
<p>For fine-grained control, add custom cache key segments:</p>
<pre><code class="language-csharp">[Cache(
    typeof(Product), 
    Scope = AutoCacheScope.Global,
    AdditionalCacheKey = &quot;featured&quot;
)]
public virtual async Task&lt;List&lt;ProductDto&gt;&gt; GetFeaturedProductsAsync()
{
    // Only featured products are cached separately
    return await GetProductsByCategoryAsync(&quot;Featured&quot;);
}
</code></pre>
<h3>Performance Metrics</h3>
<p>Monitor cache performance using <code>IAutoCacheMetrics</code>:</p>
<pre><code class="language-csharp">public class CacheMonitoringService : ITransientDependency
{
    private readonly IAutoCacheMetrics _metrics;

    public CacheMonitoringService(IAutoCacheMetrics metrics)
    {
        _metrics = metrics;
    }

    public AutoCacheStatistics GetStatistics()
    {
        return _metrics.GetStatistics(); // 👈 Get hit rate, miss count, error count
    }
}
</code></pre>
<h2>Testing the Application</h2>
<h3>1. Run the Application</h3>
<pre><code class="language-bash">abp new BookStore -u mvc -d ef
cd BookStore
dotnet run --project src/BookStore.Web
</code></pre>
<h3>2. Test Cache Behavior</h3>
<p>Create a simple test to verify caching:</p>
<pre><code class="language-csharp">[Fact]
public async Task Should_Cache_Book_Results()
{
    // First call - cache miss
    var book1 = await _bookAppService.GetAsync(testBookId);
    
    // Second call - cache hit (should be faster)
    var book2 = await _bookAppService.GetAsync(testBookId);
    
    book1.Name.ShouldBe(book2.Name);
}

[Fact]
public async Task Should_Invalidate_Cache_On_Update()
{
    // Cache the book
    var book1 = await _bookAppService.GetAsync(testBookId);
    
    // Update the book
    await _bookAppService.UpdateAsync(testBookId, new CreateUpdateBookDto 
    { 
        Name = &quot;Updated Name&quot; 
    });
    
    // Fetch again - should get updated data (cache was invalidated)
    var book2 = await _bookAppService.GetAsync(testBookId);
    
    book2.Name.ShouldBe(&quot;Updated Name&quot;);
}
</code></pre>
<h3>3. Monitor Cache Performance</h3>
<p>Check your application logs for cache metrics:</p>
<pre><code>[INF] Cache Hit: BookAppService:GetAsync:book-id-123 (Response Time: 5ms)
[INF] Cache Miss: BookAppService:GetListAsync (Response Time: 156ms)
[INF] Cache Invalidation: Book entity changed, cleared 3 cache entries
</code></pre>
<h2>Key Takeaways</h2>
<p>✅ <strong>Automatic caching reduces boilerplate code</strong> - Just add <code>[Cache]</code> attribute to methods instead of manual cache management</p>
<p>✅ <strong>Smart invalidation keeps data fresh</strong> - Entity changes automatically clear related caches without manual intervention</p>
<p>✅ <strong>Multiple scoping options</strong> - Support for global, user-specific, authenticated, and entity-level caching strategies</p>
<p>✅ <strong>Built-in fallback handling</strong> - Gracefully falls back to method execution if caching fails</p>
<p>✅ <strong>Performance monitoring</strong> - Track cache hits, misses, and errors for optimization</p>
<h2>Conclusion</h2>
<p>Automatic method-level caching dramatically simplifies performance optimization in ABP Framework applications. By using attributes and interceptors, you can add sophisticated caching behavior without cluttering your business logic with cache management code.</p>
<p>The system we've built provides intelligent cache invalidation, multiple scoping strategies, and built-in monitoring - all while maintaining clean, readable code. Whether you're building a small application or an enterprise system, this approach scales elegantly and integrates seamlessly with ABP's architecture.</p>
<p>Ready to implement this in your project? The complete working implementation is available in the <a href="https://github.com/salihozkara/AbpAutoCacheDemo">AbpAutoCacheDemo repository</a>. You can clone the repository, explore the code, and even extract the <code>src/AutoCache</code> folder to use it as a standalone library in your own ABP applications. The <a href="https://github.com/salihozkara/AbpAutoCacheDemo/commit/946df1fc07de6eddd26eb14013a09968cd59329b">main implementation commit</a> shows all the components working together, including interceptor registration, cache key management, and automatic invalidation handlers.r you're building a small application or an enterprise system, this approach scales elegantly and integrates seamlessly with ABP's architecture.</p>
<p>Ready to implement this in your project? Check out the complete working example in the repository linked below, and start improving your application's performance today!</p>
<h3>See Also</h3>
<ul>
<li><a href="https://abp.io/docs/latest/framework/fundamentals/caching">ABP Caching Documentation</a></li>
<li><a href="https://abp.io/docs/latest/framework/infrastructure/interceptors">Interceptors in ABP</a></li>
<li><a href="https://abp.io/docs/latest/framework/infrastructure/event-bus">Event Bus Documentation</a></li>
<li><a href="https://github.com/salihozkara/AbpAutoCacheDemo">Sample Project on GitHub</a></li>
</ul>
<hr />
<h2>References</h2>
<ul>
<li><a href="https://docs.abp.io">ABP Framework Documentation</a></li>
<li><a href="https://redis.io/docs/">Redis Distributed Caching</a></li>
<li><a href="https://en.wikipedia.org/wiki/Aspect-oriented_programming">Aspect-Oriented Programming Patterns</a></li>
</ul>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/api/posts/cover-picture-source/3a1e0efe-7dce-8993-1aa9-b7eb583cc8bd" />
      <media:content url="https://abp.io/api/posts/cover-picture-source/3a1e0efe-7dce-8993-1aa9-b7eb583cc8bd" medium="image" />
    </item>
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/building-productionready-llm-applications-with-.net-a-practical-guide-ya7qemfa</guid>
      <link>https://abp.io/community/posts/building-productionready-llm-applications-with-.net-a-practical-guide-ya7qemfa</link>
      <a10:author>
        <a10:name>salih</a10:name>
        <a10:uri>https://abp.io/community/members/salih</a10:uri>
      </a10:author>
      <category>dotnet</category>
      <category>application-development</category>
      <category>postgresql</category>
      <category>LLMs</category>
      <category>MCP</category>
      <title>Building Production-Ready LLM Applications with .NET: A Practical Guide</title>
      <description>Learn how to build production-ready LLM applications with .NET. This comprehensive guide covers GPT-5 API changes, advanced RAG architectures with parent-child patterns, PostgreSQL pgvector integration, smart tool usage strategies, multilingual query handling, Model Context Protocol (MCP) for cross-application tool reusability, and chat history management techniques for enterprise applications.</description>
      <pubDate>Mon, 24 Nov 2025 07:33:03 Z</pubDate>
      <a10:updated>2026-09-30T15:37:29Z</a10:updated>
      <content:encoded><![CDATA[<h1>Building Production-Ready LLM Applications with .NET: A Practical Guide</h1>
<p>Large Language Models (LLMs) have evolved rapidly, and integrating them into production .NET applications requires staying current with the latest approaches. In this article, I'll share practical tips and patterns I've learned while building LLM-powered systems, covering everything from API changes in GPT-5 to implementing efficient RAG (Retrieval Augmented Generation) architectures.</p>
<p>Whether you're building a chatbot, a knowledge base assistant, or integrating AI into your enterprise applications, these production-tested insights will help you avoid common pitfalls and build more reliable systems.</p>
<h2>The Temperature Paradigm Shift: GPT-5 Changes Everything</h2>
<p>If you've been working with GPT-4 or earlier models, you're familiar with the <code>temperature</code> and <code>top_p</code> parameters for controlling response randomness. <strong>Here's the critical update</strong>: GPT-5 no longer supports these parameters!</p>
<h3>The Old Way (GPT-4)</h3>
<pre><code class="language-csharp">var chatRequest = new ChatOptions
{
    Temperature = 0.7,  // ✅ Worked with GPT-4
    TopP = 0.9          // ✅ Worked with GPT-4
};
</code></pre>
<h3>The New Way (GPT-5)</h3>
<pre><code class="language-csharp">var chatRequest = new ChatOptions
{
    RawRepresentationFactory = (client =&gt; new ChatCompletionOptions()
    {
#pragma warning disable OPENAI001
        ReasoningEffortLevel = &quot;minimal&quot;,
#pragma warning restore OPENAI001
    })
};
</code></pre>
<p><strong>Why the change?</strong> GPT-5 incorporates an internal reasoning and verification process. Instead of controlling randomness, you now specify how much computational effort the model should invest in reasoning through the problem.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2025-11-22-building-production-ready-llm-applications/images/reasoning-effort-diagram.svg" alt="Reasoning Effort Levels" /></p>
<h3>Choosing the Right Reasoning Level</h3>
<ul>
<li><strong>Low</strong>: Quick responses for simple queries (e.g., &quot;What's the capital of France?&quot;)</li>
<li><strong>Medium</strong>: Balanced approach for most use cases</li>
<li><strong>High</strong>: Complex reasoning tasks (e.g., code generation, multi-step problem solving)</li>
</ul>
<blockquote>
<p><strong>Pro Tip</strong>: Reasoning tokens are included in your API costs. Use &quot;High&quot; only when necessary to optimize your budget.</p>
</blockquote>
<h2>System Prompts: The &quot;Lost in the Middle&quot; Problem</h2>
<p>Here's a critical insight that can save you hours of debugging: <strong>Important rules must be repeated at the END of your prompt!</strong></p>
<h3>❌ What Doesn't Work</h3>
<pre><code>You are a helpful assistant.
RULE: Never share passwords or sensitive information.

[User Input]
</code></pre>
<h3>✅ What Actually Works</h3>
<pre><code>You are a helpful assistant.
RULE: Never share passwords or sensitive information.

[User Input]

⚠️ REMINDER: Apply the rules above strictly, ESPECIALLY regarding passwords.
</code></pre>
<p><strong>Why?</strong> LLMs suffer from the &quot;Lost in the Middle&quot; phenomenon—they pay more attention to the beginning and end of the context window. Critical instructions buried in the middle are often ignored.</p>
<h2>RAG Architecture: The Parent-Child Pattern</h2>
<p>Retrieval Augmented Generation (RAG) is essential for grounding LLM responses in your own data. The most effective pattern I've found is the <strong>Parent-Child approach</strong>.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2025-11-22-building-production-ready-llm-applications/images/rag-parent-child.svg" alt="RAG Parent-Child Architecture" /></p>
<h3>How It Works</h3>
<ol>
<li><p><strong>Split documents into hierarchies</strong>:</p>
<ul>
<li><strong>Parent chunks</strong>: Large sections (1000-2000 tokens) for context</li>
<li><strong>Child chunks</strong>: Small segments (200-500 tokens) for precise retrieval</li>
</ul>
</li>
<li><p><strong>Store both in vector database</strong> with references</p>
</li>
<li><p><strong>Query flow</strong>:</p>
<ul>
<li>Search using child chunks (higher precision)</li>
<li>Return parent chunks to LLM (richer context)</li>
</ul>
</li>
</ol>
<h3>The Overlap Strategy</h3>
<p>Always use overlapping chunks to prevent information loss at boundaries!</p>
<pre><code>Chunk 1: Token 0-500
Chunk 2: Token 400-900   ← 100 token overlap
Chunk 3: Token 800-1300  ← 100 token overlap
</code></pre>
<p><strong>Standard recommendation</strong>: 10-20% overlap (for 500 tokens, use 50-100 token overlap)</p>
<h3>Implementation with Semantic Kernel</h3>
<pre><code class="language-csharp">using Microsoft.SemanticKernel.Text;

var chunks = TextChunker.SplitPlainTextParagraphs(
    documentText, 
    maxTokensPerParagraph: 500,
    overlapTokens: 50
);

foreach (var chunk in chunks)
{
    var embedding = await embeddingService.GenerateEmbeddingAsync(chunk);
    await vectorDb.StoreAsync(chunk, embedding);
}
</code></pre>
<h2>PostgreSQL + pgvector: The Pragmatic Choice</h2>
<p>For .NET developers, choosing a vector database can be overwhelming. After evaluating multiple options, <strong>PostgreSQL with pgvector</strong> is the most practical choice for most scenarios.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2025-11-22-building-production-ready-llm-applications/images/pgvector-integration.svg" alt="pgvector Integration" /></p>
<h3>Why pgvector?</h3>
<p>✅ <strong>Use existing SQL knowledge</strong> - No new query language to learn<br />
✅ <strong>EF Core integration</strong> - Works with your existing data access layer<br />
✅ <strong>JOIN with metadata</strong> - Combine vector search with traditional queries<br />
✅ <strong>WHERE clause filtering</strong> - Filter by tenant, user, date, etc.<br />
✅ <strong>ACID compliance</strong> - Transaction support for data consistency<br />
✅ <strong>No separate infrastructure</strong> - One database for everything</p>
<h3>Setting Up pgvector with EF Core</h3>
<p>First, install the NuGet package:</p>
<pre><code class="language-bash">dotnet add package Pgvector.EntityFrameworkCore
</code></pre>
<p>Define your entity:</p>
<pre><code class="language-csharp">using Pgvector;
using Pgvector.EntityFrameworkCore;

public class DocumentChunk
{
    public Guid Id { get; set; }
    public string Content { get; set; }
    public Vector Embedding { get; set; }  // 👈 pgvector type
    public Guid ParentChunkId { get; set; }
    public DateTime CreatedAt { get; set; }
}
</code></pre>
<p>Configure in DbContext:</p>
<pre><code class="language-csharp">protected override void OnModelCreating(ModelBuilder builder)
{
    builder.HasPostgresExtension(&quot;vector&quot;);
    
    builder.Entity&lt;DocumentChunk&gt;()
        .Property(e =&gt; e.Embedding)
        .HasColumnType(&quot;vector(1536)&quot;);  // 👈 OpenAI embedding dimension
    
    builder.Entity&lt;DocumentChunk&gt;()
        .HasIndex(e =&gt; e.Embedding)
        .HasMethod(&quot;hnsw&quot;)  // 👈 Fast approximate search
        .HasOperators(&quot;vector_cosine_ops&quot;);
}
</code></pre>
<h3>Performing Vector Search</h3>
<pre><code class="language-csharp">using Pgvector.EntityFrameworkCore;

public async Task&lt;List&lt;DocumentChunk&gt;&gt; SearchAsync(string query)
{
    // 1. Convert query to embedding
    var queryVector = await _embeddingService.GetEmbeddingAsync(query);
    
    // 2. Search
    return await _context.DocumentChunks
        .OrderBy(c =&gt; c.Embedding.L2Distance(queryVector))  // 👈 Lower is better
        .Take(5)
        .ToListAsync();
}
</code></pre>
<p><strong>Source</strong>: <a href="https://github.com/pgvector/pgvector-dotnet?tab=readme-ov-file#entity-framework-core">Pgvector.NET on GitHub</a></p>
<h2>Smart Tool Usage: Make RAG a Tool, Not a Tax</h2>
<p>A common mistake is calling RAG on every single user message. This wastes tokens and money. Instead, <strong>make RAG a tool</strong> and let the LLM decide when to use it.</p>
<h3>❌ Expensive Approach</h3>
<pre><code class="language-csharp">// Always call RAG, even for &quot;Hello&quot;
var context = await PerformRAG(userMessage);
var response = await chatClient.CompleteAsync($&quot;{context}\n\n{userMessage}&quot;);
</code></pre>
<h3>✅ Smart Approach</h3>
<pre><code class="language-csharp">[KernelFunction]
[Description(&quot;Search the company knowledge base for information&quot;)]
public async Task&lt;string&gt; SearchKnowledgeBase(
    [Description(&quot;The search query&quot;)] string query)
{
    var results = await _vectorDb.SearchAsync(query);
    return string.Join(&quot;\n---\n&quot;, results.Select(r =&gt; r.Content));
}
</code></pre>
<p>The LLM will call <code>SearchKnowledgeBase</code> only when needed:</p>
<ul>
<li>&quot;Hello&quot; → No tool call</li>
<li>&quot;What was our 2024 revenue?&quot; → Calls tool</li>
<li>&quot;Tell me a joke&quot; → No tool call</li>
</ul>
<h2>Multilingual RAG: Query Translation Strategy</h2>
<p>When your documents are in one language (e.g., English) but users query in another (e.g., Turkish), you need a translation strategy.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2025-11-22-building-production-ready-llm-applications/images/multilingual-rag.svg" alt="Multilingual RAG Architecture" /></p>
<h3>Solution Options</h3>
<p><strong>Option 1</strong>: Use an LLM that automatically calls tools in English</p>
<ul>
<li>Many modern LLMs can do this if properly instructed</li>
</ul>
<p><strong>Option 2</strong>: Tool chain approach</p>
<pre><code class="language-csharp">[KernelFunction]
[Description(&quot;Translate text to English&quot;)]
public async Task&lt;string&gt; TranslateToEnglish(string text)
{
    // Translation logic
}

[KernelFunction]
[Description(&quot;Search knowledge base (English only)&quot;)]
public async Task&lt;string&gt; SearchKnowledgeBase(string englishQuery)
{
    // Search logic
}
</code></pre>
<p>The LLM will:</p>
<ol>
<li>Call <code>TranslateToEnglish(&quot;2024 geliri nedir?&quot;)</code></li>
<li>Get &quot;What was 2024 revenue?&quot;</li>
<li>Call <code>SearchKnowledgeBase(&quot;What was 2024 revenue?&quot;)</code></li>
<li>Return results and respond in Turkish</li>
</ol>
<h2>Model Context Protocol (MCP): Beyond In-Process Tools</h2>
<p>Microsoft and Anthropic recently released official C# SDKs for the Model Context Protocol (MCP). This is a game-changer for tool reusability.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2025-11-22-building-production-ready-llm-applications/images/mcp-architecture.svg" alt="MCP Architecture" /></p>
<h3>MCP vs. Semantic Kernel Plugins</h3>
<p>| Feature | SK Plugins | MCP Servers |
|---------|-----------|-------------|
| <strong>Process</strong> | In-process | Out-of-process (stdio/http) |
| <strong>Reusability</strong> | Application-specific | Cross-application |
| <strong>Examples</strong> | Used within your app | VS Code Copilot, Claude Desktop |</p>
<h3>Creating an MCP Server</h3>
<pre><code class="language-csharp">using Microsoft.Extensions.Hosting;
using ModelContextProtocol.Extensions.Hosting;

var builder = Host.CreateEmptyApplicationBuilder(settings: null);

builder.Services.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly();

await builder.Build().RunAsync();
</code></pre>
<p>Define your tools:</p>
<pre><code class="language-csharp">[McpServerToolType]
public static class FileSystemTools
{
    [McpServerTool, Description(&quot;Read a file from the file system&quot;)]
    public static async Task&lt;string&gt; ReadFile(string path)
    {
        // ⚠️ SECURITY: Always validate paths!
        if (!IsPathSafe(path)) 
            throw new SecurityException(&quot;Invalid path&quot;);
        
        return await File.ReadAllTextAsync(path);
    }
    
    private static bool IsPathSafe(string path)
    {
        // Implement path traversal prevention
        var fullPath = Path.GetFullPath(path);
        return fullPath.StartsWith(AllowedDirectory);
    }
}
</code></pre>
<p>Your MCP server can now be used by VS Code Copilot, Claude Desktop, or any other MCP client!</p>
<h2>Chat History Management: Truncation + RAG Hybrid</h2>
<p>For long conversations, storing all history in the context window becomes impractical. Here's the pattern that works:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2025-11-22-building-production-ready-llm-applications/images/chat-history-hybrid.svg" alt="Chat History Hybrid Strategy" /></p>
<h3>❌ Lossy Approach</h3>
<pre><code>First 50 messages → Summarize with LLM → Single summary message
</code></pre>
<p><strong>Problem</strong>: Detail loss (fidelity loss)</p>
<h3>✅ Hybrid Approach</h3>
<ol>
<li><strong>Recent messages</strong> (last 5-10): Keep in prompt for immediate context</li>
<li><strong>Older messages</strong>: Store in vector database as a tool</li>
</ol>
<pre><code class="language-csharp">[KernelFunction]
[Description(&quot;Search conversation history for past discussions&quot;)]
public async Task&lt;string&gt; SearchChatHistory(
    [Description(&quot;What to search for&quot;)] string query)
{
    var relevantMessages = await _vectorDb.SearchAsync(query);
    return string.Join(&quot;\n&quot;, relevantMessages.Select(m =&gt; 
        $&quot;[{m.Timestamp}] {m.Role}: {m.Content}&quot;));
}
</code></pre>
<p>The LLM retrieves only relevant past context when needed, avoiding summary-induced information loss.</p>
<h2>RAG vs. Fine-Tuning: Choose Wisely</h2>
<p>A common misconception is using fine-tuning for knowledge injection. Here's when to use each:</p>
<p>| Purpose | RAG | Fine-Tuning |
|---------|-----|-------------|
| <strong>Goal</strong> | Memory (provide facts) | Behavior (teach style) |
| <strong>Updates</strong> | Dynamic (add docs anytime) | Static (requires retraining) |
| <strong>Cost</strong> | Low dev, higher inference | High dev, lower inference |
| <strong>Hallucination</strong> | Reduces | Doesn't reduce |
| <strong>Use Case</strong> | Company docs, FAQs | Brand voice, specific format |</p>
<p><strong>Common mistake</strong>: &quot;Let's fine-tune on our company documents&quot; ❌<br />
<strong>Better approach</strong>: Use RAG! ✅</p>
<p>Fine-tuning is for teaching the model <em>how</em> to respond, not <em>what</em> to know.</p>
<p><strong>Source</strong>: <a href="https://www.oracle.com/artificial-intelligence/generative-ai/retrieval-augmented-generation-rag/rag-fine-tuning/">Oracle - RAG vs Fine-Tuning</a></p>
<h2>Bonus: Why SVG is Superior for LLM-Generated Images</h2>
<p>When using LLMs to generate diagrams and visualizations, always request SVG format instead of PNG or JPG.</p>
<h3>Why SVG?</h3>
<p>✅ <strong>Text-based</strong> → LLMs produce better results<br />
✅ <strong>Lower cost</strong> → Fewer tokens than base64-encoded images<br />
✅ <strong>Editable</strong> → Easy to modify after generation<br />
✅ <strong>Scalable</strong> → Perfect quality at any size<br />
✅ <strong>Version control friendly</strong> → Works great in Git</p>
<h3>Example Prompt</h3>
<pre><code>Create an architecture diagram showing PostgreSQL with pgvector integration.
Format: SVG, 800x400 pixels. Show: .NET Application → EF Core → PostgreSQL → Vector Search.
Use arrows to connect stages. Color scheme: Blue tones.
</code></pre>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2025-11-22-building-production-ready-llm-applications/images/svg-diagram-example.svg" alt="SVG Diagram Example" /></p>
<p>All diagrams in this article were generated as SVG, resulting in excellent quality and lower token costs!</p>
<blockquote>
<p><strong>Pro Tip</strong>: If you don't need photographs or complex renders, always choose SVG.</p>
</blockquote>
<h2>Architecture Roadmap: Putting It All Together</h2>
<p>Here's the recommended stack for building production LLM applications with .NET:</p>
<ol>
<li><strong>Orchestration</strong>: Microsoft.Extensions.AI + Semantic Kernel (when needed)</li>
<li><strong>Vector Database</strong>: PostgreSQL + Pgvector.EntityFrameworkCore</li>
<li><strong>RAG Pattern</strong>: Parent-Child chunks with 10-20% overlap</li>
<li><strong>Tools</strong>: MCP servers for reusability</li>
<li><strong>Reasoning</strong>: ReasoningEffortLevel instead of temperature</li>
<li><strong>Prompting</strong>: Critical rules at the end</li>
<li><strong>Cost Optimization</strong>: Make RAG a tool, not automatic</li>
</ol>
<h2>Key Takeaways</h2>
<p>Let me summarize the most important production tips:</p>
<ol>
<li><strong>Temperature is gone</strong> → Use <code>ReasoningEffortLevel</code> with GPT-5</li>
<li><strong>Rules at the end</strong> → Combat &quot;Lost in the Middle&quot;</li>
<li><strong>RAG as a tool</strong> → Reduce costs significantly</li>
<li><strong>Parent-Child pattern</strong> → Search small, respond with large</li>
<li><strong>Always use overlap</strong> → 10-20% is the standard</li>
<li><strong>pgvector for most cases</strong> → Unless you have billions of vectors</li>
<li><strong>MCP for reusability</strong> → One codebase, works everywhere</li>
<li><strong>SVG for diagrams</strong> → Better results, lower cost</li>
<li><strong>Hybrid chat history</strong> → Recent in prompt, old in vector DB</li>
<li><strong>RAG &gt; Fine-tuning</strong> → For knowledge, not behavior</li>
</ol>
<p>Happy coding! 🚀</p>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/api/posts/cover-picture-source/3a1dc6f4-a2ed-355c-994e-4e242bc504ca" />
      <media:content url="https://abp.io/api/posts/cover-picture-source/3a1dc6f4-a2ed-355c-994e-4e242bc504ca" medium="image" />
    </item>
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/building-an-api-key-management-system-with-abp-framework-28gn4efw</guid>
      <link>https://abp.io/community/posts/building-an-api-key-management-system-with-abp-framework-28gn4efw</link>
      <a10:author>
        <a10:name>salih</a10:name>
        <a10:uri>https://abp.io/community/members/salih</a10:uri>
      </a10:author>
      <category>microservices</category>
      <category>authentication</category>
      <category>abp-framework</category>
      <category>permission-management</category>
      <category>api</category>
      <title>Building an API Key Management System with ABP Framework</title>
      <description>Learn how to implement API key authentication in ABP Framework applications. This comprehensive guide covers what API keys are, when to use them over OAuth2/JWT, real-world use cases for mobile apps and microservices, and a complete implementation with user-based key management, SHA-256 hashing, permission delegation, and built-in UI.</description>
      <pubDate>Mon, 17 Nov 2025 10:45:38 Z</pubDate>
      <a10:updated>2026-09-30T06:00:26Z</a10:updated>
      <content:encoded><![CDATA[<h1>Building an API Key Management System with ABP Framework</h1>
<p>API keys are one of the most common authentication methods for APIs, especially for machine-to-machine communication. In this article, I'll explain what API key authentication is, when to use it, and how to implement a complete API key management system using ABP Framework.</p>
<h2>What is API Key Authentication?</h2>
<p>An API key is a unique identifier used to authenticate requests to an API. Unlike user credentials (username/password) or OAuth tokens, API keys are designed for:</p>
<ul>
<li><strong>Programmatic access</strong> - Scripts, CLI tools, and automated processes</li>
<li><strong>Service-to-service communication</strong> - Microservices authenticating with each other</li>
<li><strong>Third-party integrations</strong> - External systems accessing your API</li>
<li><strong>IoT devices</strong> - Embedded systems with limited authentication capabilities</li>
<li><strong>Mobile/Desktop apps</strong> - Native applications that need persistent authentication</li>
</ul>
<h2>Why Use API Keys?</h2>
<p>While modern authentication methods like OAuth2 and JWT are excellent for user authentication, API keys offer distinct advantages in certain scenarios:</p>
<p><strong>Simplicity</strong>: No complex OAuth flows or token refresh mechanisms. Just include the key in your request header.</p>
<p><strong>Long-lived</strong>: Unlike JWT tokens that expire in minutes/hours, API keys can remain valid for months or years, making them ideal for automated systems.</p>
<p><strong>Revocable</strong>: You can instantly revoke a compromised key without affecting user credentials.</p>
<p><strong>Granular Control</strong>: Different keys for different purposes (read-only, admin, specific services).</p>
<h2>Real-World Use Cases</h2>
<p>Here are some practical scenarios where API key authentication shines:</p>
<h3>1. Mobile Applications</h3>
<p>Your mobile app needs to call your backend APIs. Instead of storing user credentials or managing token refresh flows, use an API key.</p>
<pre><code class="language-csharp">// Mobile app configuration
var apiClient = new ApiClient(&quot;https://api.yourapp.com&quot;);
apiClient.SetApiKey(&quot;sk_mobile_prod_abc123...&quot;);
</code></pre>
<h3>2. Microservice Communication</h3>
<p>Service A needs to call Service B's protected endpoints.</p>
<pre><code class="language-csharp">// Order Service calling Inventory Service
var request = new HttpRequestMessage(HttpMethod.Get, &quot;https://inventory-service/api/products&quot;);
request.Headers.Add(&quot;X-Api-Key&quot;, _configuration[&quot;InventoryService:ApiKey&quot;]);
</code></pre>
<h3>3. Third-Party Integrations</h3>
<p>You're providing APIs to external partners or customers.</p>
<pre><code class="language-bash"># Customer's integration script
curl -H &quot;X-Api-Key: pk_partner_xyz789...&quot; \
     https://api.yourplatform.com/api/orders
</code></pre>
<h2>Implementing API Key Management in ABP Framework</h2>
<p>Now let's see how to build a complete API key management system using ABP Framework. I've created an open-source implementation that you can use in your projects.</p>
<h3>Project Overview</h3>
<p>The implementation consists of:</p>
<ul>
<li><strong>User-based API keys</strong> - Each key belongs to a specific user</li>
<li><strong>Permission delegation</strong> - Keys inherit user permissions with optional restrictions</li>
<li><strong>Secure storage</strong> - Keys are hashed with SHA-256</li>
<li><strong>Prefix-based lookup</strong> - Fast key resolution with caching</li>
<li><strong>Web UI</strong> - Manage keys through a user-friendly interface</li>
<li><strong>Multi-tenancy support</strong> - Full ABP multi-tenancy compatibility</li>
</ul>
<p><img src="https://raw.githubusercontent.com/salihozkara/AbpApikeyManagement/refs/heads/master/docs/images/api-keys.png" alt="API Keys Management UI" /></p>
<h3>Architecture Overview</h3>
<p>The solution follows ABP's modular architecture with four main layers:</p>
<pre><code>┌─────────────────────────────────────────────┐
│           Web Layer (UI)                    │
│  • Razor Pages for CRUD operations          │
│  • JavaScript for client interactions       │
└─────────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────────┐
│       AspNetCore Layer (Middleware)         │
│  • Authentication Handler                   │
│  • API Key Resolver (Header/Query)          │
└─────────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────────┐
│     Application Layer (Business Logic)      │
│  • ApiKeyAppService (CRUD operations)       │
│  • DTO mappings and validations             │
└─────────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────────┐
│        Domain Layer (Core Business)         │
│  • ApiKey Entity &amp; Manager                  │
│  • IApiKeyRepository                        │
│  • Domain services &amp; events                 │
└─────────────────────────────────────────────┘
</code></pre>
<h3>Key Components</h3>
<h4>1. Domain Layer - The Core Entity</h4>
<pre><code class="language-csharp">public class ApiKey : FullAuditedAggregateRoot&lt;Guid&gt;, IMultiTenant
{
    public virtual Guid? TenantId { get; protected set; }
    public virtual Guid UserId { get; protected set; }
    public virtual string Name { get; protected set; }
    public virtual string Prefix { get; protected set; }
    public virtual string KeyHash { get; protected set; }
    public virtual DateTime? ExpiresAt { get; protected set; }
    public virtual bool IsActive { get; protected set; }
    
    // Key format: {prefix}_{key}
    // Only the hash is stored, never the actual key
}
</code></pre>
<p><strong>Key Design Decisions:</strong></p>
<ul>
<li><strong>Prefix-based lookup</strong>: Keys have format <code>prefix_actualkey</code>. The prefix is indexed for fast database lookups.</li>
<li><strong>SHA-256 hashing</strong>: The actual key is hashed and never stored in plain text.</li>
<li><strong>User association</strong>: Each key belongs to a user, inheriting their permissions.</li>
<li><strong>Soft delete</strong>: Deleted keys are marked as deleted but not removed from database for audit purposes.</li>
</ul>
<h4>2. Authentication Flow</h4>
<p>Here's how authentication works when a request arrives:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2025-11-15-building-an-api-key-management-system/images/auth-flow.svg" alt="Authentication Flow" /></p>
<pre><code class="language-csharp">// 1. Extract API key from request
var apiKey = httpContext.Request.Headers[&quot;X-Api-Key&quot;].FirstOrDefault();
if (string.IsNullOrEmpty(apiKey)) return AuthenticateResult.NoResult();

// 2. Split prefix and key
var parts = apiKey.Split('_', 2);
var prefix = parts[0];
var key = parts[1];

// 3. Find key by prefix (cached)
var apiKeyEntity = await _apiKeyRepository.FindByPrefixAsync(prefix);
if (apiKeyEntity == null) return AuthenticateResult.Fail(&quot;Invalid API key&quot;);

// 4. Verify hash
var keyHash = HashHelper.ComputeSha256(key);
if (apiKeyEntity.KeyHash != keyHash) 
    return AuthenticateResult.Fail(&quot;Invalid API key&quot;);

// 5. Check expiration and active status
if (apiKeyEntity.ExpiresAt &lt; DateTime.UtcNow || !apiKeyEntity.IsActive)
    return AuthenticateResult.Fail(&quot;API key expired or inactive&quot;);

// 6. Create claims principal with user identity
var claims = new List&lt;Claim&gt;
{
    new Claim(AbpClaimTypes.UserId, apiKeyEntity.UserId.ToString()),
    new Claim(AbpClaimTypes.TenantId, apiKeyEntity.TenantId?.ToString() ?? &quot;&quot;),
    new Claim(&quot;ApiKeyId&quot;, apiKeyEntity.Id.ToString())
};

return AuthenticateResult.Success(ticket);
</code></pre>
<h4>3. Creating and Managing API Keys</h4>
<p><strong>Creating a new key:</strong></p>
<p><img src="https://raw.githubusercontent.com/salihozkara/AbpApikeyManagement/refs/heads/master/docs/images/new-api-key.png" alt="Create API Key Modal" /></p>
<pre><code class="language-csharp">public class ApiKeyManager : DomainService
{
    public async Task&lt;(ApiKey, string)&gt; CreateAsync(
        Guid userId, 
        string name, 
        DateTime? expiresAt = null)
    {
        // Generate unique prefix
        var prefix = await GenerateUniquePrefixAsync();
        
        // Generate secure random key
        var key = GenerateSecureRandomString(32);
        
        // Hash the key for storage
        var keyHash = HashHelper.ComputeSha256(key);
        
        var apiKey = new ApiKey(
            GuidGenerator.Create(),
            userId,
            name,
            prefix,
            keyHash,
            expiresAt,
            CurrentTenant.Id
        );
        
        await _apiKeyRepository.InsertAsync(apiKey);
        
        // Return both entity and the full key (prefix_key)
        // This is the ONLY time the actual key is visible
        return (apiKey, $&quot;{prefix}_{key}&quot;);
    }
}
</code></pre>
<p><strong>Important</strong>: The actual key is returned only once during creation. After that, only the hash is stored.</p>
<p><img src="https://raw.githubusercontent.com/salihozkara/AbpApikeyManagement/refs/heads/master/docs/images/created.png" alt="Created Key - Copy Once" /></p>
<h3>Using API Keys in Your Application</h3>
<p>Once created, clients can use the API key to authenticate:</p>
<p><strong>HTTP Header (Recommended):</strong></p>
<pre><code class="language-bash">curl -H &quot;X-Api-Key: sk_prod_abc123def456...&quot; \
     https://api.example.com/api/products
</code></pre>
<p><strong>JavaScript:</strong></p>
<pre><code class="language-javascript">const response = await fetch('https://api.example.com/api/products', {
  headers: {
    'X-Api-Key': 'sk_prod_abc123def456...'
  }
});
</code></pre>
<p><strong>C# HttpClient:</strong></p>
<pre><code class="language-csharp">var client = new HttpClient();
client.DefaultRequestHeaders.Add(&quot;X-Api-Key&quot;, &quot;sk_prod_abc123def456...&quot;);
var response = await client.GetAsync(&quot;https://api.example.com/api/products&quot;);
</code></pre>
<p><strong>Python:</strong></p>
<pre><code class="language-python">import requests

headers = {'X-Api-Key': 'sk_prod_abc123def456...'}
response = requests.get('https://api.example.com/api/products', headers=headers)
</code></pre>
<h3>Permission Management</h3>
<p>API keys inherit the user's permissions, but you can further restrict them:</p>
<p><img src="https://raw.githubusercontent.com/salihozkara/AbpApikeyManagement/refs/heads/master/docs/images/permissions.png" alt="Permission Management" /></p>
<p>This allows scenarios like:</p>
<ul>
<li>Read-only API key for reporting tools</li>
<li>Limited scope keys for third-party integrations</li>
<li>Service-specific keys with minimal permissions</li>
</ul>
<pre><code class="language-csharp">// Check if current request is authenticated via API key
if (CurrentUser.FindClaim(&quot;ApiKeyId&quot;) != null)
{
    var apiKeyId = CurrentUser.FindClaim(&quot;ApiKeyId&quot;).Value;
    // Additional API key specific logic
}
</code></pre>
<h2>Performance Considerations</h2>
<p>The implementation uses several optimizations:</p>
<p><strong>1. Prefix-based indexing</strong>: Database lookups are done by prefix (indexed column), not the full key hash.</p>
<p><strong>2. Distributed caching</strong>: API keys are cached after first lookup, dramatically reducing database queries.</p>
<pre><code class="language-csharp">// Cache configuration
Configure&lt;AbpDistributedCacheOptions&gt;(options =&gt;
{
    options.KeyPrefix = &quot;ApiKey:&quot;;
});
</code></pre>
<p><strong>3. Cache invalidation</strong>: When a key is modified or deleted, cache is automatically invalidated.</p>
<p><strong>Typical Performance:</strong></p>
<ul>
<li>Cached lookup: <strong>&lt; 5ms</strong></li>
<li>Database lookup: <strong>&lt; 50ms</strong></li>
<li>Cache hit rate: <strong>~95%</strong></li>
</ul>
<h2>Security Best Practices</h2>
<p>When implementing API key authentication, follow these guidelines:</p>
<p>✅ <strong>Always use HTTPS</strong> - Never send API keys over unencrypted connections</p>
<p>✅ <strong>Use different keys per environment</strong> - Separate keys for dev, staging, production</p>
<p>❌ <strong>Don't log the full key</strong> - Only log the prefix for debugging</p>
<h2>Getting Started</h2>
<p>The complete source code is available on GitHub:</p>
<p><strong>Repository</strong>: <a href="https://github.com/salihozkara/AbpApikeyManagement">github.com/salihozkara/AbpApikeyManagement</a></p>
<p>To integrate it into your ABP project:</p>
<ol>
<li>Clone or download the repository</li>
<li>Add project references to your solution</li>
<li>Add module dependencies to your modules</li>
<li>Run EF Core migrations to create the database tables</li>
<li>Navigate to <code>/ApiKeyManagement</code> to start managing keys</li>
</ol>
<pre><code class="language-csharp">// In your Web module
[DependsOn(typeof(ApiKeyManagementWebModule))]
public class YourWebModule : AbpModule
{
    // ...
}

// In your HttpApi.Host module
[DependsOn(typeof(ApiKeyManagementHttpApiModule))]
public class YourHttpApiHostModule : AbpModule
{
    // ...
}
</code></pre>
<h2>Conclusion</h2>
<p>API key authentication remains a crucial part of modern API security, especially for machine-to-machine communication. While it shouldn't replace user authentication methods like OAuth2 for user-facing applications, it's perfect for:</p>
<ul>
<li>Automated scripts and tools</li>
<li>Service-to-service communication</li>
<li>Third-party integrations</li>
<li>Long-lived access without token refresh complexity</li>
</ul>
<p>The implementation shown here demonstrates how ABP Framework's modular architecture, DDD principles, and built-in features (multi-tenancy, caching, permissions) can be leveraged to build a production-ready API key management system.</p>
<p>The solution is open-source and ready to be integrated into your ABP projects. Feel free to explore the code, suggest improvements, or adapt it to your specific needs.</p>
<p><strong>Resources:</strong></p>
<ul>
<li>GitHub Repository: <a href="https://github.com/salihozkara/AbpApikeyManagement">salihozkara/AbpApikeyManagement</a></li>
<li>ABP Framework: <a href="https://abp.io">abp.io</a></li>
<li>ABP Documentation: <a href="https://abp.io/docs/latest">docs.abp.io</a></li>
</ul>
<p>Happy coding! 🚀</p>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/api/posts/cover-picture-source/3a1da398-6d95-47a8-abee-9047196d042f" />
      <media:content url="https://abp.io/api/posts/cover-picture-source/3a1da398-6d95-47a8-abee-9047196d042f" medium="image" />
    </item>
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/what-is-that-domain-service-in-ddd-for-.net-developers-uqnpwjja</guid>
      <link>https://abp.io/community/posts/what-is-that-domain-service-in-ddd-for-.net-developers-uqnpwjja</link>
      <a10:author>
        <a10:name>salih</a10:name>
        <a10:uri>https://abp.io/community/members/salih</a10:uri>
      </a10:author>
      <category>domain-service</category>
      <category>ddd</category>
      <category>abp-framework</category>
      <category>dotnet</category>
      <title>What is That Domain Service in DDD for .NET Developers</title>
      <description>Learn what Domain Services are in Domain-Driven Design and when to use them in .NET projects. This practical guide covers the difference between Domain and Application Services, features real-world examples including money transfers and order processing, and shows how ABP Framework's DomainService base class simplifies implementation with built-in localization, logging, and event publishing.</description>
      <pubDate>Mon, 10 Nov 2025 06:37:51 Z</pubDate>
      <a10:updated>2026-09-30T09:33:14Z</a10:updated>
      <content:encoded><![CDATA[<h1>What is That Domain Service in DDD for .NET Developers?</h1>
<p>When you start applying <strong>Domain-Driven Design (DDD)</strong> in your .NET projects, you'll quickly meet some core building blocks: <strong>Entities</strong>, <strong>Value Objects</strong>, <strong>Aggregates</strong>, and finally… <strong>Domain Services</strong>.</p>
<p>But what exactly <em>is</em> a Domain Service, and when should you use one?</p>
<p>Let's break it down with practical examples and ABP Framework implementation patterns.</p>
<hr />
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2025-11-08-what-is-that-domain-service-in-ddd-for-net-developers/images/ddd-layers.png" alt="Diagram showing layered architecture: UI, Application, Domain (Entities, Value Objects, Domain Services), Infrastructure boundaries" /></p>
<h2>The Core Idea of Domain Services</h2>
<p>A <strong>Domain Service</strong> represents <strong>a domain concept that doesn't naturally belong to a single Entity or Value Object</strong>, but still belongs to the <strong>domain layer</strong> - <em>not</em> to the application or infrastructure.</p>
<p>In short:</p>
<blockquote>
<p>If your business logic doesn't fit into a single Entity, but still expresses a business rule, that's a good candidate for a Domain Service.</p>
</blockquote>
<hr />
<h2>Example: Money Transfer Between Accounts</h2>
<p>Imagine a simple <strong>banking system</strong> where you can transfer money between accounts.</p>
<pre><code class="language-csharp">public class Account : AggregateRoot&lt;Guid&gt;
{
    public decimal Balance { get; private set; }

    // Domain model should be created in a valid state.
    public Account(decimal openingBalance = 0m)
    {
        if (openingBalance &lt; 0)
            throw new BusinessException(&quot;Opening balance cannot be negative.&quot;);
        Balance = openingBalance;
    }

    public void Withdraw(decimal amount)
    {
        if (amount &lt;= 0)
            throw new BusinessException(&quot;Withdrawal amount must be positive.&quot;);
        if (Balance &lt; amount)
            throw new BusinessException(&quot;Insufficient balance.&quot;);
        Balance -= amount;
    }

    public void Deposit(decimal amount)
    {
        if (amount &lt;= 0)
            throw new BusinessException(&quot;Deposit amount must be positive.&quot;);
        Balance += amount;
    }
}
</code></pre>
<blockquote>
<p>In a richer domain you might introduce a <code>Money</code> value object (amount + currency + rounding rules) instead of a raw <code>decimal</code> for stronger invariants.</p>
</blockquote>
<hr />
<h2>Implementing a Domain Service</h2>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2025-11-08-what-is-that-domain-service-in-ddd-for-net-developers/images/money-transfer.png" alt="Conceptual illustration showing how a domain service coordinates two aggregates" /></p>
<pre><code class="language-csharp">public class MoneyTransferManager : DomainService
{
    public void Transfer(Account from, Account to, decimal amount)
    {
        if (from is null) throw new ArgumentNullException(nameof(from));
        if (to is null) throw new ArgumentNullException(nameof(to));
        if (ReferenceEquals(from, to))
            throw new BusinessException(&quot;Cannot transfer to the same account.&quot;);
        if (amount &lt;= 0)
            throw new BusinessException(&quot;Transfer amount must be positive.&quot;);

        from.Withdraw(amount);
        to.Deposit(amount);
    }
}
</code></pre>
<blockquote>
<p><strong>Naming Convention</strong>: ABP suggests using the <code>Manager</code> or <code>Service</code> suffix for domain services. We typically use <code>Manager</code> suffix (e.g., <code>IssueManager</code>, <code>OrderManager</code>).</p>
</blockquote>
<blockquote>
<p><strong>Note</strong>: This is a synchronous domain operation. The domain service focuses purely on business rules without infrastructure concerns like database access or event publishing. For cross-cutting concerns, use Application Service layer or domain events.</p>
</blockquote>
<hr />
<h2>Domain Service vs. Application Service</h2>
<p>Here's a quick comparison:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2025-11-08-what-is-that-domain-service-in-ddd-for-net-developers/images/service-comparison.png" alt="Side-by-side comparison: Domain Service (pure business rule) vs Application Service (orchestrates repositories, transactions, external systems)" /></p>
<p>| Layer                   | Responsibility                                                                   | Example                      |
| ----------------------- | -------------------------------------------------------------------------------- | ---------------------------- |
| <strong>Domain Service</strong>      | Pure business rule spanning entities/aggregates                                  | <code>MoneyTransferManager</code>       |
| <strong>Application Service</strong> | Orchestrates use cases, handles repositories, transactions, external systems     | <code>BankAppService</code>             |</p>
<hr />
<h2>The Application Service Layer</h2>
<p>An <strong>Application Service</strong> orchestrates the domain logic and handles infrastructure concerns:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2025-11-08-what-is-that-domain-service-in-ddd-for-net-developers/images/abp-structure.png" alt="ABP solution layout highlighting Domain layer (Entities, Value Objects, Domain Services) separate from Application and Infrastructure layers" /></p>
<pre><code class="language-csharp">public class BankAppService : ApplicationService
{
    private readonly IRepository&lt;Account, Guid&gt; _accountRepository;
    private readonly MoneyTransferManager _moneyTransferManager;

    public BankAppService(
        IRepository&lt;Account, Guid&gt; accountRepository,
        MoneyTransferManager moneyTransferManager)
    {
        _accountRepository = accountRepository;
        _moneyTransferManager = moneyTransferManager;
    }

    public async Task TransferAsync(Guid fromId, Guid toId, decimal amount)
    {
        var from = await _accountRepository.GetAsync(fromId);
        var to = await _accountRepository.GetAsync(toId);

        _moneyTransferManager.Transfer(from, to, amount);

        await _accountRepository.UpdateAsync(from);
        await _accountRepository.UpdateAsync(to);
    }
}
</code></pre>
<blockquote>
<p><strong>Note</strong>: Domain services are automatically registered to Dependency Injection with a <strong>Transient</strong> lifetime when inheriting from <code>DomainService</code>.</p>
</blockquote>
<hr />
<h2>Benefits of ABP's DomainService Base Class</h2>
<p>The <code>DomainService</code> base class gives you access to:</p>
<ul>
<li><strong>Localization</strong> (<code>IStringLocalizer L</code>) - Multi-language support for error messages</li>
<li><strong>Logging</strong> (<code>ILogger Logger</code>) - Built-in logger for tracking operations</li>
<li><strong>Local Event Bus</strong> (<code>ILocalEventBus LocalEventBus</code>) - Publish local domain events</li>
<li><strong>Distributed Event Bus</strong> (<code>IDistributedEventBus DistributedEventBus</code>) - Publish distributed events</li>
<li><strong>GUID Generator</strong> (<code>IGuidGenerator GuidGenerator</code>) - Sequential GUID generation for better database performance</li>
<li><strong>Clock</strong> (<code>IClock Clock</code>) - Abstraction for date/time operations</li>
</ul>
<h3>Example with ABP Features</h3>
<blockquote>
<p><strong>Important</strong>: While domain services <em>can</em> publish domain events using the event bus, they should remain focused on business rules. Consider whether event publishing belongs in the domain service or the application service based on your consistency boundaries.</p>
</blockquote>
<pre><code class="language-csharp">public class MoneyTransferredEvent
{
    public Guid FromAccountId { get; set; }
    public Guid ToAccountId { get; set; }
    public decimal Amount { get; set; }
}

public class MoneyTransferManager : DomainService
{
    public async Task TransferAsync(Account from, Account to, decimal amount)
    {
        if (from is null) throw new ArgumentNullException(nameof(from));
        if (to is null) throw new ArgumentNullException(nameof(to));
        if (ReferenceEquals(from, to))
            throw new BusinessException(L[&quot;SameAccountTransferNotAllowed&quot;]);
        if (amount &lt;= 0)
            throw new BusinessException(L[&quot;InvalidTransferAmount&quot;]);

        // Log the operation
        Logger.LogInformation(
            &quot;Transferring {Amount} from {From} to {To}&quot;, amount, from.Id, to.Id);

        from.Withdraw(amount);
        to.Deposit(amount);

        // Publish local event for further policies (limits, notifications, audit, etc.)
        await LocalEventBus.PublishAsync(
            new MoneyTransferredEvent
            {
                FromAccountId = from.Id,
                ToAccountId = to.Id,
                Amount = amount
            }
        );
    }
}
</code></pre>
<blockquote>
<p><strong>Local Events</strong>: By default, event handlers are executed within the same Unit of Work. If an event handler throws an exception, the database transaction is rolled back, ensuring consistency.</p>
</blockquote>
<hr />
<h2>Best Practices</h2>
<h3>1. Keep Domain Services Pure and Focused on Business Rules</h3>
<p>Domain services should only contain business logic. They should not be responsible for application-level concerns like database transactions, authorization, or fetching entities from a repository.</p>
<pre><code class="language-csharp">// Good ✅ Pure rule: receives aggregates already loaded.
public class MoneyTransferManager : DomainService
{
    public void Transfer(Account from, Account to, decimal amount)
    {
        // Business rules and coordination
        from.Withdraw(amount);
        to.Deposit(amount);
    }
}

// Bad ❌ Mixing application and domain concerns.
// This logic belongs in an Application Service.
public class MoneyTransferManager : DomainService
{
    private readonly IRepository&lt;Account, Guid&gt; _accountRepository;
    
    public MoneyTransferManager(IRepository&lt;Account, Guid&gt; accountRepository)
    {
        _accountRepository = accountRepository;
    }

    public async Task TransferAsync(Guid fromId, Guid toId, decimal amount)
    {
        // Don't fetch entities inside a domain service.
        var from = await _accountRepository.GetAsync(fromId);
        var to = await _accountRepository.GetAsync(toId);

        from.Withdraw(amount);
        to.Deposit(amount);
    }
}
</code></pre>
<h3>2. Leverage Entity Methods First</h3>
<p>Always prefer encapsulating business logic within an entity's methods when the logic belongs to a single aggregate. A domain service should only be used when a business rule spans multiple aggregates.</p>
<pre><code class="language-csharp">// Good ✅ - Internal state change belongs in the entity
public class Account : AggregateRoot&lt;Guid&gt;
{
    public decimal Balance { get; private set; }
    
    public void Withdraw(decimal amount)
    {
        if (Balance &lt; amount)
            throw new BusinessException(&quot;Insufficient balance&quot;);
        Balance -= amount;
    }
}

// Use Domain Service only when logic spans multiple aggregates
public class MoneyTransferManager : DomainService
{
    public void Transfer(Account from, Account to, decimal amount)
    {
        from.Withdraw(amount);  // Delegates to entity
        to.Deposit(amount);     // Delegates to entity
    }
}
</code></pre>
<h3>3. Prefer Domain Services over Anemic Entities</h3>
<p>Avoid placing business logic that coordinates multiple entities directly into an application service. This leads to an &quot;Anemic Domain Model,&quot; where entities are just data bags and the business logic is scattered in application services.</p>
<pre><code class="language-csharp">// Bad ❌ - Business logic is in the Application Service (Anemic Domain)
public class BankAppService : ApplicationService
{
    public async Task TransferAsync(Guid fromId, Guid toId, decimal amount)
    {
        var from = await _accountRepository.GetAsync(fromId);
        var to = await _accountRepository.GetAsync(toId);

        // This is domain logic and should be in a Domain Service
        if (ReferenceEquals(from, to))
            throw new BusinessException(&quot;Cannot transfer to the same account.&quot;);
        if (amount &lt;= 0)
            throw new BusinessException(&quot;Transfer amount must be positive.&quot;);

        from.Withdraw(amount);
        to.Deposit(amount);
    }
}
</code></pre>
<h3>4. Use Meaningful Names</h3>
<p>ABP recommends naming domain services with a <code>Manager</code> or <code>Service</code> suffix based on the business concept they represent.</p>
<pre><code class="language-csharp">// Good ✅
MoneyTransferManager
OrderManager
IssueManager
InventoryAllocationService

// Bad ❌
AccountHelper
OrderProcessor
</code></pre>
<hr />
<h2>Advanced Example: Order Processing with Inventory Check</h2>
<p>Here's a more complex scenario showing domain service interaction with domain abstractions:</p>
<pre><code class="language-csharp">// Domain abstraction - defines contract but implementation is in infrastructure
public interface IInventoryChecker : IDomainService
{
    Task&lt;bool&gt; IsAvailableAsync(Guid productId, int quantity);
}

public class OrderManager : DomainService
{
    private readonly IInventoryChecker _inventoryChecker;

    public OrderManager(IInventoryChecker inventoryChecker)
    {
        _inventoryChecker = inventoryChecker;
    }

    // Validates and coordinates order processing with inventory
    public async Task ProcessAsync(Order order, Inventory inventory)
    {
        // First pass: validate availability using domain abstraction
        foreach (var item in order.Items)
        {
            if (!await _inventoryChecker.IsAvailableAsync(item.ProductId, item.Quantity))
            {
                throw new BusinessException(
                    L[&quot;InsufficientInventory&quot;, item.ProductId]);
            }
        }
        
        // Second pass: perform reservations
        foreach (var item in order.Items)
        {
            inventory.Reserve(item.ProductId, item.Quantity);
        }
        
        order.SetStatus(OrderStatus.Processing);
    }
}
</code></pre>
<blockquote>
<p><strong>Domain Abstractions</strong>: The <code>IInventoryChecker</code> interface is a domain service contract. Its implementation can be in the infrastructure layer, but the contract belongs to the domain. This keeps the domain layer independent of infrastructure details while still allowing complex validations.</p>
</blockquote>
<blockquote>
<p><strong>Caution</strong>: Always perform validation and action atomically within a single transaction to avoid race conditions (TOCTOU - Time Of Check Time Of Use).</p>
</blockquote>
<blockquote>
<p><strong>Transaction Boundaries</strong>: When a domain service coordinates multiple aggregates, ensure the Application Service wraps the operation in a Unit of Work to maintain consistency. ABP's <code>[UnitOfWork]</code> attribute or Application Services' built-in UoW handling ensures this automatically.</p>
</blockquote>
<hr />
<h2>Common Pitfalls and How to Avoid Them</h2>
<h3>1. Bloated Domain Services</h3>
<p>Don't let domain services become &quot;god objects&quot; that do everything. Keep them focused on a single business concept.</p>
<pre><code class="language-csharp">// Bad ❌ - Too many responsibilities
public class AccountManager : DomainService
{
    public void Transfer(Account from, Account to, decimal amount) { }
    public void CalculateInterest(Account account) { }
    public void GenerateStatement(Account account) { }
    public void ValidateAddress(Account account) { }
    public void SendNotification(Account account) { }
}

// Good ✅ - Split by business concept
public class MoneyTransferManager : DomainService
{
    public void Transfer(Account from, Account to, decimal amount) { }
}

public class InterestCalculationManager : DomainService
{
    public void Calculate(Account account) { }
}
</code></pre>
<h3>2. Circular Dependencies Between Aggregates</h3>
<p>When domain services coordinate multiple aggregates, be careful about creating circular dependencies.</p>
<pre><code class="language-csharp">// Consider using Domain Events instead of direct coupling
public class OrderManager : DomainService
{
    public async Task ProcessAsync(Order order)
    {
        order.SetStatus(OrderStatus.Processing);
        
        // Instead of directly modifying Customer aggregate here,
        // publish an event that CustomerManager can handle
        await LocalEventBus.PublishAsync(new OrderProcessedEvent
        {
            OrderId = order.Id,
            CustomerId = order.CustomerId
        });
    }
}
</code></pre>
<h3>3. Confusing Domain Service with Domain Event Handlers</h3>
<p>Domain services orchestrate business operations. Domain event handlers react to state changes. Don't mix them.</p>
<pre><code class="language-csharp">// Domain Service - Orchestrates business logic
public class MoneyTransferManager : DomainService
{
    public async Task TransferAsync(Account from, Account to, decimal amount)
    {
        from.Withdraw(amount);
        to.Deposit(amount);
        await LocalEventBus.PublishAsync(
            new MoneyTransferredEvent
            {
                FromAccountId = from.Id,
                ToAccountId = to.Id,
                Amount = amount
            }
        );
    }
}

// Domain Event Handler - Reacts to domain events
public class MoneyTransferredEventHandler : 
    ILocalEventHandler&lt;MoneyTransferredEvent&gt;,
    ITransientDependency
{
    public async Task HandleEventAsync(MoneyTransferredEvent eventData)
    {
        // Send notification, update analytics, etc.
    }
}
</code></pre>
<hr />
<h2>Testing Domain Services</h2>
<p>Domain services are easy to test because they have minimal dependencies:</p>
<pre><code class="language-csharp">public class MoneyTransferManager_Tests
{
    [Fact]
    public void Should_Transfer_Money_Between_Accounts()
    {
        // Arrange
        var fromAccount = new Account(1000m);
        var toAccount = new Account(500m);
        var manager = new MoneyTransferManager();

        // Act
        manager.Transfer(fromAccount, toAccount, 200m);

        // Assert
        fromAccount.Balance.ShouldBe(800m);
        toAccount.Balance.ShouldBe(700m);
    }

    [Fact]
    public void Should_Throw_When_Insufficient_Balance()
    {
        var fromAccount = new Account(100m);
        var toAccount = new Account(500m);
        var manager = new MoneyTransferManager();
        
        Should.Throw&lt;BusinessException&gt;(() =&gt; 
            manager.Transfer(fromAccount, toAccount, 200m));
    }

    [Fact]
    public void Should_Throw_When_Amount_Is_NonPositive()
    {
        var fromAccount = new Account(100m);
        var toAccount = new Account(100m);
        var manager = new MoneyTransferManager();
        
        Should.Throw&lt;BusinessException&gt;(() =&gt; 
            manager.Transfer(fromAccount, toAccount, 0m));
        Should.Throw&lt;BusinessException&gt;(() =&gt; 
            manager.Transfer(fromAccount, toAccount, -5m));
    }

    [Fact]
    public void Should_Throw_When_Same_Account()
    {
        var account = new Account(100m);
        var manager = new MoneyTransferManager();
        
        Should.Throw&lt;BusinessException&gt;(() =&gt; 
            manager.Transfer(account, account, 10m));
    }
}
</code></pre>
<h3>Integration Testing with ABP Test Infrastructure</h3>
<pre><code class="language-csharp">public class MoneyTransferManager_IntegrationTests : BankingDomainTestBase
{
    private readonly MoneyTransferManager _transferManager;
    private readonly IRepository&lt;Account, Guid&gt; _accountRepository;

    public MoneyTransferManager_IntegrationTests()
    {
        _transferManager = GetRequiredService&lt;MoneyTransferManager&gt;();
        _accountRepository = GetRequiredService&lt;IRepository&lt;Account, Guid&gt;&gt;();
    }

    [Fact]
    public async Task Should_Transfer_And_Persist_Changes()
    {
        // Arrange
        var fromAccount = new Account(1000m);
        var toAccount = new Account(500m);
        
        await _accountRepository.InsertAsync(fromAccount);
        await _accountRepository.InsertAsync(toAccount);
        await UnitOfWorkManager.Current.SaveChangesAsync();

        // Act
        await _transferManager.TransferAsync(fromAccount, toAccount, 200m);
        await UnitOfWorkManager.Current.SaveChangesAsync();

        // Assert
        var updatedFrom = await _accountRepository.GetAsync(fromAccount.Id);
        var updatedTo = await _accountRepository.GetAsync(toAccount.Id);
        
        updatedFrom.Balance.ShouldBe(800m);
        updatedTo.Balance.ShouldBe(700m);
    }
}
</code></pre>
<hr />
<h2>When NOT to Use a Domain Service</h2>
<p>Not every operation needs a domain service. Avoid over-engineering:</p>
<ol>
<li><strong>Simple CRUD Operations</strong>: Use Application Services directly</li>
<li><strong>Single Aggregate Operations</strong>: Use Entity methods</li>
<li><strong>Infrastructure Concerns</strong>: Use Infrastructure Services</li>
<li><strong>Application Workflow</strong>: Use Application Services</li>
</ol>
<pre><code class="language-csharp">// Don't create a domain service for this ❌
public class AccountBalanceReader : DomainService
{
    public decimal GetBalance(Account account) =&gt; account.Balance;
}

// Just use the property directly ✅
var balance = account.Balance;
</code></pre>
<hr />
<h2>Summary</h2>
<ul>
<li><strong>Domain Services</strong> are domain-level, not application-level</li>
<li>They encapsulate <strong>business logic that doesn't belong to a single entity</strong></li>
<li>They keep your <strong>entities clean</strong> and <strong>business logic consistent</strong></li>
<li>In ABP, inherit from <code>DomainService</code> to get built-in features</li>
<li>Keep them <strong>focused</strong>, <strong>pure</strong>, and <strong>testable</strong></li>
</ul>
<hr />
<h2>Final Thoughts</h2>
<p>Next time you're writing a business rule that doesn't clearly belong to an entity, ask yourself:</p>
<blockquote>
<p>&quot;Is this a Domain Service?&quot;</p>
</blockquote>
<p>If it's pure domain logic that coordinates multiple entities or implements a business rule, <strong>put it in the domain layer</strong> - your future self (and your team) will thank you.</p>
<p>Domain Services are a powerful tool in your DDD toolkit. Use them wisely to keep your domain model clean, expressive, and maintainable.</p>
<hr />
]]></content:encoded>
      <media:thumbnail url="https://abp.io/api/posts/cover-picture-source/3a1d7ea9-1142-d6c6-667b-bf2fea1b6be7" />
      <media:content url="https://abp.io/api/posts/cover-picture-source/3a1d7ea9-1142-d6c6-667b-bf2fea1b6be7" medium="image" />
    </item>
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/truly-layering-a-.net-application-based-on-ddd-principles-428jhn3a</guid>
      <link>https://abp.io/community/posts/truly-layering-a-.net-application-based-on-ddd-principles-428jhn3a</link>
      <a10:author>
        <a10:name>salih</a10:name>
        <a10:uri>https://abp.io/community/members/salih</a10:uri>
      </a10:author>
      <category>repository</category>
      <category>ddd</category>
      <category>abp-framework</category>
      <category>application-services</category>
      <category>architectural-design</category>
      <title>Truly Layering a .NET Application Based on DDD Principles</title>
      <description>Learn how to structure .NET apps with Layered Architecture and DDD to keep business logic clean and maintainable. Covers core layers Presentation, Application, Domain, Infrastructure and DDD concepts like Entities, Value Objects, Aggregates, and Repositories. Shows how ABP Framework simplifies setup with base classes, generic repositories, and ApplicationServices.</description>
      <pubDate>Mon, 15 Sep 2025 07:05:18 Z</pubDate>
      <a10:updated>2026-09-30T13:37:00Z</a10:updated>
      <content:encoded><![CDATA[<h1><strong>Truly Layering a .NET Application Based on DDD Principles</strong></h1>
<p>Okay, so we ALL been there, right? You start new project thinking &quot;this time will be different&quot; - clean code, perfect architecture, everything organized. Fast forward 3 months and your codebase look like someone throw grenade into bowl of spaghetti. Business logic everywhere, your controllers doing database work, and every new feature feel like defusing bomb.</p>
<p>I been there too many times, and honestly, it suck. But here thing - there actually way to build .NET apps that not turn into maintenance nightmare. It called <strong>Layered Architecture</strong> + <strong>Domain-Driven Design (DDD)</strong>, and once you get it, it game changer.</p>
<p>Let me walk you through this step by step, no fluff, just practical stuff that actually work.</p>
<h3><strong>Layered Architecture 101 (The Foundation)</strong></h3>
<p>So layered architecture basically about keeping your code organized. Instead of having everything mixed together like bad smoothie, you separate concerns into different layers. Think like organizing your room - clothes go in closet, books on shelf, etc.</p>
<p>Here how it typically break down:</p>
<ul>
<li><strong>Presentation Layer (UI):</strong> This what users actually see and click on - your ASP.NET Core MVC stuff, Razor Pages, Blazor, whatever float your boat.</li>
<li><strong>Application Layer:</strong> The conductor of orchestra. It not do heavy lifting itself, but tell everyone else what to do. It like middle manager of your code.</li>
<li><strong>Domain Layer:</strong> The VIP section. This where all your business rules live - entities, value objects, whole nine yards. This layer pure and not give damn about databases or UI.</li>
<li><strong>Infrastructure Layer:</strong> The &quot;how-to&quot; guy. Database stuff, email sending, API calls - basically all technical plumbing that make everything work.</li>
</ul>
<p>The golden rule? <strong>Dependency Rule</strong>: Layers can only talk to layers below them (or more central). UI talk to Application, Application talk to Domain, but Domain? Domain not talk to anyone. It the cool kid that everyone want to hang out with.</p>
<h3><strong>DDD: Where Magic Happen</strong></h3>
<p>Alright, so DDD not some fancy framework you install from NuGet. It more like mindset - basically saying &quot;hey, let make our code actually reflect business we building for.&quot; Instead of having bunch of random classes, we organize everything around actual business domain.</p>
<p>Think like this: if you building e-commerce app, your code should scream &quot;I'M E-COMMERCE APP&quot; not &quot;I'M BUNCH OF RANDOM CLASSES.&quot;</p>
<p>Here toolkit DDD give you (all living in your Domain Layer):</p>
<ul>
<li><strong>Entity:</strong> This something that have identity. Like <code>Customer</code> - two customers with same name still different people because they have different IDs. It like having two friends named John - they not same person.</li>
<li><strong>Value Object:</strong> Opposite of entity. It defined by what it contain, not who it is. <code>Address</code> perfect for this - if two addresses have same street, city, and zip code, they same address. Usually immutable too.</li>
<li><strong>Aggregate &amp; Aggregate Root:</strong> This where it get interesting. Aggregate like family of related objects that stick together. <strong>Aggregate Root</strong> head of family - only one you talk to when you want change something. Like <code>Order</code> that contain <code>OrderItem</code>s. You not mess with <code>OrderItem</code> directly, you tell <code>Order</code> to handle it.</li>
<li><strong>Repository (Interface):</strong> Think like your data access contract. It say &quot;here how you can get and save stuff&quot; without caring about whether it SQL Server, MongoDB, or file on your desktop. Interface live in Domain, implementation go in Infrastructure.</li>
<li><strong>Domain Service:</strong> When business logic too complex for single entity or value object, this your go-to. It like utility class but for business rules.</li>
</ul>
<h3><strong>Putting It All Together: Real C# Code</strong></h3>
<p>Alright, enough theory. Let see what this actually look like in real .NET solution. You typically have projects like:</p>
<ul>
<li><code>MyProject.Domain</code> (or <code>.Core</code>) - The VIP section</li>
<li><code>MyProject.Application</code> - The middle manager</li>
<li><code>MyProject.Infrastructure</code> - The technical guy</li>
<li><code>MyProject.Web</code> (or whatever UI you using) - The pretty face</li>
</ul>
<p><strong>1. The Domain Layer (<code>MyProject.Domain</code>) - The Heart</strong></p>
<p>This where magic happen. Zero dependencies on other projects (maybe some basic utility libraries, but that it). Pure business logic, no database nonsense, no UI concerns.</p>
<pre><code class="language-csharp">// In MyProject.Domain/Orders/Order.cs
public class Order : AggregateRoot&lt;Guid&gt;
{
    public Address ShippingAddress { get; private set; }
    private readonly List&lt;OrderItem&gt; _orderItems = new();
    public IReadOnlyCollection&lt;OrderItem&gt; OrderItems =&gt; _orderItems.AsReadOnly();

    // Private constructor for ORM
    private Order() { }

    public Order(Guid id, Address shippingAddress) : base(id)
    {
        ShippingAddress = shippingAddress;
    }

    public void AddOrderItem(Guid productId, int quantity, decimal price)
    {
        if (quantity &lt;= 0)
        {
            throw new BusinessException(&quot;Quantity must be greater than zero.&quot;);
        }
        // More business rules...
        _orderItems.Add(new OrderItem(productId, quantity, price));
    }
}

// In MyProject.Domain/Orders/IOrderRepository.cs
public interface IOrderRepository
{
    Task&lt;Order&gt; GetAsync(Guid id);
    Task AddAsync(Order order);
    Task UpdateAsync(Order order);
}
</code></pre>
<p>See what I mean? The <code>Order</code> class all about business rules (<code>AddOrderItem</code> with validation and all that jazz). It not give damn about databases or how it get saved. That someone else problem.</p>
<p><strong>2. The Application Layer (<code>MyProject.Application</code>) - The Conductor</strong></p>
<p>This where we orchestrate everything. It talk to domain objects and use repositories to get/save data. Think like middle manager that coordinate work but not do heavy lifting.</p>
<pre><code class="language-csharp">// In MyProject.Application/Orders/OrderAppService.cs
public class OrderAppService
{
    private readonly IOrderRepository _orderRepository;

    public OrderAppService(IOrderRepository orderRepository)
    {
        _orderRepository = orderRepository;
    }

    public async Task CreateOrderAsync(CreateOrderDto input)
    {
        var shippingAddress = new Address(input.Street, input.City, input.ZipCode);
        var order = new Order(Guid.NewGuid(), shippingAddress);

        foreach (var item in input.Items)
        {
            order.AddOrderItem(item.ProductId, item.Quantity, item.Price);
        }

        await _orderRepository.AddAsync(order);
    }
}
</code></pre>
<p>The application service coordinate everything but let domain objects handle actual business rules. Clean separation!</p>
<p><strong>3. The Infrastructure Layer (<code>MyProject.Infrastructure</code>) - The Technical Guy</strong></p>
<p>This where we implement all interfaces we defined in domain. Entity Framework Core, email services, API clients - all technical plumbing live here.</p>
<pre><code class="language-csharp">// In MyProject.Infrastructure/Orders/EfCoreOrderRepository.cs
public class EfCoreOrderRepository : IOrderRepository
{
    private readonly MyDbContext _dbContext;

    public EfCoreOrderRepository(MyDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task&lt;Order&gt; GetAsync(Guid id)
    {
        // EF Core logic to get the order
        return await _dbContext.Orders.FindAsync(id);
    }

    public async Task AddAsync(Order order)
    {
        await _dbContext.Orders.AddAsync(order);
    }
    
    // ... other implementations
}
</code></pre>
<h3><strong>ABP Framework: The Shortcut (Because We Lazy)</strong></h3>
<p>Look, setting all this up from scratch pain. That where <strong>ABP Framework</strong> come in clutch. It basically DDD and layered architecture on steroids, and it do all boring setup work for you.</p>
<p>ABP not just talk talk - it walk walk. When you create new ABP solution, boom! Perfect project structure, all layered and DDD-compliant, ready to go.</p>
<p>Here what you get out of box:</p>
<ul>
<li><strong>Base Classes:</strong> <code>AggregateRoot</code>, <code>Entity</code>, <code>ValueObject</code> - all with good stuff like optimistic concurrency and domain events. No more writing boilerplate.</li>
<li><strong>Generic Repositories:</strong> No more writing <code>IRepository</code> interfaces for every single entity. ABP give you <code>IRepository&lt;TEntity, TKey&gt;</code> with all standard CRUD methods. Just inject it and go.</li>
<li><strong>Application Services:</strong> Inherit from <code>ApplicationService</code> and boom - you done. It handle validation, authorization, exception handling, all that cross-cutting concern stuff without cluttering your actual business logic.</li>
</ul>
<p>With ABP, our <code>OrderAppService</code> become way cleaner:</p>
<pre><code class="language-csharp">// In ABP project, this much cleaner
public class OrderAppService : ApplicationService, IOrderAppService
{
    private readonly IRepository&lt;Order, Guid&gt; _orderRepository;

    public OrderAppService(IRepository&lt;Order, Guid&gt; orderRepository)
    {
        _orderRepository = orderRepository;
    }

    public async Task CreateAsync(CreateOrderDto input)
    {
        // ... same logic as before, but using ABP generic repository
        var order = new Order(...);
        await _orderRepository.InsertAsync(order);
    }
}
</code></pre>
<h3><strong>Wrapping Up</strong></h3>
<p>Look, I get it - this stuff take discipline and it not always fastest way to get features out door. But here thing: when you actually layer your app properly and put solid Domain Model at center, you end up with software that not suck to maintain.</p>
<p>Your code start speaking language of business instead of some random technical jargon. That whole point of DDD - make your code reflect what you actually building for.</p>
<p>Yeah, it take work upfront, but payoff huge. And frameworks like ABP make journey way less painful. Trust me, your future self will thank you when you not debugging spaghetti code at 2 AM.</p>
<p>What you think? You try this approach before, or you still stuck in spaghetti code phase? Let me know in comments!</p>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/api/posts/cover-picture-source/3a1c5e5e-1322-2d31-f841-8cd2b071b854" />
      <media:content url="https://abp.io/api/posts/cover-picture-source/3a1c5e5e-1322-2d31-f841-8cd2b071b854" medium="image" />
    </item>
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/we-had-a-blast-at-basta-frankfurt-2025-9cpcf17y</guid>
      <link>https://abp.io/community/posts/we-had-a-blast-at-basta-frankfurt-2025-9cpcf17y</link>
      <a10:author>
        <a10:name>salih</a10:name>
        <a10:uri>https://abp.io/community/members/salih</a10:uri>
      </a10:author>
      <title>WE HAD A BLAST AT BASTA! FRANKFURT 2025</title>
      <description>Our team had a great time at BASTA! Frankfurt 2025, held from March 3 to 8 at the Frankfurt Marriott Hotel. As a sponsor, we were excited to connect with many talented asp.net web developers.</description>
      <pubDate>Tue, 11 Mar 2025 12:42:56 Z</pubDate>
      <a10:updated>2026-09-30T10:35:37Z</a10:updated>
      <content:encoded><![CDATA[<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2025-03-10-WE-HAD-A-BLAST-AT-BASTA-FRANKFURT-2025/20250304_122158.webp" alt="" /></p>
<p>Our team had an amazing time at BASTA! Frankfurt 2025, held from March 3 to 8 at the Frankfurt am Main Marriott Hotel. As a sponsor for this major conference for asp.net web developers, we were thrilled to connect with so many talented participants.</p>
<p><strong>Event Highlights</strong></p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2025-03-10-WE-HAD-A-BLAST-AT-BASTA-FRANKFURT-2025/20250304_153010.webp" alt="" /></p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2025-03-10-WE-HAD-A-BLAST-AT-BASTA-FRANKFURT-2025/20250304_153413.webp" alt="" /></p>
<p>The conference hosted many talented developers as speakers. One of them was İsmail Çağdaş who’s a lead developer from ABP team who talked about the concepts of monoliths and microservices. He explained how modular monoliths combine the strengths of these two architectures, using the ABP Framework as an example to showcase its modularity features and demonstrate how to build and develop a modular monolith application.</p>
<p><strong>ABP’S Presence</strong></p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2025-03-10-WE-HAD-A-BLAST-AT-BASTA-FRANKFURT-2025/IMG_4732-2000px.webp" alt="" /></p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2025-03-10-WE-HAD-A-BLAST-AT-BASTA-FRANKFURT-2025/IMG_4770-2000px.webp" alt="" /></p>
<p>At our booth we displayed the latest features of the ABP Framework and gathered valuable feedback from especially dotnet developers. So many attendees showed interest in ABP which was very exciting. It was great engaging with so many participants who wanted to learn more about how ABP provides the infrastructure and tools to create business solutions.</p>
<p>We're also very grateful to our booth neighbor, Xceed and it was a lot of fun connecting with them during the conference. For those who don’t know, Xceed provides comprehensive UI components that allow developers to focus on innovation and their business requirements.</p>
<p><strong>Networking and Community Engagement</strong></p>
<p>We organized two raffles during the event, where attendees had the chance to win 2 great prizes. One attendee won a LEGO set and another won an Amazon Kindle. We were happy to see many people attending our raffles, it definitely made this event more fun. Congratulations to the winners and thanks to those who participated!</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2025-03-10-WE-HAD-A-BLAST-AT-BASTA-FRANKFURT-2025/IMG_4915-2000px.webp" alt="" /></p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2025-03-10-WE-HAD-A-BLAST-AT-BASTA-FRANKFURT-2025/20250305_151043.webp" alt="" /></p>
<p><strong>Looking Ahead</strong></p>
<p>BASTA! Frankfurt 2025 strengthened our commitment to the developer community. We want to continue our support for asp.net core developers, helping them create asp.net applications and optimize their workflows for web applications.</p>
<p><strong>Gratitude and Future Events</strong></p>
<p>Thank you to the organizers, speakers, and attendees for making BASTA! Frankfurt 2025 such a fantastic experience. We look forward to future events and continued contributions to the net framework developers.</p>
<p>We look forward to sharing more updates with you soon. We hope to see you at our next event!</p>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/api/posts/cover-picture-source/3a189767-9da3-5cdc-6874-041484459333" />
      <media:content url="https://abp.io/api/posts/cover-picture-source/3a189767-9da3-5cdc-6874-041484459333" medium="image" />
    </item>
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/middleware-now-supports-keyed-dependency-injection-in-.net-9-4whni6rx</guid>
      <link>https://abp.io/community/posts/middleware-now-supports-keyed-dependency-injection-in-.net-9-4whni6rx</link>
      <a10:author>
        <a10:name>salih</a10:name>
        <a10:uri>https://abp.io/community/members/salih</a10:uri>
      </a10:author>
      <category>dotnet-9.0</category>
      <title>Middleware Now Supports Keyed Dependency Injection in .NET 9</title>
      <description>.NET 9 introduces keyed dependency injection in middleware, enabling injection of specific service instances based on keys. </description>
      <pubDate>Wed, 13 Nov 2024 10:15:34 Z</pubDate>
      <a10:updated>2026-09-30T17:11:01Z</a10:updated>
      <content:encoded><![CDATA[<h1>Middleware Now Supports Keyed Dependency Injection in .NET 9</h1>
<p>This article explores a new feature in .NET 9 that enables keyed dependency injection in middleware. Previously, .NET 8 introduced keyed services, which allowed developers to register multiple instances of the same service type with distinct keys. Now, .NET 9 extends this feature to middleware, making it easier to inject specific services within the middleware based on defined keys. For more details, see this <a href="https://github.com/dotnet/core/blob/main/release-notes/9.0/preview/rc1/aspnetcore.md#keyed-di-in-middleware">overview on the .NET blog</a>.</p>
<h2>What is Keyed Dependency Injection?</h2>
<p>Keyed dependency injection is a technique for registering multiple service versions with unique identifiers, or “keys.” This approach is especially helpful when multiple implementations of the same service are required in different contexts. For example, you may have various logging services but want to inject a specific logger based on the application’s current needs. By using keys, developers can ensure that the appropriate service version is injected precisely where it’s needed.</p>
<h2>Using Keyed Dependency Injection in Middleware</h2>
<p>In .NET 9, developers can now use keyed dependency injection directly in middleware. Keyed services can be injected through the middleware constructor or via the <code>Invoke</code>/<code>InvokeAsync</code> methods, allowing for straightforward and flexible control of service instances in middleware components. Here’s an example of how to configure and use keyed dependency injection in middleware:</p>
<pre><code class="language-csharp">var builder = WebApplication.CreateBuilder(args);

// Register services with unique keys
builder.Services.AddKeyedSingleton&lt;MySingletonClass&gt;(&quot;test&quot;);
builder.Services.AddKeyedScoped&lt;MyScopedClass&gt;(&quot;test2&quot;);

var app = builder.Build();
app.UseMiddleware&lt;MyMiddleware&gt;();
app.Run();

internal class MyMiddleware
{
    private readonly RequestDelegate _next;
    private readonly MySingletonClass _singletonService;

    // Constructor injection with key
    public MyMiddleware(RequestDelegate next, [FromKeyedServices(&quot;test&quot;)] MySingletonClass singletonService)
    {
        _next = next;
        _singletonService = singletonService;
    }

    // Invoke method with additional scoped service injection using key
    public Task Invoke(HttpContext context, [FromKeyedServices(&quot;test2&quot;)] MyScopedClass scopedService)
    {
        // Middleware logic here
        return _next(context);
    }
}
</code></pre>
<p>In this example:</p>
<ul>
<li><code>MySingletonClass</code> and <code>MyScopedClass</code> are registered with unique keys (<code>&quot;test&quot;</code> and <code>&quot;test2&quot;</code>).</li>
<li>These services are injected into the middleware through both the constructor and <code>Invoke</code> method, based on their respective keys.</li>
</ul>
<p>This approach allows developers to manage which service instances are available within middleware precisely.</p>
<h2>Conclusion</h2>
<p>Keyed dependency injection in middleware is a significant addition in .NET 9. It provides developers with more control over which services are injected based on specific keys. This enhancement enables selective service injection in middleware scenarios, allowing for more modular and maintainable applications.</p>
<h2>References</h2>
<ul>
<li><a href="https://github.com/dotnet/core/blob/main/release-notes/9.0/preview/rc1/aspnetcore.md#keyed-di-in-middleware">.NET 9 Release Notes</a></li>
<li><a href="https://learn.microsoft.com/aspnet/core/fundamentals/dependency-injection#keyed-services">Dependency Injection and Keyed Services</a></li>
</ul>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/api/posts/cover-picture-source/3a163732-4c38-65fb-2c7d-7e88688c8a2b" />
      <media:content url="https://abp.io/api/posts/cover-picture-source/3a163732-4c38-65fb-2c7d-7e88688c8a2b" medium="image" />
    </item>
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/ef-core-8-enhancements-to-json-column-mapping-8rqnb87j</guid>
      <link>https://abp.io/community/posts/ef-core-8-enhancements-to-json-column-mapping-8rqnb87j</link>
      <a10:author>
        <a10:name>salih</a10:name>
        <a10:uri>https://abp.io/community/members/salih</a10:uri>
      </a10:author>
      <category>entity-framework-core</category>
      <category>net8</category>
      <category>efcore8</category>
      <title>EF Core 8 - Enhancements to JSON column mapping</title>
      <description>In this article, we will examine the enhancements introduced in EF Core 8 for the JSON column feature, building upon the foundation laid by JSON columns in Entity Framework Core 7</description>
      <pubDate>Wed, 08 Nov 2023 12:56:36 Z</pubDate>
      <a10:updated>2026-09-30T16:48:12Z</a10:updated>
      <content:encoded><![CDATA[<h1>EF Core 8 - Enhancements to JSON column mapping</h1>
<p>In this article, we will examine the enhancements introduced in EF Core 8 for the JSON column feature, building upon the foundation laid by <a href="https://community.abp.io/posts/json-columns-in-entity-framework-core-7-cjaom76j">JSON columns in Entity Framework Core 7</a>.</p>
<h2>The entity classes we will be using in the article</h2>
<pre><code class="language-csharp">public class Person
{
    public int Id { get; set; }
    [Required]
    public string Name { get; set; }
    [Required]
    public ContactDetails ContactDetails { get; set; }
}

public class ContactDetails
{
    public List&lt;Address&gt; Addresses { get; set; } = new();
    public string? Phone { get; set; }
}

public class Address
{
    public Address(string street, string city, string postcode, string country)
    {
        Street = street;
        City = city;
        Postcode = postcode;
        Country = country;
    }

    public string Street { get; set; }
    public string City { get; set; }
    public string Postcode { get; set; }
    public string Country { get; set; }
    public bool IsMainAddress { get; set; }
}
</code></pre>
<h2>The DbContext class we will be using in the article</h2>
<pre><code class="language-csharp">public class AppDbContext : DbContext
{
    public DbSet&lt;Person&gt; Persons { get; set; } = null!;

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
#if SQLSERVER
        optionsBuilder.UseSqlServer(&quot;Server=localhost;Database=EfCore8Json;Trusted_Connection=True;TrustServerCertificate=True&quot;);
#elif SQLITE
        optionsBuilder.UseSqlite(&quot;Data Source=EfCore8Json.db&quot;);
#endif
        base.OnConfiguring(optionsBuilder);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity&lt;Person&gt;(b =&gt;
        {
            b.ToTable(&quot;Persons&quot;);
            b.HasKey(x =&gt; x.Id);
            b.Property(x =&gt; x.Name).IsRequired();
            b.OwnsOne(x =&gt; x.ContactDetails, cb =&gt;
            {
                cb.ToJson();
                cb.Property(x =&gt; x.Phone);
                cb.OwnsMany(x =&gt; x.Addresses);
            });
        });

        base.OnModelCreating(modelBuilder);
    }
}
</code></pre>
<h2>Translate element access into JSON arrays</h2>
<p>EF Core 8 supports indexing in JSON arrays when executing queries. For example, the following query returns individuals whose first address is the main address in the database:</p>
<pre><code class="language-csharp">var query = dbContext.Persons
    .Select(x =&gt; x.ContactDetails.Addresses[0])
    .Where(x =&gt; x.IsMainAddress == true)
    .ToListAsync();
</code></pre>
<p>The generated SQL query is as follows when using SQL Server:</p>
<pre><code class="language-sql">SELECT JSON_QUERY([p].[ContactDetails], '$.Addresses[0]'), [p].[Id]
FROM [Persons] AS [p]
WHERE CAST(JSON_VALUE([p].[ContactDetails], '$.Addresses[0].IsMainAddress') AS bit) = CAST(1 AS bit)
</code></pre>
<blockquote>
<p>Note: If you attempt to access an index that is outside of the array, it will return null.</p>
</blockquote>
<h2>JSON Columns for SQLite</h2>
<p>In EF Core 7, JSON column mapping was supported for Azure SQL/SQL Server. In EF Core 8, this support has been extended to include SQLite as well.</p>
<h3>Queries into JSON columns</h3>
<p>The following query returns individuals whose first address is the main address in the database:</p>
<pre><code class="language-csharp">var query = dbContext.Persons
    .Select(x =&gt; x.ContactDetails.Addresses[0])
    .Where(x =&gt; x.IsMainAddress == true)
    .ToListAsync();
</code></pre>
<p>The generated SQL query is as follows when using SQLite:</p>
<pre><code class="language-sql">SELECT &quot;p&quot;.&quot;ContactDetails&quot; -&gt;&gt; '$.Addresses[0]', &quot;p&quot;.&quot;Id&quot;
FROM &quot;Persons&quot; AS &quot;p&quot;
WHERE &quot;p&quot;.&quot;ContactDetails&quot; -&gt;&gt; '$.Addresses[0].IsMainAddress' = 0
</code></pre>
<h2>References</h2>
<ul>
<li><a href="https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-8.0/whatsnew#enhancements-to-json-column-mapping">EF Core 8 - Enhancements to JSON column mapping</a></li>
</ul>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/images/others/blank-cover-image-150_79.png" />
      <media:content url="https://abp.io/images/others/blank-cover-image-150_79.png" medium="image" />
    </item>
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/-speed-up-your-asp.net-application--4o3ubiaf</guid>
      <link>https://abp.io/community/posts/-speed-up-your-asp.net-application--4o3ubiaf</link>
      <a10:author>
        <a10:name>salih</a10:name>
        <a10:uri>https://abp.io/community/members/salih</a10:uri>
      </a10:author>
      <category>speed up site</category>
      <category>asp.net</category>
      <title>💻 Speed Up Your ASP.NET Application 🚀</title>
      <description>This article provides tips and tricks to optimize the performance of your ASP.NET application for better speed, responsiveness, and user experience.</description>
      <pubDate>Tue, 18 Apr 2023 14:49:21 Z</pubDate>
      <a10:updated>2026-09-30T17:27:52Z</a10:updated>
      <content:encoded><![CDATA[<h2>How to Optimize Your ASP.NET Application for Improved Performance</h2>
<p>If you want your ASP.NET application to perform well, you need to optimize it for speed, responsiveness, and user experience. Performance optimization is critical for factors like fast page load times, improved response efficiency, and happy users. In this article, I'll provide several tips and tricks to help you optimize performance in ASP.NET Core.</p>
<h3>Use Response Compression in Your ASP.NET Application</h3>
<p>You can use ASP.NET Core's built-in response compression middleware to compress the response data and reduce the amount of data that needs to be transferred over the network. To use response compression, add the following code to your application's Startup.cs file:</p>
<pre><code class="language-csharp">
services.AddResponseCompression(options =&gt;

{

    options.EnableForHttps = true;

});



app.UseResponseCompression();

</code></pre>
<h3>🖼️ Optimize Images in Your ASP.NET Application:</h3>
<p>Images can be a major contributor to page bloat and slow load times. Here are some tips to optimize images:</p>
<p>🖌️ Use a tool like ImageOptim or Kraken.io to compress and optimize images.</p>
<p>🖼️ Specify the width and height of images in HTML so the browser can allocate space for them before they load.</p>
<p>📝 Use alt attributes to provide descriptive text for images, which can improve accessibility and also help with SEO.</p>
<p>📜 Use lazy loading for images that are below the fold, meaning they're not visible on the initial screen view. You can use libraries like Vanilla LazyLoad to implement lazy loading.</p>
<p>📱 Use responsive images to serve different image sizes to different devices. This can improve page load times by reducing the size of images that are displayed on smaller devices.</p>
<p>💻 Example:</p>
<pre><code class="language-html">
&lt;picture&gt;

    &lt;source media=&quot;(min-width: 650px)&quot; data-srcset=&quot;image.webp&quot;&gt;

    &lt;source media=&quot;(min-width: 465px)&quot; data-srcset=&quot;image_small.webp&quot;&gt;

    &lt;img src=&quot;https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2023-04-18--speed-up-your-aspnet-application-/placeholder.png&quot; data-src=&quot;image.webp&quot; alt=&quot;Image&quot; width=&quot;100&quot; height=&quot;100&quot; class=&quot;lazy&quot; /&gt;

&lt;/picture&gt;

</code></pre>
<pre><code class="language-javascript">
var lazyLoadInstance = new LazyLoad();

</code></pre>
<h3>🧱 Optimize HTML in Your ASP.NET Application:</h3>
<p>The structure and organization of HTML can affect the page speed. Here are some tips to optimize HTML:</p>
<p>📝 Use the heading tags (h1, h2, h3, etc.) in a logical and sequential order.</p>
<p>🔩 Use the &quot;defer&quot; attribute for script tags that don't need to be executed immediately. This can improve the page load times by delaying the execution of scripts until after the page has rendered.</p>
<p>🔩 Use the &quot;async&quot; attribute for script tags that can be executed asynchronously. This can further improve the page load times by allowing scripts to be downloaded and executed simultaneously.</p>
<p>🧱 Use semantic HTML elements (like nav, section, and article) to provide additional structure and meaning to the page.</p>
<h3>🎨 Optimize CSS and JavaScript in Your ASP.NET Application:</h3>
<p>CSS and JavaScript files can be a major contributor to the page load times. Here are some tips to optimize CSS and JavaScript in your ASP.NET application:</p>
<p>🔨 Minify and concatenate CSS and JavaScript files to reduce their size.</p>
<p>🔩 Use the &quot;defer&quot; or &quot;async&quot; attributes for script tags to delay or asynchronously load scripts.</p>
<h3>🔡 Use system fonts in Your ASP.NET Application:</h3>
<p>Loading custom fonts can be slow and increase page load times. Using system fonts can improve page speed by allowing the browser to use fonts that are already installed on the user's device.</p>
<h3>🖼️ Use Placeholders and Progress Indicators in Your ASP.NET Application:</h3>
<p>To improve the perceived performance of your website, you can use placeholders and progress indicators for slow-loading sections of your page. You can use JavaScript to load these sections after the initial page load.</p>
<p>💻 Example:</p>
<pre><code class="language-html">


&lt;div id=&quot;placeholder&quot; data-url=&quot;/slow-loading-content&quot;&gt;

  &lt;p&gt;Loading...&lt;/p&gt;

&lt;/div&gt;

</code></pre>
<pre><code class="language-javascript">
const placeholder = document.querySelector('#placeholder');

  fetch(placeholder.dataset.url)

    .then(response =&gt; response.text())

    .then(html =&gt; placeholder.innerHTML = html);

</code></pre>
<h3>🔗 Use the Appropriate Link Text and ARIA Labels:</h3>
<p>When using links, use appropriate link texts that accurately describe the content of the linked page. This can improve the accessibility and also help with SEO.</p>
<p>ARIA labels should also be used to provide additional context for links. This can also improve the accessibility and help with SEO.</p>
<p>💻 Example:</p>
<pre><code class="language-html">
&lt;a href=&quot;https://example.com/&quot; aria-label=&quot;Go to Example&quot;&gt;Example&lt;/a&gt;

&lt;a href=&quot;https://example.com/&quot; aria-label=&quot;Go to Another Example&quot;&gt;Another Example&lt;/a&gt;

</code></pre>
<h3>🌐 Optimize the Third-party Resources in Your ASP.NET Application:</h3>
<p>Third-party resources like social media widgets and advertising scripts can slow down the page load times. Here are some tips to optimize third-party resources:</p>
<p>🔩 Use asynchronous scripts when possible.</p>
<p>🔍 Only load third-party resources that are necessary for the page.</p>
<p>By following these optimization techniques, you can significantly improve the page speed of your ASP.NET Core web application.</p>
<h2>Optimized Web Applications with ABP Framework?</h2>
<p>ABP Framework offers an opinionated architecture to build enterprise software solutions with ASP.NET Core best practices on top of the .NET and the ASP.NET Core platforms. It is also a powerful infrastructure to help you develop low-effort web-optimized applications.</p>
<p>It provides the fundamental web application infrastructure, production-ready dotnet startup templates, modules, asp.net core ui themes, tooling, guides and documentation to implement that ASP.NET core architecture properly and automate the details and repetitive work as much as possible.</p>
<p>If you are starting a new ASP.NET Core project and want a fast website <a href="https://abp.io/">abp.io</a> now...</p>
<p><strong>IT IS FREE AND OPEN-SOURCE!</strong></p>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/api/posts/cover-picture-source/3a0aa704-4de2-5c58-bdc4-7e362024b537" />
      <media:content url="https://abp.io/api/posts/cover-picture-source/3a0aa704-4de2-5c58-bdc4-7e362024b537" medium="image" />
    </item>
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/streamline-localization-in-your-abp-project-1t12rmjc</guid>
      <link>https://abp.io/community/posts/streamline-localization-in-your-abp-project-1t12rmjc</link>
      <a10:author>
        <a10:name>salih</a10:name>
        <a10:uri>https://abp.io/community/members/salih</a10:uri>
      </a10:author>
      <category>localization</category>
      <category>localization-tool</category>
      <category>tool</category>
      <title>Streamline Localization in Your ABP Project</title>
      <description>Making localization changes to an ABP project can be a daunting task, especially if you're dealing with multiple languages and translations. During development, it's easy to overlook some changes and that leads to inconsistencies across different languages. Fortunately, I have developed a tool that can help streamline the localization process and ensure consistency across different languages.</description>
      <pubDate>Wed, 01 Mar 2023 14:12:25 Z</pubDate>
      <a10:updated>2026-09-30T09:19:17Z</a10:updated>
      <content:encoded><![CDATA[<h1>Streamline Localization in Your ABP Project</h1>
<p>Making localization changes to an ABP project can be a daunting task, especially if you're dealing with multiple languages and translations. During development, it's easy to overlook some changes and that leads to inconsistencies across different languages. Fortunately, I have developed a tool that can help streamline the localization process and ensure consistency across different languages.</p>
<p>The tool is a console application that uses JSON files to manage localization keys and their translations. It addresses three common scenarios that can arise during localization:</p>
<ol>
<li><p>When the argument count of a key changes, it can be difficult to update the translations for all languages. My tool solves this problem by scanning all JSON files in the project folder and identifying any keys that have mismatched argument counts. It then offers two options to the user: delete the mismatched translations or export them as a JSON file for manual editing.</p>
</li>
<li><p>When a new key is added to the project, forgetting to add its translations to all the other languages is easy. My tool helps to avoid this issue by scanning the default language's JSON file and identifying any keys that don't have translations in other languages. It then exports these keys as a JSON file that can be used to add missing translations.</p>
</li>
<li><p>When a key's name is changed, it's important to update its translations in all the other languages. My tool makes this task simple by scanning all the JSON files in the project folder and updating any translations of the old key name with the new one.</p>
</li>
</ol>
<p>The tool also includes an export feature that allows users to modify translations outside of the application and import them back into the JSON files.</p>
<h2>How it Helps</h2>
<p>With my Localization Key Synchronizer tool, you can perform complex localization changes more quickly and easily than by manually sifting through files and making changes one-by-one. This can save you significant time and effort, especially if you're working with a large number of languages or translations.</p>
<h2>How it Works</h2>
<p>When you run the Localization Key Synchronizer tool, it presents you with three options:</p>
<ol>
<li>Find Asynchronous Keys</li>
<li>Apply Changes in the Exported File</li>
<li>Replace Keys</li>
</ol>
<p>If you select &quot;Find Asynchronous Keys,&quot; the tool prompts you to enter the default language path. Once you've entered the path, the tool displays all of the JSON files in the same folder as a multi-select list. After selecting one or more files, you are asked whether you want to find keys that do not match the number of arguments, missing keys, or both. If you select &quot;Missing Keys,&quot; the tool prompts you to enter the absolute path to export the missing keys. After you've entered the path, the export process starts, and the tool closes.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2023-02-22-Streamline-Localization-In-Your-ABP-Project/images/Part1.gif" alt="" /></p>
<p>If you select &quot;Apply Changes in the Exported File&quot; at the main menu, the tool prompts you to enter the path to the exported file. After you've entered the path, the import process starts, and the tool closes.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2023-02-22-Streamline-Localization-In-Your-ABP-Project/images/Part2.gif" alt="" /></p>
<p>If you select &quot;Replace Keys,&quot; the tool prompts you to enter the localization folder path, the old key, the new key, and the JSON files to apply the changes to. Once you've entered all the required information and made your selections, the tool performs the replacements and closes.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2023-02-22-Streamline-Localization-In-Your-ABP-Project/images/Part3.gif" alt="" /></p>
<h2>Conclusion</h2>
<p>If you're struggling to manage localization changes in an ABP project, give my Localization Key Synchronizer tool a try. It can help streamline your workflow and make the process much more manageable. You can find the tool on <a href="https://github.com/abpframework/abp/tree/dev/tools/localization-key-synchronizer">GitHub</a>.</p>
<p>To use the tool, simply run the console application and follow the prompts. It's a user-friendly solution that helps to ensure localization consistency in your ABP project. Give it a try and let me know what you think!</p>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/api/posts/cover-picture-source/3a09afb1-402b-190e-b35c-49b3a15bb905" />
      <media:content url="https://abp.io/api/posts/cover-picture-source/3a09afb1-402b-190e-b35c-49b3a15bb905" medium="image" />
    </item>
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/json-columns-in-entity-framework-core-7-cjaom76j</guid>
      <link>https://abp.io/community/posts/json-columns-in-entity-framework-core-7-cjaom76j</link>
      <a10:author>
        <a10:name>salih</a10:name>
        <a10:uri>https://abp.io/community/members/salih</a10:uri>
      </a10:author>
      <category>entity-framework-core</category>
      <category>dotnet7</category>
      <category>dotnet</category>
      <title>JSON Columns in Entity Framework Core 7</title>
      <description>In this article, we will see how to use the new JSON Columns features that came with EF Core 7 in an ABP based application (with examples).</description>
      <pubDate>Mon, 28 Nov 2022 08:06:21 Z</pubDate>
      <a10:updated>2026-09-30T10:34:21Z</a10:updated>
      <content:encoded><![CDATA[<h1>JSON Columns in Entity Framework Core 7</h1>
<p>In this article, we will see how to use the new <strong>JSON Columns</strong> features that came with EF Core 7 in an ABP based application (with examples).</p>
<h2>JSON Columns</h2>
<p>Most relational databases support columns that contain JSON documents. The JSON in these columns can be drilled into with queries. This allows, for example, filtering and sorting by the elements of the documents, as well as projection of elements out of the documents into results. JSON columns allow relational databases to take on some of the characteristics of document databases, creating a useful hybrid between these two database management approaches.</p>
<p>EF7 contains provider-agnostic support for JSON columns, with an implementation for SQL Server. This support allows the mapping of aggregates built from .NET types to JSON documents. Normal LINQ queries can be used on the aggregates, and these will be translated to the appropriate query constructs needed to drill into the JSON. EF7 also supports updating and saving changes to JSON documents.</p>
<blockquote>
<p>You can find more information about JSON columns in EF Core's <a href="https://docs.microsoft.com/en-us/ef/core/what-is-new/ef-core-7.0/whatsnew#json-columns">documentation</a>.</p>
</blockquote>
<h3>Mapping JSON Columns</h3>
<p>In EF Core, aggregate types can be defined using <code>OwnsOne</code> and <code>OwnsMany</code> methods. <code>OwnsOne</code> can be used to map a single aggregate and the <code>OwnsMany</code> method can be used to map a collection of aggregates.</p>
<p>With EF 7, we have a new extension method for mapping  property to a JSON Column: <code>ToJson</code>. We can use this method to mark a property as a JSON Column. The property can be of any type that can be serialized to JSON.</p>
<p>The following example shows how to map a JSON column to an aggregate type:</p>
<pre><code class="language-csharp">public class ContactDetails
{
    public Address Address { get; set; }
    public string? Phone { get; set; }
}

public class Address
{
    public Address(string street, string city, string postcode, string country)
    {
        Street = street;
        City = city;
        Postcode = postcode;
        Country = country;
    }

    public string Street { get; set; }
    public string City { get; set; }
    public string Postcode { get; set; }
    public string Country { get; set; }
}

public class Person : AggregateRoot&lt;int&gt;
{
    public string Name { get; set; } = null!;
    public ContactDetails ContactDetails { get; set; } = null!;
}
</code></pre>
<ul>
<li>Above, we have defined an aggregate type <code>ContactDetails</code> that contains an <code>Address</code> and a <code>Phone</code> number. The aggregate type is configured in <code>OnModelCreating</code> using <code>OwnsOne</code> and <code>ToJson</code> methods below.</li>
<li>The <code>Address</code> property is mapped to a JSON column using <code>ToJson</code>, and the <code>Phone</code> property is mapped to a regular column. This requires just one call to <strong>ToJson()</strong> when configuring the aggregate type:</li>
</ul>
<pre><code class="language-csharp">
public class MyDbContext : AbpDbContext&lt;MyDbContext&gt;
{
    public DbSet&lt;Person&gt; Persons { get; set; }

    public MyDbContext(DbContextOptions&lt;MyDbContext&gt; options)
        : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);

        builder.Entity&lt;Person&gt;(b =&gt;
        {
            b.ToTable(MyProjectConsts.DbTablePrefix + &quot;Persons&quot;, MyProjectConsts.DbSchema);
            b.ConfigureByConvention();
            b.OwnsOne(x=&gt;x.ContactDetails, c =&gt;
            {
                c.ToJson(); //mark as JSON Column
                c.OwnsOne(cd =&gt; cd.Address);
            });
        });
    }
}
</code></pre>
<h3>Querying JSON Columns</h3>
<p>Queries into JSON columns work just the same as querying into any other aggregate type in EF Core. That's it, just use the LINQ! Here are some examples:</p>
<pre><code class="language-csharp">var persons = await (await GetDbSetAsync()).ToListAsync();

var contacts = await (await GetDbSetAsync()).Select(person =&gt; new
{
    person,
    person.ContactDetails.Phone, //query over JSON column
    Addresses = person.ContactDetails.Address //query over JSON column
}).ToListAsync();

var addresses = await (await GetDbSetAsync()).Select(person =&gt; new
{
    person,
    Addresses = person.ContactDetails.Address //query over JSON column
}).ToListAsync();
</code></pre>
<h3>Updating JSON Columns</h3>
<p>You can update JSON columns the same as updating any record by using the <code>UpdateAsync</code> method. The following example shows how to update a JSON column:</p>
<pre><code class="language-csharp">var person = await (await GetDbSetAsync()).FirstAsync();

person.ContactDetails.Phone = &quot;123456789&quot;;
person.ContactDetails.Address = new Address(&quot;Street&quot;, &quot;City&quot;, &quot;Postcode&quot;, &quot;Country&quot;);
await UpdateAsync(person, true);
</code></pre>
<h3>JSON Column in a Database</h3>
<p>After you've configured the database relations, created a new migration and applied it to database you will have a database table like below:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2022-11-25-JSON-columns/Database.png" alt="image" /></p>
<p>As you can see, thanks to JSON Columns feature the <strong>ContactDetails</strong> row has JSON content and we can use it in a query or update it from our application with the LINQ JSON query support that mentioned above.</p>
<h3>Conclusion</h3>
<p>In this article, I've briefly introduced the JSON Columns feature that was shipped with EF Core 7. It's pretty straightforward to use JSON Columns in an ABP based application. You can see the examples above and give it a try!</p>
<h3>The Source Code</h3>
<ul>
<li>You can find the full source code of the example application <a href="https://github.com/abpframework/abp-samples/tree/master/EfCoreJSONColumnDemo">here</a>.</li>
</ul>
<h3>References</h3>
<ul>
<li><a href="https://docs.microsoft.com/en-us/ef/core/what-is-new/ef-core-7.0/whatsnew#json-columns">https://docs.microsoft.com/en-us/ef/core/what-is-new/ef-core-7.0/whatsnew#json-columns</a></li>
<li><a href="https://docs.microsoft.com/en-us/ef/core/modeling/owned-entities">https://docs.microsoft.com/en-us/ef/core/modeling/owned-entities</a></li>
</ul>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/api/posts/cover-picture-source/f5f8f77d-f857-39e4-1abe-3a07cf72ad0b" />
      <media:content url="https://abp.io/api/posts/cover-picture-source/f5f8f77d-f857-39e4-1abe-3a07cf72ad0b" medium="image" />
    </item>
  </channel>
</rss>