<?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, 21 Oct 2026 01:26:48 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=ahmet.celik" />
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/.net-11-in-practice-fulljoin-async-validation-cache-metrics-tracing-rules-57ve6uql</guid>
      <link>https://abp.io/community/posts/.net-11-in-practice-fulljoin-async-validation-cache-metrics-tracing-rules-57ve6uql</link>
      <a10:author>
        <a10:name>ahmet.celik</a10:name>
        <a10:uri>https://abp.io/community/members/ahmet.celik</a10:uri>
      </a10:author>
      <category>new-features</category>
      <category>metrics</category>
      <category>OpenTelemetry</category>
      <category>validation</category>
      <category>.net</category>
      <title>.NET 11 in Practice: FullJoin, Async Validation, Cache Metrics, Tracing Rules</title>
      <description>A hands-on look at four .NET 11 features you will use in everyday code: LINQ FullJoin, async DataAnnotations validation, built-in MemoryCache metrics, and configuration-driven Activity tracing rules. Each one comes with a runnable example, real output from RC1, the gotchas found while testing, and an adoption checklist. All examples live in a sample app with 28 tests.</description>
      <pubDate>Thu, 17 Sep 2026 07:22:08 Z</pubDate>
      <a10:updated>2026-10-20T23:32:30Z</a10:updated>
      <content:encoded><![CDATA[<p>Every .NET release ships a long list of changes. Most of them matter to library authors or to people chasing the last few percent of throughput. This article is about four changes in .NET 11 that show up in ordinary application code: a LINQ operator you have probably hand-rolled more than once, validation rules that can finally <code>await</code>, cache metrics you no longer have to write yourself, and a way to turn distributed tracing on and off from configuration.</p>
<p>Each section covers the problem, the API, a runnable example with its output, and the caveats I hit while testing. Everything was run on .NET 11 RC1, and the complete sample application is on GitHub: <a href="https://github.com/ahmetcelik05/dotnet11-features-demo">github.com/ahmetcelik05/dotnet11-features-demo</a>.</p>
<blockquote>
<p><strong>Where .NET 11 stands today.</strong> .NET 11 RC1 shipped on September 8, 2026 with a go-live license. General availability is planned for November 10, 2026, and .NET 11 is a Standard Term Support (STS) release, supported until November 9, 2028. The APIs below are in RC1, but as you will see in the cache metrics section, details can still move before GA.</p>
</blockquote>
<p>Short on time? The whole article in one table:</p>
<p>| Feature | What is new in .NET 11 | The catch I found on RC1 |
|---|---|---|
| LINQ joins | <code>FullJoin</code>, plus <code>Join</code>/<code>GroupJoin</code> overloads without a result selector | <code>GroupJoin</code> returns <code>IGrouping</code>, not a tuple; value-type <code>default</code> hides &quot;no match&quot; |
| Async validation | <code>AsyncValidationAttribute</code>, <code>IAsyncValidatableObject</code>, <code>Validator.*Async</code> | Synchronous <code>Validator</code> calls on the same model throw |
| Cache metrics | <code>MemoryCache</code> publishes an OpenTelemetry meter when <code>TrackStatistics</code> is on | The hit/miss tag name is being renamed before GA |
| Tracing rules | <code>AddTracing</code> with rules from code or configuration, live reload | Nothing is enabled by default, <code>Sample</code> is mandatory, OpenTelemetry is unaffected |</p>
<h2>The sample application</h2>
<p>The sample is a small minimal API called <code>OrdersDemo</code>. It has no UI; you drive it with <code>curl</code> and watch the console. Each endpoint exists to demonstrate one feature, and the features also interact: placing an order runs async validation and then produces a traced activity.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-09-17-net11-in-practice-fulljoin-async-validation-cache-metrics-tracing-rules/sample-app-architecture.png" alt="Sample application architecture" /></p>
<p>Each feature lives in its own folder under <code>Features</code> and registers itself through <code>Add...</code> and <code>Map...Endpoints</code> extensions, so <code>Program.cs</code> is only a composition root.</p>
<h2>1. LINQ <code>FullJoin</code> and selector-less <code>Join</code>/<code>GroupJoin</code></h2>
<h3>The problem</h3>
<p>.NET 10 added <code>LeftJoin</code> and <code>RightJoin</code>. A full outer join was still missing, so if you needed &quot;everything from both sides, matched where possible&quot; you wrote a left join, a right join, and a <code>Concat</code> or <code>Union</code>, or a <code>GroupJoin</code>/<code>SelectMany</code>/<code>DefaultIfEmpty</code> combination in each direction. Most of us have a <code>FullOuterJoin</code> extension method somewhere in a <code>Utils</code> folder, copied from a Stack Overflow answer. It works, right up until a reconciliation report is wrong.</p>
<p>A smaller annoyance: <code>Join</code> and <code>GroupJoin</code> always required a result selector. In most call sites that selector was <code>(a, b) =&gt; (a, b)</code>, typed for the hundredth time.</p>
<h3>The API</h3>
<p>.NET 11 adds <code>FullJoin</code> and selector-less overloads of <code>Join</code> and <code>GroupJoin</code>. They exist on <code>Enumerable</code>, <code>Queryable</code>, and <code>AsyncEnumerable</code>. The new overloads take an optional <code>IEqualityComparer&lt;TKey&gt;</code> as a trailing parameter, and the same optional comparer is available on <code>LeftJoin</code> and <code>RightJoin</code>.</p>
<pre><code class="language-csharp">// Full outer join, returning tuples
IEnumerable&lt;(TOuter? Outer, TInner? Inner)&gt; FullJoin&lt;TOuter, TInner, TKey&gt;(
    this IEnumerable&lt;TOuter&gt; outer, IEnumerable&lt;TInner&gt; inner,
    Func&lt;TOuter, TKey&gt; outerKeySelector, Func&lt;TInner, TKey&gt; innerKeySelector,
    IEqualityComparer&lt;TKey&gt;? comparer = null);

// Full outer join with your own projection
IEnumerable&lt;TResult&gt; FullJoin&lt;TOuter, TInner, TKey, TResult&gt;(
    this IEnumerable&lt;TOuter&gt; outer, IEnumerable&lt;TInner&gt; inner,
    Func&lt;TOuter, TKey&gt; outerKeySelector, Func&lt;TInner, TKey&gt; innerKeySelector,
    Func&lt;TOuter?, TInner?, TResult&gt; resultSelector,
    IEqualityComparer&lt;TKey&gt;? comparer = null);
</code></pre>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-09-17-net11-in-practice-fulljoin-async-validation-cache-metrics-tracing-rules/join-family.png" alt="Which rows each join returns" /></p>
<h3>Example: reconciling two stock systems</h3>
<p>The reconciliation endpoint compares stock levels reported by a warehouse and by an ERP system. A SKU can exist in either system or both, and when it exists in both the quantities may disagree. Three pair shapes, four statuses, one full outer join, and pattern matching on the tuple expresses all of it directly:</p>
<pre><code class="language-csharp">internal sealed class ReconciliationService : IReconciliationService
{
    public IReadOnlyList&lt;ReconciliationRow&gt; Reconcile(IEnumerable&lt;WarehouseStock&gt; warehouse, IEnumerable&lt;ErpStock&gt; erp)
    {
        var pairs = warehouse.FullJoin(
            erp,
            w =&gt; w.Sku,
            e =&gt; e.Sku,
            StringComparer.OrdinalIgnoreCase);

        return [.. pairs.Select(ToRow)];
    }

    private static ReconciliationRow ToRow((WarehouseStock? Warehouse, ErpStock? Erp) pair) =&gt; pair switch
    {
        (null, { } erp) =&gt; new ReconciliationRow(erp.Sku, ReconciliationStatus.MissingInWarehouse, null, erp.Quantity),
        ({ } warehouse, null) =&gt; new ReconciliationRow(warehouse.Sku, ReconciliationStatus.MissingInErp, warehouse.Quantity, null),
        ({ } warehouse, { } erp) when warehouse.Quantity != erp.Quantity
            =&gt; new ReconciliationRow(warehouse.Sku, ReconciliationStatus.QuantityMismatch, warehouse.Quantity, erp.Quantity),
        ({ } warehouse, { } erp) =&gt; new ReconciliationRow(warehouse.Sku, ReconciliationStatus.Ok, warehouse.Quantity, erp.Quantity),
        (null, null) =&gt; throw new UnreachableException(&quot;FullJoin never yields an empty pair.&quot;),
    };
}
</code></pre>
<p>Calling <code>GET /reconciliation</code> with three warehouse rows (<code>LAPTOP-15</code>, <code>mouse-01</code>, <code>CHAIR-ERG</code>) and three ERP rows (<code>LAPTOP-15</code>, <code>MOUSE-01</code>, <code>DESK-STD</code>) returns four rows:</p>
<pre><code class="language-json">[
  { &quot;sku&quot;: &quot;LAPTOP-15&quot;, &quot;status&quot;: &quot;Ok&quot;,                 &quot;warehouseQuantity&quot;: 5,    &quot;erpQuantity&quot;: 5 },
  { &quot;sku&quot;: &quot;mouse-01&quot;,  &quot;status&quot;: &quot;QuantityMismatch&quot;,   &quot;warehouseQuantity&quot;: 0,    &quot;erpQuantity&quot;: 3 },
  { &quot;sku&quot;: &quot;CHAIR-ERG&quot;, &quot;status&quot;: &quot;MissingInErp&quot;,       &quot;warehouseQuantity&quot;: 7,    &quot;erpQuantity&quot;: null },
  { &quot;sku&quot;: &quot;DESK-STD&quot;,  &quot;status&quot;: &quot;MissingInWarehouse&quot;, &quot;warehouseQuantity&quot;: null, &quot;erpQuantity&quot;: 2 }
]
</code></pre>
<p>Note the second row: <code>mouse-01</code> and <code>MOUSE-01</code> matched only because of the <code>StringComparer.OrdinalIgnoreCase</code> argument. Without it they would be two separate &quot;missing&quot; rows, which is exactly what happens in real life when two systems disagree on casing and nobody passes a comparer.</p>
<h3>What the tests showed about the semantics</h3>
<p>The documentation describes the join operators but not their ordering or null behaviour, so I wrote tests against RC1 to pin down what you can observe today.</p>
<p>Output order follows the outer sequence, with unmatched inner elements appended at the end: for <code>outer = [1, 2, 3]</code> and <code>inner = [3, 4, 2]</code>, <code>FullJoin</code> yields <code>(1, 0), (2, 2), (3, 3), (0, 4)</code>. Treat that as an implementation detail, not a guarantee. Null keys never match each other; a <code>null</code> key on either side produces an unmatched row, the same way <code>NULL = NULL</code> is false in SQL.</p>
<p>Look at that <code>(1, 0)</code> again. It means &quot;outer 1 had no partner&quot;, but <code>0</code> is also a perfectly valid inner value, and nothing in the tuple tells you which. When the element type is a struct, use nullable elements or the <code>resultSelector</code> overload so you can tell the two apart.</p>
<p>One more surprise: the selector-less <code>GroupJoin</code> does not return tuples. The &quot;What's new&quot; page calls the new overloads &quot;tuple-returning&quot;, which is true for <code>Join</code>, but <code>GroupJoin</code> returns <code>IEnumerable&lt;IGrouping&lt;TOuter, TInner&gt;&gt;</code> with the outer element as the <code>Key</code> and the matching inner elements as the group.</p>
<pre><code class="language-csharp">var grouped = customers
    .GroupJoin(orders, c =&gt; c.Id, o =&gt; o.CustomerId)
    .Select(group =&gt; (group.Key.Name, Count: group.Count()));
</code></pre>
<h3>When to use it</h3>
<p>Use <code>FullJoin</code> whenever you need a reconciliation, a diff, or a &quot;who did not show up on the other list&quot; report over two in-memory sequences. Keep the memory profile in mind: the inner sequence is fully materialised into a lookup, so put the smaller sequence on the inner side. Against a database, EF Core 11 translates <code>Queryable.FullJoin</code> to <code>FULL JOIN</code> on SQL Server. EF Core 11 is still in preview at the time of writing, so check the provider you use before relying on it.</p>
<p>That covers combining data that is already in memory. The next feature is about the data on its way in: the order request that arrives at <code>POST /orders</code> and has to be checked against systems you can only reach asynchronously.</p>
<h2>2. Asynchronous validation with DataAnnotations</h2>
<h3>The problem</h3>
<p><code>ValidationAttribute.IsValid</code> is synchronous. Rules that need I/O, such as &quot;is this e-mail registered&quot;, &quot;does this VAT number exist in the tax registry&quot;, or &quot;is there enough stock&quot;, either blocked a thread with <code>.GetAwaiter().GetResult()</code> or moved out of the validation layer entirely. Once they move, their error messages no longer flow through <code>ValidationResult</code> and <code>ValidationProblemDetails</code>, and every consumer of the model has to remember to call them. You know the result: <code>[Required]</code> failures arrive as a tidy 400 with a field name, &quot;customer not found&quot; arrives as a 500 from the service layer, and the front-end team asks why.</p>
<h3>The API</h3>
<p>.NET 11 adds an async path to <code>System.ComponentModel.DataAnnotations</code> with three pieces:</p>
<ul>
<li><code>AsyncValidationAttribute</code>, an abstract base class with <code>IsValidAsync(object?, ValidationContext, CancellationToken)</code> returning <code>Task&lt;ValidationResult?&gt;</code>.</li>
<li><code>IAsyncValidatableObject</code>, with <code>ValidateAsync(ValidationContext, CancellationToken)</code> returning <code>IAsyncEnumerable&lt;ValidationResult&gt;</code>.</li>
<li>Async counterparts on <code>Validator</code>: <code>ValidateObjectAsync</code>, <code>TryValidateObjectAsync</code>, <code>ValidatePropertyAsync</code>, <code>TryValidatePropertyAsync</code>, <code>ValidateValueAsync</code>, and <code>TryValidateValueAsync</code>.</li>
</ul>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-09-17-net11-in-practice-fulljoin-async-validation-cache-metrics-tracing-rules/async-validation-pipeline.png" alt="The async validation pipeline" /></p>
<p>One design point matters before you write your first rule: the synchronous members are still required. <code>IsValid(object?, ValidationContext)</code> is abstract on <code>AsyncValidationAttribute</code>, and <code>IAsyncValidatableObject</code> inherits <code>IValidatableObject.Validate</code>. The framework samples throw from both so that accidental synchronous validation fails loudly instead of silently skipping the rule, and that is the approach I take below.</p>
<h3>Example: an async attribute and an async object rule</h3>
<p>The order request in the sample uses both mechanisms. The attribute checks that the customer exists in a directory:</p>
<pre><code class="language-csharp">[AttributeUsage(AttributeTargets.Property)]
public sealed class RegisteredCustomerAttribute : AsyncValidationAttribute
{
    protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) =&gt;
        throw new InvalidOperationException(
            $&quot;{nameof(RegisteredCustomerAttribute)} only supports asynchronous validation. Use Validator.ValidateObjectAsync.&quot;);

    protected override async Task&lt;ValidationResult?&gt; IsValidAsync(
        object? value, ValidationContext validationContext, CancellationToken cancellationToken)
    {
        if (value is not string email || string.IsNullOrWhiteSpace(email))
        {
            return ValidationResult.Success; // [Required] owns the empty-value case
        }

        var directory = validationContext.GetRequiredService&lt;ICustomerDirectory&gt;();

        return await directory.ExistsAsync(email, cancellationToken)
            ? ValidationResult.Success
            : new ValidationResult($&quot;'{email}' is not a registered customer.&quot;, [validationContext.MemberName!]);
    }
}
</code></pre>
<p>The model itself checks stock for every line, a rule that spans properties:</p>
<pre><code class="language-csharp">public sealed class CreateOrderRequest : IAsyncValidatableObject
{
    [Required, EmailAddress, RegisteredCustomer]
    public string CustomerEmail { get; init; } = string.Empty;

    [MinLength(1)]
    public IReadOnlyList&lt;OrderLine&gt; Lines { get; init; } = [];

    public IEnumerable&lt;ValidationResult&gt; Validate(ValidationContext validationContext) =&gt;
        throw new InvalidOperationException($&quot;Validate {nameof(CreateOrderRequest)} with {nameof(ValidateAsync)}.&quot;);

    public async IAsyncEnumerable&lt;ValidationResult&gt; ValidateAsync(
        ValidationContext validationContext,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        var inventory = validationContext.GetRequiredService&lt;IInventoryService&gt;();

        foreach (var line in Lines)
        {
            var available = await inventory.GetAvailableQuantityAsync(line.Sku, cancellationToken);
            if (available &lt; line.Quantity)
            {
                yield return new ValidationResult(
                    $&quot;Only {available} unit(s) of '{line.Sku}' are in stock.&quot;, [nameof(Lines)]);
            }
        }
    }
}
</code></pre>
<p>Both rules resolve their dependencies from the <code>ValidationContext</code>, which means the context has to be created with a service provider. Minimal APIs do that for you; when you call <code>Validator</code> yourself, pass one in:</p>
<pre><code class="language-csharp">var context = new ValidationContext(request, serviceProvider, items: null);
var results = new List&lt;ValidationResult&gt;();

var isValid = await Validator.TryValidateObjectAsync(
    request, context, results, validateAllProperties: true, cancellationToken);
</code></pre>
<h3>Running it through a minimal API</h3>
<p><code>builder.Services.AddValidation()</code> has been available since .NET 10; it makes minimal API endpoints validate their parameters before the handler runs. What is new in .NET 11 is that the same call now runs the async rules as well, concurrently where possible. Posting an unknown customer:</p>
<pre><code class="language-bash">curl -X POST http://localhost:5028/orders -H &quot;Content-Type: application/json&quot; \
     -d '{&quot;customerEmail&quot;:&quot;nobody@example.com&quot;,&quot;lines&quot;:[{&quot;sku&quot;:&quot;MOUSE-01&quot;,&quot;quantity&quot;:2}]}'
</code></pre>
<pre><code class="language-json">{
  &quot;title&quot;: &quot;One or more validation errors occurred.&quot;,
  &quot;status&quot;: 400,
  &quot;errors&quot;: { &quot;CustomerEmail&quot;: [&quot;'nobody@example.com' is not a registered customer.&quot;] }
}
</code></pre>
<p>Only the attribute error is reported. That is by design: object-level validation runs after all property rules pass, so the stock check never executed. Post the same order with a registered customer and you get the object-level message instead:</p>
<pre><code class="language-json">{
  &quot;title&quot;: &quot;One or more validation errors occurred.&quot;,
  &quot;status&quot;: 400,
  &quot;errors&quot;: { &quot;Lines&quot;: [&quot;Only 0 unit(s) of 'MOUSE-01' are in stock.&quot;] }
}
</code></pre>
<h3>How the pipeline behaves</h3>
<p>From reading <code>Validator</code> and confirming with tests, the async path works like this:</p>
<ol>
<li><code>[Required]</code> is evaluated first for each property and short-circuits that property on failure.</li>
<li>Synchronous attributes on a property run next. Async attributes run only if all synchronous ones pass, and they run concurrently.</li>
<li>Type-level attributes follow, then <code>IAsyncValidatableObject.ValidateAsync</code>.</li>
<li>Pass <code>null</code> for the results collection and validation stops at the first failure; pass a collection and it keeps going and collects everything.</li>
<li>The cancellation token flows into every async rule.</li>
</ol>
<h3>The migration trap</h3>
<p>So what happens when an older code path calls the synchronous <code>Validator.TryValidateObject</code> on a model that now carries an async attribute? Does it quietly skip the rule? No. It has no special handling for async attributes at all: it calls your synchronous <code>IsValid</code>, and if you followed the framework guidance, that throws:</p>
<pre><code>System.InvalidOperationException: RegisteredCustomerAttribute only supports asynchronous validation. Use Validator.ValidateObjectAsync.
</code></pre>
<p>On purpose, and rightly so. But it makes adding an async attribute a breaking change for every caller that still validates synchronously, so find those callers before you decorate a shared model. Minimal APIs and Blazor forms use the async path in .NET 11; MVC controllers are not listed in the RC1 notes and the public design notes defer them to follow-up work, so treat MVC as a synchronous caller until the docs say otherwise.</p>
<p>Validation decides whether a request gets in. The next two features are about seeing what the application does once it is in, starting with the cache that every request touches.</p>
<h2>3. Built-in OpenTelemetry metrics for <code>MemoryCache</code></h2>
<h3>The problem</h3>
<p><code>IMemoryCache</code> has had <code>GetCurrentStatistics()</code> for a while, but turning it into metrics meant polling it and publishing your own <code>Meter</code>. Every team wrote a slightly different decorator, and none of them agreed on instrument names. The decorator then quietly stopped being registered after a refactor, and the &quot;cache hit ratio&quot; panel on the dashboard showed a flat line for three weeks before anyone noticed.</p>
<h3>The API</h3>
<p>Do you need an extra package or an adapter for this? No. In .NET 11, <code>MemoryCache</code> publishes a meter named <code>Microsoft.Extensions.Caching.Memory.MemoryCache</code> as soon as you opt in with <code>TrackStatistics = true</code>. The new <code>MemoryCacheOptions.Name</code> becomes a <code>dotnet.cache.name</code> tag so that multiple caches can share a process, and a new constructor overload accepts an <code>IMeterFactory</code> for per-instance meters. <code>MemoryCacheStatistics</code> gained <code>TotalEvictions</code> to back the new eviction counter.</p>
<p>RC1 publishes these four instruments, captured with a <code>MeterListener</code> in the test suite:</p>
<p>| Instrument | Type | Unit | Tags |
|---|---|---|---|
| <code>dotnet.cache.requests</code> | ObservableCounter | <code>{request}</code> | <code>dotnet.cache.request.type</code> = <code>hit</code> / <code>miss</code> |
| <code>dotnet.cache.evictions</code> | ObservableCounter | <code>{eviction}</code> | |
| <code>dotnet.cache.entries</code> | ObservableUpDownCounter | <code>{entry}</code> | |
| <code>dotnet.cache.estimated_size</code> | ObservableGauge | <code>By</code> | |</p>
<h3>Example: a read-through product catalog</h3>
<p>In most applications the opt-in is a one-liner: <code>services.AddMemoryCache(options =&gt; options.TrackStatistics = true)</code>. In the demo the cache is configured through an <code>IConfigureOptions&lt;MemoryCacheOptions&gt;</code> instead, so that the demo-specific settings live next to the feature:</p>
<pre><code class="language-csharp">public void Configure(MemoryCacheOptions options)
{
    options.TrackStatistics = true;        // opt-in: without this no instruments are published
    options.Name = CacheName;              // becomes the dotnet.cache.name tag
    options.SizeLimit = productCache.Value.SizeLimit;
    options.CompactionPercentage = productCache.Value.CompactionPercentage;
}
</code></pre>
<pre><code class="language-csharp">internal sealed class CachedProductCatalog(IMemoryCache cache, IProductStore store) : IProductCatalog
{
    public Task&lt;Product?&gt; GetAsync(string sku, CancellationToken cancellationToken) =&gt;
        cache.GetOrCreateAsync(sku, entry =&gt;
        {
            entry.SetSize(1); // required when MemoryCacheOptions.SizeLimit is set
            entry.SetSlidingExpiration(TimeSpan.FromMinutes(5));
            return store.FindAsync(sku, cancellationToken);
        });
}
</code></pre>
<p>To export the meter, add it to your OpenTelemetry metrics pipeline:</p>
<pre><code class="language-csharp">services.AddOpenTelemetry()
    .WithMetrics(metrics =&gt; metrics
        .AddMeter(&quot;Microsoft.Extensions.Caching.Memory.MemoryCache&quot;)
        .AddConsoleExporter());
</code></pre>
<p>After five requests for <code>LAPTOP-15</code> and one each for three other SKUs, the console exporter prints:</p>
<pre><code>Metric Name: dotnet.cache.requests, Description: Total cache requests., Unit: {request}, Metric Type: LongSum
(...) dotnet.cache.name: products dotnet.cache.request.type: hit   Value: 4
(...) dotnet.cache.name: products dotnet.cache.request.type: miss  Value: 4

Metric Name: dotnet.cache.evictions, Description: Total cache evictions., Unit: {eviction}, Metric Type: LongSum
(...) dotnet.cache.name: products                                   Value: 1

Metric Name: dotnet.cache.entries, Description: Current number of cache entries., Unit: {entry}, Metric Type: LongSumNonMonotonic
(...) dotnet.cache.name: products                                   Value: 2

Metric Name: dotnet.cache.estimated_size, Description: Estimated size of the cache., Unit: By, Metric Type: LongGauge
(...) dotnet.cache.name: products                                   Value: 2
</code></pre>
<p>Four misses (one per SKU), four hits, and one eviction because the size limit is three. The hit ratio is <code>hit / (hit + miss)</code>, which you can compute in any backend that supports the tag.</p>
<p>A detail that cost me a few minutes (and a brief, unfair suspicion of the runtime): with the default <code>CompactionPercentage</code> of 5 %, a three-entry cache never evicts anything; it just refuses the fourth entry. The demo uses 50 % so the counter moves; real caches are large enough that the default is fine.</p>
<h3>Caveats</h3>
<ul>
<li><strong>The tag name is changing.</strong> RC1 emits <code>dotnet.cache.request.type</code> and unit <code>By</code>; the <code>release/11.0</code> branch already has <code>dotnet.cache.request.result</code> and unit <code>1</code>, because <code>SizeLimit</code> is an application-defined number, not bytes. Re-check any dashboard or alert that references the tag at GA.</li>
<li><strong><code>TrackStatistics</code> is not free.</strong> It adds interlocked increments on every hit and miss. That is cheap, but measure it on a hot cache before enabling it everywhere.</li>
<li><strong>All four instruments are observable.</strong> The hot path only increments the counters it already kept for <code>GetCurrentStatistics()</code>; values are read when a collector asks. <code>MeterListener</code> users therefore have to call <code>RecordObservableInstruments()</code>. The same release moved the HTTP <code>open_connections</code> and <code>active_requests</code> metrics to observable instruments, so this applies to more than the cache.</li>
</ul>
<p>Metrics tell you how often the cache is hit. They do not tell you what a single slow order did along the way. For that you need traces, and the last feature is about deciding which traces you actually want, without a redeploy.</p>
<h2>4. Declarative <code>Activity</code> tracing rules</h2>
<h3>The problem</h3>
<p><code>ActivitySource.StartActivity</code> returns <code>null</code> unless some <code>ActivityListener</code> has said it is interested in that source. Deciding which sources are interesting has always happened in code: either an <code>ActivityListener</code> with a <code>ShouldListenTo</code> callback, or OpenTelemetry's <code>AddSource</code>. Changing the decision meant a redeploy. If you have ever watched a noisy health-check span flood a trace backend because nobody could turn it off in production, you know the feeling. Logging solved the equivalent problem years ago with <code>Logging:LogLevel</code> in <code>appsettings.json</code>, and metrics got <code>MetricsBuilder.EnableMetrics</code> in .NET 8. Tracing had nothing.</p>
<h3>The API</h3>
<p><code>Microsoft.Extensions.Diagnostics</code> in .NET 11 adds a tracing builder with the same shape as the metrics one:</p>
<pre><code class="language-csharp">services.AddTracing(tracing =&gt;
{
    tracing.AddListener(&quot;console&quot;, listener =&gt; { /* ActivityListenerBuilder */ });
    tracing.EnableTracing(sourceName: &quot;OrdersDemo.Orders&quot;);
    tracing.DisableTracing(sourceName: &quot;OrdersDemo.Orders&quot;, operationName: &quot;HealthCheck&quot;);
    tracing.AddConfiguration(configuration.GetSection(&quot;Tracing&quot;));
});
</code></pre>
<ul>
<li><code>AddListener(name, configure)</code> registers a named <code>ActivityListener</code>. The <code>ActivityListenerBuilder</code> exposes <code>Sample</code>, <code>SampleUsingParentId</code>, <code>ActivityStarted</code>, <code>ActivityStopped</code>, and <code>ExceptionRecorder</code>.</li>
<li><code>EnableTracing</code> / <code>DisableTracing</code> add a <code>TracingRule</code>. <code>sourceName</code> is an exact name, a prefix, or a pattern with a single <code>*</code>; <code>operationName</code> and <code>listenerName</code> are exact matches; <code>null</code> matches everything.</li>
<li><code>AddConfiguration</code> reads rules from an <code>IConfiguration</code> section and re-applies them when the configuration reloads.</li>
<li>The same release adds <code>ActivitySourceFactory</code> for creating sources through DI and unseals <code>ActivitySource</code>.</li>
</ul>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-09-17-net11-in-practice-fulljoin-async-validation-cache-metrics-tracing-rules/tracing-rules.png" alt="How rules, listeners and sources fit together" /></p>
<p>The configuration shape mirrors logging. This is the sample's <code>appsettings.json</code>:</p>
<pre><code class="language-json">{
  &quot;Tracing&quot;: {
    &quot;EnabledTracing&quot;: {
      &quot;OrdersDemo.Orders&quot;: {
        &quot;Default&quot;: true,
        &quot;HealthCheck&quot;: false
      }
    }
  }
}
</code></pre>
<p><code>EnabledTracing</code> applies to all listeners; <code>EnabledGlobalTracing</code> and <code>EnabledLocalTracing</code> target only sources created with <code>new ActivitySource(...)</code> or through <code>ActivitySourceFactory</code>, respectively; any other key under <code>Tracing</code> is a listener name with its own nested block. Inside a source, <code>Default</code> is the source-level rule, every other key is an operation name, and a bare boolean (<code>&quot;OrdersDemo.*&quot;: true</code>) is shorthand for <code>Default</code>.</p>
<h3>Example: a console listener driven by configuration</h3>
<p>In the demo there is one named listener, registered with <code>AddListener</code>, and the rules come from <code>AddConfiguration</code> alone, no code-based rules. The listener itself:</p>
<pre><code class="language-csharp">internal static class ConsoleActivityListener
{
    public const string Name = &quot;console&quot;;

    public static void Configure(ActivityListenerBuilder listener)
    {
        // The rules API makes no sampling decision for you: without Sample, StartActivity returns null.
        listener.Sample = static (ref _) =&gt; ActivitySamplingResult.AllDataAndRecorded;
        listener.ActivityStopped = Print;
    }

    private static void Print(Activity activity)
    {
        // Activity.Tags only yields string-valued tags; TagObjects includes numbers such as order.lines.
        var tags = string.Join(' ', activity.TagObjects.Select(tag =&gt; $&quot;{tag.Key}={tag.Value}&quot;));
        Console.WriteLine($&quot;[trace] {activity.Source.Name}/{activity.OperationName} {activity.Duration.TotalMilliseconds:F1} ms {tags}&quot;);
    }
}
</code></pre>
<p><code>OrderService</code> creates its source through <code>ActivitySourceFactory</code> and starts two activities, <code>PlaceOrder</code> and <code>HealthCheck</code>:</p>
<pre><code class="language-csharp">private readonly ActivitySource _activitySource =
    activitySourceFactory.Create(new ActivitySourceOptions(&quot;OrdersDemo.Orders&quot;));

// in PlaceOrderAsync
using var activity = _activitySource.StartActivity(&quot;PlaceOrder&quot;);   // enabled by rule

// in CheckHealthAsync
using var activity = _activitySource.StartActivity(&quot;HealthCheck&quot;);  // disabled by rule
</code></pre>
<p>Place an order and call the health endpoint. Only the order shows up:</p>
<pre><code>[trace] OrdersDemo.Orders/PlaceOrder 10.2 ms order.customer=ada@example.com order.lines=1 order.id=ORD-6067
</code></pre>
<p>Now change <code>&quot;HealthCheck&quot;: false</code> to <code>true</code> in <code>appsettings.json</code> while the app is running. The configuration reloads, the listener refreshes its filters, and the next two health calls print:</p>
<pre><code>[trace] OrdersDemo.Orders/HealthCheck 16.0 ms
[trace] OrdersDemo.Orders/HealthCheck 6.6 ms
</code></pre>
<p>Set it back to <code>false</code> and the lines stop. No restart, no redeploy.</p>
<h3>What I learned by running it</h3>
<p>The documentation for this feature is currently one paragraph and a five-line snippet, so most of the following comes from the source and from tests:</p>
<ul>
<li><strong>Nothing is enabled by default.</strong> <code>AddTracing()</code> without rules enables no source.</li>
<li><strong>You must set <code>Sample</code>.</strong> A listener without a sampling callback never records anything, and <code>StartActivity</code> returns <code>null</code> for its sources. The snippet in the release notes omits this, which is why my first attempt produced no output at all.</li>
<li><strong>Listeners activate when the host starts.</strong> Rules are wired up during <code>IHost.StartAsync</code> (or when <code>ActivitySourceFactory</code> is first resolved). Building the service provider is not enough, which matters for tests and for tools that never start a host.</li>
<li><strong>Rules cover sources created with <code>new ActivitySource(...)</code> too.</strong> Those are &quot;global&quot; scope; factory-created sources are &quot;local&quot;. A rule for <code>Legacy.*</code> picks up activities from a plain <code>new ActivitySource(&quot;Legacy.Billing&quot;)</code>.</li>
<li><strong>Rules resolve like CSS selectors.</strong> The most specific one wins: a listener name beats none, a longer source pattern beats a shorter one, an operation name beats none. Between equally specific rules the last one registered wins; avoid overlapping rules rather than relying on that.</li>
</ul>
<h3>Rules and OpenTelemetry</h3>
<p>The question everyone asks first: does <code>AddTracing</code> replace OpenTelemetry's <code>AddSource</code>? No. OpenTelemetry registers its own <code>ActivityListener</code>, and the rules only govern listeners registered through <code>AddTracing</code>. I confirmed this with an OpenTelemetry <code>TracerProvider</code> and a rule that disables <code>HealthCheck</code>: OpenTelemetry still received <code>HealthCheck</code>, and the rule-driven listener did not.</p>
<p>There is one interaction worth knowing about. ASP.NET Core's hosting layer creates a request activity even when no listener samples it, so that parent is unrecorded, and OpenTelemetry's default <code>ParentBased(AlwaysOn)</code> sampler follows the parent and drops the child spans. Sampling decisions are combined across listeners with the most permissive winning, so in my first run the rule-driven listener's <code>AllDataAndRecorded</code> decision is what got <code>PlaceOrder</code> exported, while <code>HealthCheck</code> looked as if the rule had suppressed it in OpenTelemetry too. It had not; the sampler had. The demo uses <code>SetSampler(new AlwaysOnSampler())</code> to keep the two mechanisms independent; in production, ASP.NET Core instrumentation samples the parent properly.</p>
<p>So where does this API fit? Use it for listeners you own: a console or file listener for local debugging, an in-process collector, a diagnostic listener you want operators to toggle in a running service. Keep your OpenTelemetry pipeline as it is.</p>
<p>Four features down. Here is how to run the application and the tests behind them yourself.</p>
<h2>Running the sample</h2>
<p>The repository pins the SDK in <code>global.json</code> (<code>11.0.100-rc.1.26425.128</code>); the README shows how to install RC1 into a private folder without making it your machine's default SDK. Then, from the repository root:</p>
<pre><code class="language-bash">dotnet test
dotnet run --project src/OrdersDemo   # listens on http://localhost:5028 (from launchSettings.json)
</code></pre>
<p>The test project uses xUnit v3 on Microsoft.Testing.Platform, and the run I used for this article looked like this:</p>
<pre><code>OrdersDemo.Tests.dll (net11.0|x64) passed [+28/x0/?0] (1s 461ms)
Test run summary: Passed!
  failed: 0
  skipped: 0
  duration: 1s 896ms
</code></pre>
<p>Every observed behaviour listed in the feature sections above is backed by a test with a matching name.</p>
<h2>Preview and RC caveats</h2>
<ul>
<li><strong>RC1 is go-live, not GA.</strong> Names and units can still change; the cache tag rename (<code>request.type</code> → <code>request.result</code>, unit <code>By</code> → <code>1</code>) is already merged for release/11.0.</li>
<li><strong>Async validation in MVC controllers</strong> is not covered by the RC1 documentation; assume synchronous validation there.</li>
<li><strong>Tracing rules have minimal documentation.</strong> The behaviours above were verified against RC1 source and tests, not a specification; re-verify against the GA docs. EF Core 11's <code>FullJoin</code> translation is likewise still in preview.</li>
</ul>
<h2>Adoption checklist</h2>
<ol>
<li>Target <code>net11.0</code>, pin the SDK in <code>global.json</code> with <code>rollForward: latestFeature</code>.</li>
<li>Replace hand-written full outer joins with <code>FullJoin</code>; test null keys and decide how value types represent &quot;no match&quot;.</li>
<li>Convert <code>(a, b) =&gt; (a, b)</code> selectors to the selector-less <code>Join</code>/<code>GroupJoin</code> overloads; remember <code>GroupJoin</code> returns <code>IGrouping</code>.</li>
<li>Move I/O-bound rules to <code>AsyncValidationAttribute</code> or <code>IAsyncValidatableObject</code>; decide whether the synchronous members throw or fall back.</li>
<li>Find every synchronous caller of those models (<code>Validator.*</code>, MVC model binding, custom pipelines) and switch it, or keep async attributes off shared models.</li>
<li>Call <code>AddValidation()</code> in minimal API projects and pass <code>HttpContext.RequestAborted</code> through.</li>
<li>Enable <code>TrackStatistics</code>, set <code>MemoryCacheOptions.Name</code>, add the meter to OpenTelemetry, build a hit-ratio panel; re-check the tag name at GA.</li>
<li>If you use <code>MeterListener</code>, call <code>RecordObservableInstruments()</code> on your collection interval.</li>
<li>Move custom <code>ActivityListener</code> code to <code>AddTracing</code> with a named listener and a <code>Tracing</code> section; always set <code>Sample</code>; leave OpenTelemetry's <code>AddSource</code> alone.</li>
<li>Prefer <code>ActivitySourceFactory</code> over static <code>ActivitySource</code> fields in new code.</li>
</ol>
<h2>Closing thoughts</h2>
<p>None of these four features will headline the .NET 11 launch keynote, and that is exactly why they are worth knowing: they are the changes you will use on a Tuesday afternoon, not the ones you will benchmark. Good luck with the upgrade, keep the tests from this article close, and treat every &quot;it obviously works like this&quot; moment with a little suspicion until GA. Hit a surprise I did not cover? The sample repository is the place to open an issue.</p>
<h2>References</h2>
<ul>
<li><a href="https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-11/overview">What's new in .NET 11</a></li>
<li><a href="https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-11/libraries">What's new in .NET libraries for .NET 11</a> (LINQ joins, async validation, cache metrics, tracing configuration)</li>
<li><a href="https://learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-11">What's new in ASP.NET Core for .NET 11</a> (async validation in minimal APIs and Blazor)</li>
<li><a href="https://learn.microsoft.com/en-us/dotnet/core/diagnostics/">.NET diagnostics overview</a> and <a href="https://learn.microsoft.com/en-us/dotnet/core/diagnostics/distributed-tracing-collection-walkthroughs">Collect a distributed trace</a></li>
<li><a href="https://devblogs.microsoft.com/dotnet/dotnet-11-rc-1/">Announcing .NET 11 Release Candidate 1</a></li>
<li><a href="https://github.com/dotnet/runtime/issues/100317">dotnet/runtime #100317: Tracing configuration API proposal</a> and <a href="https://github.com/dotnet/runtime/pull/129380">PR #129380</a></li>
<li><a href="https://github.com/dotnet/runtime/issues/124140">dotnet/runtime #124140: Metrics for MemoryCache API proposal</a> and <a href="https://github.com/dotnet/runtime/pull/133151">PR #133151: tag and unit fix</a></li>
<li><a href="https://gist.github.com/halter73/f4d0974da579fb78d17bd2e6d9f78173">Async validation design notes (dotnet/aspnetcore)</a> (MVC support deferred)</li>
<li><a href="https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-11.0/whatsnew">What's new in EF Core 11</a> (FullJoin translation)</li>
</ul>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/api/posts/cover-picture-source/3a23c06b-5d5a-5ab1-25de-07cd78b99fe8" />
      <media:content url="https://abp.io/api/posts/cover-picture-source/3a23c06b-5d5a-5ab1-25de-07cd78b99fe8" medium="image" />
    </item>
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/abp-vs-clean-architecture-templates-starter-kits-and-diy-asp.net-core-r93iqcy5</guid>
      <link>https://abp.io/community/posts/abp-vs-clean-architecture-templates-starter-kits-and-diy-asp.net-core-r93iqcy5</link>
      <a10:author>
        <a10:name>ahmet.celik</a10:name>
        <a10:uri>https://abp.io/community/members/ahmet.celik</a10:uri>
      </a10:author>
      <category>startup-templates</category>
      <category>multi-tenancy</category>
      <category>modularity</category>
      <category>abp-platform</category>
      <category>clean architecture</category>
      <title>ABP vs Clean Architecture templates, starter kits, and DIY ASP.NET Core</title>
      <description>Compare ABP Platform with DIY ASP.NET Core, Clean Architecture templates, and starter kits, and learn which trade-offs matter for multi-tenancy, identity, modularity, UI choices, and long-term maintenance.</description>
      <pubDate>Fri, 04 Sep 2026 12:19:24 Z</pubDate>
      <a10:updated>2026-10-20T23:49:47Z</a10:updated>
      <content:encoded><![CDATA[<p>Every serious .NET team hits the same whiteboard sooner or later. Do we build the multi-tenant SaaS from scratch? Grab a starter kit? Adopt a platform?</p>
<p>Search “ABP vs …” and you will land on pages written by kits and tenancy libraries. Fair. Those pages exist because the question is real. They are not comparing two products of the same kind.</p>
<p>You are choosing among four shapes:</p>
<ol>
<li>Build it yourself on ASP.NET Core, often with a tenancy library such as Finbuckle.MultiTenant.</li>
<li>A Clean Architecture template (Jason Taylor, Ardalis) that gives you folders, layers, and a sample feature.</li>
<li>A starter kit that copies a snapshot of production modules into your repo (fullstackhero, Brick, and similar kits).</li>
<li>A maintained application platform. Architecture, infrastructure, modules, and tools that keep moving.</li>
</ol>
<p>ABP competes across several decision categories rather than against one uniform product set. Some teams compare it with Clean Architecture and DDD references or starter kits because they prefer to own and assemble the application foundation themselves. Others evaluate .NET application and service frameworks such as ServiceStack, model-driven RAD platforms such as DevExpress XAF, or cross-stack SaaS platforms and starter kits. Existing ASP.NET Boilerplate applications are usually a migration and adoption consideration rather than a same-generation alternative. The table below focuses on the architecture and application-foundation decision; these other categories can still enter the same broader technology discussion.</p>
<p><a href="https://abp.io">ABP</a> takes the application-platform approach. The rest of this article is about the things teams evaluate later: multi-tenancy, identity, permissions, modularity, UI (including React), and a path from modular monolith (one deployable application organized into independent modules) to microservices.</p>
<h2>TL;DR</h2>
<p>Choose ABP when you are shipping a long-lived, multi-tenant, modular .NET product and want a maintained platform: tenancy, identity, modules, official UIs (including React), and Studio / Suite on the same stack.</p>
<p>Choose a Clean Architecture template (Jason Taylor, Ardalis) when the goal is to own every architectural decision from a well-named skeleton.</p>
<p>Choose a starter kit (fullstackhero, Brick, and similar) when you prefer a copy of modules in your repo on day one and you are willing to maintain that snapshot.</p>
<p>Choose Finbuckle (or custom tenant middleware) when tenant resolution is the only additional capability you need on an app you already own. ABP also connects tenancy with application concerns such as identity, permissions, and audit.</p>
<p>Tables in this article follow <a href="https://abp.io/docs/latest">official ABP documentation</a>.</p>
<h2>This is for the long haul</h2>
<p>ABP is the recommended choice when you are building a long-lived, multi-tenant, modular .NET product. B2B SaaS. Line of business. A platform several teams will still be extending in a few years.</p>
<p>You want:</p>
<ul>
<li>Cross-cutting concerns (auth, tenancy, audit, jobs, localization) already solved and documented.</li>
<li>Official UI options that match your team: React, Angular, Blazor, MVC, MAUI, or React Native.</li>
<li>A way to add pre-built modules and, later, split a module into a service without rewriting the application code.</li>
<li>Tooling (Studio, Suite, CLI) and, if you need it, commercial modules and support, without leaving the same platform.</li>
</ul>
<p>Yes, ABP is opinionated. The initial learning cost is real: teams need to understand modules, application services, and unit of work conventions before those conventions become productive. Then, when a module needs to become a microservice, the contracts are already there. The conventions become team-wide standards so the next project does not invent a second way to do the same thing.</p>
<h2>Four shapes. One table.</h2>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-09-02-abp-vs-clean-architecture-templates-and-starter-kits/ABP-vs-Alternatives-Inline.png" alt="DIY ASP.NET Core, Clean Architecture, starter kit, and ABP Platform as four different shapes" /></p>
<p>| Capability | ABP Platform | DIY ASP.NET Core | Clean Architecture templates (Jason Taylor, Ardalis) | Starter kits (fullstackhero, Brick, and similar) |
|---|---|---|---|---|
| What you get | Maintained framework + modules + templates + tooling | Empty host and libraries you assemble | Layered/CQRS skeleton + a sample feature | A copy of modules in your repo on day one |
| Multi-tenancy | Native: <a href="https://abp.io/docs/latest/framework/architecture/multi-tenancy">single database, database-per-tenant, or hybrid</a>; identity and authentication/token infrastructure wired for tenants | You design isolation, filters, and cache keys, or add a library such as Finbuckle.MultiTenant | Not included | Often via a tenancy library you still own end-to-end |
| Identity, permissions, audit | Application modules + automatic tenant-data filtering, <a href="https://abp.io/docs/latest/framework/infrastructure/audit-logging">audit logging</a>, permission system | You compose Identity, policies, and audit yourself | Identity sample at most | Bundled in the kit; you maintain the fork |
| Modularity | First-class <a href="https://abp.io/docs/latest/framework/architecture/modularity/basics">module system</a>; install/uninstall packages | You invent module boundaries | Folders and layers, not a product module catalog | Modular folders; upgrades are merge/cherry-pick |
| UI | <a href="https://abp.io/docs/latest/framework/ui/react">React (Modern)</a>, MVC, Blazor, Angular, <a href="https://abp.io/docs/latest/framework/ui/react-native">React Native</a>, <a href="https://abp.io/docs/latest/framework/ui/maui">MAUI</a> | You pick and integrate | Template-specific (often Angular/React/API) | Kit-specific (often one SPA) |
| Monolith → microservices | Same application contracts; HTTP/C# proxies replace in-process calls | You design the split | You design the split | Extract-a-module if the kit allows it |
| Tooling | <a href="https://abp.io/studio">ABP Studio</a> (including AI Agent), <a href="https://abp.io/suite">ABP Suite</a>, CLI | <code>dotnet</code> CLI and your IDE | Template CLI + IDE | Kit CLI / Aspire host, varies |
| How it evolves | NuGet upgrades for the framework and modules, plus documented migrations | You upgrade every library | You copy the next template over your tree | You merge upstream into source you already customized |
| Support | Open-source core plus commercial extras and vendor support | Your team | Community | Community |</p>
<p>ABP may be a suitable fit when the application needs architecture, infrastructure modules, and development tooling to remain integrated and maintainable as the product grows.</p>
<p>How to read the table: templates teach structure. Starter kits give you a snapshot you own. ABP combines application architecture, infrastructure modules, and development tooling in one platform. Names in the headers are examples of the category, not a review of every repository.</p>
<p>Identity, permissions, and audit. ABP ships these as application modules: permission UI, automatic filtering of tenant data, and audit logging. On plain ASP.NET Core you compose Identity, policies, and audit yourself. A Clean Architecture template usually stops at an Identity sample. A starter kit may bundle them; you then maintain that copy.</p>
<p>How the stack evolves. When a customized starter kit is updated, identity and tenancy code may already contain local changes. Reconciling those changes across the files you now own can add significant maintenance work. That is a different upgrade model from updating framework packages.</p>
<p>ABP uses framework and module packages, together with documented migrations. A Clean Architecture template’s next version is something you copy forward. A kit’s next version is reconciled with the snapshot you already changed.</p>
<h2>Let’s be honest: what are you actually downloading?</h2>
<h3>What you build yourself</h3>
<p>ASP.NET Core is phenomenal. It is a foundation, not a complete application platform. Building from scratch gives teams maximum control, but also leaves tenant resolution, permission checks, audit logs, job plumbing, and module boundaries to the team.</p>
<p>That work is legitimate if the architecture is unique. For a standard business product, it is infrastructure before the first domain feature.</p>
<p>ABP sits on ASP.NET Core. You keep the Microsoft stack. ABP provides established conventions and modules for teams that prefer those decisions to be part of the platform. See <a href="https://abp.io/docs/latest/others/why-abp-platform">Why ABP Platform</a>.</p>
<p>ABP suits teams that want application infrastructure to remain integrated with the product as it grows.</p>
<h3>Clean Architecture templates</h3>
<p>This category includes the templates most often compared with ABP: Jason Taylor’s Clean Architecture template and Ardalis’s Clean Architecture template. They provide a good classroom and a clean slate. Their focus is architectural structure and ownership rather than a built-in application module catalog or ABP-specific tooling.</p>
<p>Choose a Clean Architecture template when architectural ownership and a focused starting point matter most.</p>
<p>ABP is a fit when architectural conventions and application modules need to evolve together.</p>
<h3>Starter kits</h3>
<p>A starter kit copies identity, tenancy, auditing, and a few domain modules into your repository. You own every line. That is a real preference for some teams.</p>
<p>Starter kits are another common option in this decision. fullstackhero is a copy-and-own MIT kit (identity, tenancy, React admin, modules in your repo). Other starter kits, such as Brick, can also provide a prebuilt starting point; the exact features, ownership model, and update process depend on the kit. They can provide a fast starting point, but long-term maintenance depends on how each kit handles updates to customized code.</p>
<p>With ABP, framework and module updates are distributed through packages and documented migrations. With a kit, teams have direct ownership of the source, while updates may require reconciling local changes with upstream code. These are different maintenance models.</p>
<p>Choose a starter kit when an existing set of application features and direct source ownership can accelerate your team.</p>
<p>Choose ABP when you want those application concerns, modules, and tooling to evolve as part of a maintained platform. ABP’s open-source core is yours to use; your business code lives in your repo. There is no separate proprietary runtime for that core. Commercial extras (themes, Pro modules, Suite, support) are optional layers on the same platform, not a rewrite.</p>
<p>Package-based framework and module updates may suit teams that prefer them to reconciling changes in an owned source snapshot.</p>
<h2>If you last looked a year ago: React is official</h2>
<p>This is easy to miss if you last looked at ABP a year ago.</p>
<p>Official UI options (<a href="https://abp.io/docs/latest/framework/ui">ABP UI</a>):</p>
<ul>
<li>React, in the <a href="https://abp.io/docs/latest/solution-templates/modern-vs-classic">Modern template system</a> (ABP Studio or <code>abp new --modern</code>).</li>
<li>MVC / Razor Pages, Blazor (WebAssembly, Server, WebApp), and Angular, on Classic templates.</li>
<li>React Native and MAUI for mobile / hybrid.</li>
</ul>
<p><a href="https://abp.io/docs/latest/solution-templates/modern-vs-classic">Modern vs Classic</a>: Classic is not a deprecated track. It is the actively supported family with the broadest UI matrix (MVC, Angular, Blazor, MAUI) and the template-first Studio flow. Modern is the newer architecture-first Studio flow, React (or no UI), and the Admin Console / Low-Code path (metadata-driven screens configured through a designer). Choose Classic when the team’s UI is MVC, Angular, or Blazor. Choose Modern when the web UI is React.</p>
<p>One backend, the UI your team already knows.</p>
<h2>Multi-tenancy without a side project</h2>
<p>ABP treats tenancy as infrastructure, not a feature you bolt on:</p>
<ul>
<li>Current tenant is resolved per request.</li>
<li>Entities implementing <code>IMultiTenant</code> (ABP’s contract for separating tenant-owned data) are automatically filtered; new records get a <code>TenantId</code> identifying the tenant.</li>
<li>You can use one database for all tenants, one database per tenant, or a hybrid.</li>
</ul>
<p>That isolation also applies to cache and related concerns so business code stays mostly tenancy-agnostic. Details: <a href="https://abp.io/docs/latest/framework/architecture/multi-tenancy">Multi-Tenancy</a>.</p>
<p>A tenancy library on raw ASP.NET Core, including Finbuckle.MultiTenant, can resolve the tenant. It does not automatically give you permission UI, audit, jobs, Identity/OpenIddict authentication and token wiring for tenants, and module installs on the same model. That is the platform difference. Pages titled “Finbuckle vs ABP vs custom” are answering a library question. This article is answering the application-platform question.</p>
<p>ABP is worth considering when tenancy needs to stay connected to identity, permissions, data filtering, and the rest of the application model.</p>
<h2>Modularity that can become microservices</h2>
<p>ABP modules are real packages (domain, application, HTTP API, UI) with documented dependency rules. You can start with a <a href="https://abp.io/architecture/modular-monolith">modular monolith</a> and later replace in-process calls with HTTP using the same application service contracts and <a href="https://abp.io/docs/latest/framework/api-development/dynamic-csharp-clients">client proxies</a> (typed clients that call application services over HTTP).</p>
<p>That path is the point. You do not throw away the monolith to “do microservices”; you change the hosting of a module that already had a boundary.</p>
<p>This approach fits teams that want modular boundaries to support both a modular monolith and a later service split.</p>
<h2>ABP Low-Code: admin screens inside the same app</h2>
<p><a href="https://abp.io/docs/latest/low-code">ABP Low-Code</a> is a module in your ABP application (Team license or higher). You model entities, pages, forms, permissions, and scripts in the Admin Console. The runtime uses that metadata in the same app, with the same identity, audit, and EF Core model. There is no separate low-code database.</p>
<p>Designer, <a href="https://abp.io/docs/latest/studio/ai-agent">Studio AI Agent</a>, and hand-written C# / Script API land on one model.</p>
<p>The documented runtime UI is React. You can still keep MVC, Razor Pages, Angular, or Blazor as the main UI and host the React Low-Code runtime beside it (<a href="https://abp.io/docs/latest/low-code/non-react-ui-integration">non-React integration</a>).</p>
<p>Docs still label the system Preview (APIs and designer may change before GA). That is a maturity label, not a missing product.</p>
<p>| | ABP Low-Code | External low-code platform | Hand-written CRUD every time |
|---|---|---|---|
| Where it runs | Inside your ABP app | Separate product / runtime | Your repo |
| Data and identity | Same database and ABP authorization | Often a parallel model | Whatever you build |
| Extend with code | C#, Script API, Agent | Platform limits | Always code |
| Screens | Grid, form, calendar, kanban, gallery, dashboard, import/export | Vendor widgets | Custom pages |
| How you enable it | Studio modern wizard (layered, single-layer, modular monolith + EF Core) or <a href="https://abp.io/docs/latest/low-code/add-to-existing-solution">add to an existing EF Core solution</a> | Vendor onboarding | Always code |</p>
<p>Studio’s <em>new-solution wizard step</em> is omitted for microservice architecture and when MongoDB is selected, because runtime-managed tables use EF Core. Layered and modular-monolith EF Core solutions are the documented enablement path today.</p>
<p>Use Low-Code for the admin CRUD and metadata-driven screens. Keep distinctive product UX in normal ABP UI, including Suite-generated React CRUD on modern solutions.</p>
<h2>When this is the right call</h2>
<p>Pick ABP when:</p>
<ul>
<li>The product will have tenants, roles, audit, and more than one team.</li>
<li>You want React <em>or</em> Blazor <em>or</em> Angular <em>or</em> MVC on a shared backend.</li>
<li>You expect to grow from modular monolith to services without a rewrite.</li>
<li>You want Studio, Suite, and (optionally) Low-Code on the same stack.</li>
</ul>
<p>A Clean Architecture template is a solid choice when architectural ownership and a focused starting point matter most.</p>
<p>A starter kit is a solid choice when an existing set of application features and direct source ownership can accelerate your team. ABP is a better fit when those application concerns, modules, and tooling should evolve as part of a maintained platform.</p>
<p>ASP.NET Boilerplate is the predecessor, not a same-generation starter kit. If that is the comparison, it is a migration to the current ABP platform, not a fork-and-own kit decision.</p>
<p>DevExpress XAF and similar commercial frameworks follow a model-driven RAD approach built around a vendor UI ecosystem. That approach can reduce application boilerplate and accelerate internal business applications, while also placing the application’s model and much of its UI experience within DevExpress conventions and controls. ABP is an application platform on ASP.NET Core, with an open-source core, modular architecture, and multiple UI options. It gives teams more direct control over the application layers and frontend choices, including MVC, Blazor, Angular, and React, while supporting a modular-monolith-to-microservices path. XAF may be the better fit when the priority is delivering a model-driven business application quickly within the DevExpress ecosystem. ABP may be the better fit when the product needs ASP.NET Core, a more customized frontend experience, multiple client options, or a modular architecture that can evolve toward services.</p>
<h2>FAQ</h2>
<h3>Is ABP free or commercial?</h3>
<p>ABP Framework has an open-source core. Some themes, modules, generators, Studio capabilities, support, and other commercial offerings require an appropriate commercial license. Exact availability depends on the product and license level.</p>
<h3>If we stop paying for commercial extras, or we leave ABP later, is the app a black box?</h3>
<p>No. The application is still ASP.NET Core, your C#, and EF Core (or MongoDB). The <a href="https://abp.io/docs/latest/others/why-abp-platform">open-source framework</a> stays in the solution as packages you already reference. Leaving ABP means replacing those packages the way you would replace any framework, not extracting code from a closed runtime. Suite output is source in your repo. Commercial modules, themes, and Suite itself are optional layers; dropping them does not delete your domain.</p>
<h3>If we already have a Clean Architecture or starter-kit repo (Jason Taylor, Ardalis, fullstackhero, Brick), can we “just add ABP” like a NuGet?</h3>
<p>No. ABP’s module pipeline, interceptors, automatic tenant-data filters (which limit queries to the current tenant), and startup conventions assume it is the host. Teams adopt ABP on a new solution (or plan a migration), they do not drop <code>Volo.Abp.*</code> onto an existing CA template and keep the old composition root unchanged. That is the real cost versus a tenancy library you add to code you already own. See <a href="https://abp.io/docs/latest/others/why-abp-platform">Why ABP Platform</a>.</p>
<h3>We already use Finbuckle (or custom tenant middleware). What does ABP tenancy still do that that library does not?</h3>
<p>A tenancy library resolves <em>who the tenant is</em>. ABP also isolates queries and inserts (<code>IMultiTenant</code> data filters), wires Identity and OpenIddict for tenants, and applies the same tenant context to cache and related infrastructure. That covers <a href="https://abp.io/docs/latest/framework/architecture/multi-tenancy">single database, database-per-tenant, or hybrid</a>. Permissions, audit, and jobs then run inside that model instead of each being a separate integration project.</p>
<h3>If we start Modern/React, can the same backend later serve a second UI (public MVC, partner Blazor, mobile)?</h3>
<p>Yes. Application services and HTTP APIs are UI-agnostic. <a href="https://abp.io/docs/latest/framework/ui">Official UIs</a> (React, Angular, Blazor, MVC, React Native, MAUI) are hosts on that backend. You choose Modern vs Classic at solution creation for the primary web app; extra clients consume the same APIs. You do not rewrite domain or application layers to add a second frontend.</p>
<h3>When we split a module into a microservice, do we rewrite application services as controllers?</h3>
<p>Not as the default path. Module contracts stay; in-process calls become HTTP (or messaging) via <a href="https://abp.io/docs/latest/framework/api-development/dynamic-csharp-clients">client proxies</a> against the same application-service interfaces. You change hosting, not the feature’s application API. That is the comparison point versus a starter kit whose “modules” are folders with no proxy story.</p>
<h3>For the same admin entity, should we use Suite-generated React CRUD or Low-Code pages?</h3>
<p><a href="https://abp.io/docs/latest/suite">Suite</a> writes source into your layers (entity, app service, UI, tests) that you commit and customize, including React on modern solutions. <a href="https://abp.io/docs/latest/low-code">Low-Code</a> keeps the entity in metadata (designer + runtime APIs/pages) without generating those classes for the standard flow. Use Suite when the screen will grow into product code; use Low-Code when the screen should stay designer-driven. They are not substitutes for each other.</p>
<h3>Can we keep MediatR / vertical-slice handlers for new features inside an ABP solution?</h3>
<p>You can reference extra libraries, but ABP’s default application surface is <a href="https://abp.io/docs/latest/framework/architecture/domain-driven-design/application-services">application services</a> with unit of work, validation, and authorization conventions. Comparison with CA templates is not “MediatR vs nothing”; it is whether the team standardizes on ABP’s application layer or on a handler-per-feature folder as the public API. Mixing both without a rule usually duplicates the same use case in two styles.</p>
<h2>Next step</h2>
<p>Create a solution in <a href="https://abp.io/studio">ABP Studio</a> and pick Modern (React) or Classic (MVC / Blazor / Angular). Then add only the modules you need.</p>
<p><a href="https://abp.io/studio">Create a free ABP solution</a> · <a href="https://abp.io/framework">Read the framework overview</a> · <a href="https://abp.io/docs/latest/others/why-abp-platform">Why ABP Platform</a></p>
<h3>Sources</h3>
<ul>
<li><a href="https://abp.io/docs/latest/framework/ui">ABP UI options</a></li>
<li><a href="https://abp.io/docs/latest/solution-templates/modern-vs-classic">Modern vs Classic templates</a></li>
<li><a href="https://abp.io/docs/latest/framework/ui/react">React UI</a></li>
<li><a href="https://abp.io/docs/latest/framework/architecture/multi-tenancy">Multi-tenancy</a></li>
<li><a href="https://abp.io/docs/latest/others/why-abp-platform">Why ABP Platform</a></li>
<li><a href="https://abp.io/docs/latest/low-code">Low-Code System</a></li>
<li><a href="https://github.com/jasontaylordev/CleanArchitecture">Jason Taylor Clean Architecture template</a></li>
<li><a href="https://github.com/ardalis/CleanArchitecture">Ardalis Clean Architecture template</a></li>
<li><a href="https://github.com/fullstackhero/dotnet-starter-kit">fullstackhero starter kit</a></li>
<li><a href="https://www.finbuckle.com/MultiTenant/Docs">Finbuckle.MultiTenant documentation</a></li>
<li><a href="https://docs.servicestack.net/why-servicestack">ServiceStack documentation</a></li>
<li><a href="https://www.devexpress.com/products/net/application_framework/">DevExpress XAF overview</a></li>
</ul>
<p>Product names in this article belong to their owners. Mention is for identification in a technical comparison, not affiliation or endorsement.</p>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/api/posts/cover-picture-source/3a237e88-da65-f0f7-12f1-136174946eca" />
      <media:content url="https://abp.io/api/posts/cover-picture-source/3a237e88-da65-f0f7-12f1-136174946eca" medium="image" />
    </item>
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/abp-studio-vs-.net-aspire-ai-ides-and-crud-generators-8s1tl8nf</guid>
      <link>https://abp.io/community/posts/abp-studio-vs-.net-aspire-ai-ides-and-crud-generators-8s1tl8nf</link>
      <a10:author>
        <a10:name>ahmet.celik</a10:name>
        <a10:uri>https://abp.io/community/members/ahmet.celik</a10:uri>
      </a10:author>
      <category>abp-suite</category>
      <category>abp-studio</category>
      <category>crud-page-generator</category>
      <category>dotnet-aspire</category>
      <category>ai</category>
      <title>ABP Studio vs .NET Aspire, AI IDEs, and CRUD generators</title>
      <description>Compare ABP Studio with .NET Aspire, generic AI IDEs, and CRUD generators, and see how Studio, Agent, Solution Runner, and Suite fit together across solution structure, runtime workflows, and UI generation.</description>
      <pubDate>Fri, 04 Sep 2026 12:16:03 Z</pubDate>
      <a10:updated>2026-10-20T23:32:36Z</a10:updated>
      <content:encoded><![CDATA[<p>You already have an IDE. You might already have Aspire. You might already have Cursor. The remaining question is how those tools handle ABP solution structure, modules, run profiles, and generated application code.</p>
<p>Teams compare <a href="https://abp.io/studio">ABP Studio</a> with three neighbors. This is not an “IDE replacement” debate.</p>
<ol>
<li>.NET Aspire, for describing distributed resources as code and watching them on a dashboard.</li>
<li>Generic AI IDEs (Cursor, GitHub Copilot, Claude Code), for repo-wide chat and file edits.</li>
<li>CRUD scaffolders, such as <code>dotnet aspnet-codegenerator</code> and visual Blazor builders (for example Radzen).</li>
</ol>
<p>Those tools are good. Keep Visual Studio, Rider, or VS Code. Studio can <a href="https://abp.io/docs/latest/studio/solution-explorer">open the solution in them</a>. This article is about what the ABP workflow brings together in one environment: templates, module graph, Solution Runner (Studio’s tool for starting, stopping, and inspecting applications), the ABP Agent, and <a href="https://abp.io/suite">ABP Suite</a> (including React).</p>
<h2>TL;DR</h2>
<p>Choose ABP Studio when the work is ABP: solution shape, module graph, Solution Runner, AI Agent, and Suite CRUD (including React on modern solutions).</p>
<p>Keep Visual Studio, Rider, or VS Code beside it for refactor, tests, and language services.</p>
<p>Add .NET Aspire when you want resource-as-code and the Aspire dashboard. Studio does not forbid it; microservice templates can wire Aspire in.</p>
<p>Use a generic AI IDE as an extra editor if the team already lives there. Prefer the ABP Agent when the change needs modules, migrations, proxies, or live run telemetry.</p>
<p>Tables in this article follow <a href="https://abp.io/docs/latest">official ABP documentation</a>.</p>
<h2>If the bottleneck is ABP, not the C# file</h2>
<p>Studio may be a suitable choice when your team is (or will be) on the ABP Platform and you want one place to:</p>
<ul>
<li>Scaffold production templates (including React Modern and Classic MVC / Blazor / Angular).</li>
<li>Run many services, containers, and UIs from a <a href="https://abp.io/docs/latest/studio/running-applications">Solution Runner</a> profile (the set of applications and services started together).</li>
<li>Ask, plan, and implement with AI against modules, packages, migrations, and live telemetry.</li>
<li>Generate layered CRUD with ABP Suite for MVC, Blazor, Angular, and React.</li>
</ul>
<p>If you only need a general editor, keep using your IDE. If you only need container orchestration and OpenTelemetry locally, Aspire may already be on the machine. Add Studio when the solution graph and ABP conventions are the bottleneck, not the C# file.</p>
<h2>What actually sits next to Studio</h2>
<p>| Capability | ABP Studio + Agent | .NET Aspire | General AI IDEs (Cursor, Copilot, Claude Code, …) | Visual Studio / Rider / VS Code |
|---|---|---|---|---|
| Role | ABP solution lifecycle + AI coding in context | Cloud-native orchestration, service discovery, dashboard | Repo-wide AI edit / chat / agents | General-purpose IDE |
| ABP / DDD structure in context | Solution, modules, packages, run profiles in the <a href="https://abp.io/docs/latest/studio/ai-agent">agent system context</a> | Unaware of ABP modules | Files and text; no ABP module graph | Solution explorer of projects, not ABP modules |
| ABP-shaped artifacts | Suite generates entity, app service, DTOs, repository, and UI; Agent follows those module conventions | No | Generic C# unless you prompt every ABP convention | Language services only |
| Production ABP templates | Layered, modular, microservice; Modern React or Classic UIs | Minimal host templates for Aspire | None | <code>dotnet new</code> / ABP CLI if you add it |
| Run + monitor | <a href="https://abp.io/docs/latest/studio/running-applications">Solution Runner</a>: start/stop apps and containers, HTTP requests, exceptions, logs, events, browse UI | Aspire dashboard: resources, logs, traces, metrics | Shell / terminal processes | Multi-project debug |
| AI that can build, migrate, generate proxies | Agent mode: files, shell, <a href="https://abp.io/docs/latest/studio/ai-agent-built-in-capabilities">Studio tools</a> (<code>dotnet_build</code>, <code>start_applications</code>, <code>generate_csharp_proxies</code>, <code>get_exceptions</code>, …), MCP | Not an ABP coding agent | Shell-wrapped <code>dotnet</code> if you teach it | Copilot in the editor (generic) |
| Kubernetes | Studio Kubernetes integration for ABP solutions | Publish / deploy story for Aspire apps | No | No (unless you add tools) |
| Code edit / refactor | Agent writes code; Open with your IDE for deep refactor | Not an editor | Strong | Strongest language tooling |
| Use together? | Yes, open IDE from Studio; optional Aspire integration | Yes with ABP microservice template | Yes as an extra editor | Yes; Studio launches them |</p>
<p>How to read the table: these tools stack. Studio does not replace Rider's refactorings or Aspire's resource model. Its differentiated value is the ABP-aware workflow across solution structure, modules, run profiles, and agent tools.</p>
<p>Kubernetes. Studio’s Kubernetes integration is for developing against a cluster (browse and health using service names from the run profile). It is not Helm, GitOps, or your production deploy pipeline.</p>
<h2>The ABP Agent vs “just ask Cursor”</h2>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-09-02-abp-studio-vs-aspire-ai-ides-and-crud-generators/ABP-Studio-vs-Alternatives-Inline.png" alt="Generic AI IDE and ABP Agent: repository context and ABP Studio context" /></p>
<p>Cursor and Copilot are excellent at finishing a LINQ query or explaining a regex. That is not the argument.</p>
<p>Ask a generic AI IDE: &quot;Add a Product entity, make it multi-tenant, add the EF Core migration, generate the React UI.&quot; A generic AI IDE can work effectively when it has sufficient repository context and explicit project conventions. ABP Studio's differentiated value is that solution, module, run-profile, and telemetry context are available through first-class Studio capabilities. For cross-layer ABP changes, this can reduce the amount of context and manual coordination the developer must provide.</p>
<p><a href="https://abp.io/docs/latest/studio/ai-agent">ABP Studio AI Agent</a> is not limited to the currently open text buffer. The session can use the solution, modules, packages, runnable apps, run profile, AI scope, and enabled tools. <a href="https://abp.io/suite">ABP Suite</a> remains the CRUD generator, while the Agent is designed to work with the module graph, add a migration, start the profile, and inspect live exceptions.</p>
<p>Modes:</p>
<ul>
<li>Ask, read-only answers. Can search ABP documentation.</li>
<li>Plan, read-only implementation plans.</li>
<li>Agent, read/write files, shell, add migrations, run Studio tools, MCP, update plan steps.</li>
</ul>
<p>The ABP Agent fits changes where module, solution, and runtime context should remain available throughout the work.</p>
<p>That is the difference versus a generic AI IDE:</p>
<p>| What you ask | ABP Agent | Generic AI IDE |
|---|---|---|
| “Add an app service following ABP layering” | Receives solution, module, and ABP documentation context through Studio | Works from the repository context and instructions provided to it |
| “Why did this HTTP call fail?” | <code>get_requests</code> / <code>get_exceptions</code> / <code>get_logs</code> on the running profile | You paste a log or attach a debugger |
| “Generate C# / Angular proxies” | First-class Studio tools | A shell command if the model guesses it |
| “Start the apps, then continue” | <code>start_applications</code> / <code>start_containers</code> | Terminal + wait |
| Scope | AI scopes limit which modules the agent may touch; <code>.abpignore</code> blocks secrets | Works from the repository context and instructions provided to it |</p>
<p>You still review the diff. Agent mode is execution with a permission boundary, not unsupervised production deploys.</p>
<p>The <a href="https://abp.io/docs/latest/studio/ai-agent">privacy boundary</a> is the session you give it: files, prompts, Studio tool output, attachments, and allowed URLs. Privacy depends on the selected model, configured provider, accessible scope, and enabled tools. <a href="https://abp.io/docs/latest/studio/ai-agent-configuration"><code>.abpignore</code></a> prevents excluded files from entering the agent context.</p>
<h2>Why Aspire does not replace Studio (they stack)</h2>
<p><a href="https://abp.io/docs/latest/studio/running-applications">Solution Runner</a> is how you run an ABP modular or microservice tree: profiles per team, folders for apps/gateways/services, C# hosts, CLI tasks (for example Angular), Docker containers, start/stop/build, browse, health, and live HTTP / exception / log / event views.</p>
<p>.NET Aspire is how many .NET teams describe distributed resources as code and watch them on a dashboard (OpenTelemetry, containers, connection strings).</p>
<p>Their responsibilities overlap around local orchestration, but their primary scopes are different. Aspire focuses on distributed resource orchestration, while ABP Studio adds ABP solution templates, module workflows, Suite, and the ABP Agent.</p>
<p>You do not have to pick one. ABP microservice templates can enable Aspire so AppHost starts infrastructure and services; you can still use Studio’s runner and Agent. See <a href="https://abp.io/docs/latest/solution-templates/microservice/aspire-integration">Aspire integration</a>.</p>
<p>Pairing ABP Studio with Aspire works well when ABP solution workflows and distributed resource orchestration are both needed.</p>
<h2>Suite: a CRUD slice, not a pretty grid</h2>
<p><a href="https://abp.io/docs/latest/suite">ABP Suite</a> generates a CRUD slice of an ABP application from an entity: domain type, repository, application service, migration, UI, tests, navigation properties, multi-tenant flag, localization keys.</p>
<p>| | ABP Suite | <code>dotnet aspnet-codegenerator</code> / EF scaffolding | Visual Blazor app builders (for example Radzen) |
|---|---|---|---|
| Output | Entity through application layer + UI + optional tests | Controllers, Razor Pages, Blazor CRUD, or Minimal API endpoints against a DbContext | Blazor UI + data wiring from a database or REST source |
| UI stacks | MVC, Blazor (Blazorise or MudBlazor, detected), Angular, and React (modern solutions) | MVC views, Razor Pages, and Blazor components. No Angular. No React. | Blazor only (Server, WebAssembly, Auto) |
| ABP permissions, tenancy, audit base classes | Options on the entity (<a href="https://abp.io/docs/latest/suite/generating-crud-page">CRUD generation</a>) | Does not use ABP permission and tenancy conventions | Does not use ABP permission and tenancy conventions |
| Custom code on regenerate | Customizable code <a href="https://abp.io/docs/latest/suite/customizing-the-generated-code">hook points</a> for MVC, Blazor, and Angular; React pages require extra care when regenerated | Often requires manually preserving custom changes | Varies by product |
| React UI | Yes, template-based CRUD for modern React apps, including search, paging, validation, permissions, localization, and navigation properties. Registers routes and menu | No React generator in the official scaffolding set (<code>blazor</code>, <code>razorpage</code>, <code>view</code>, <code>controller</code>, <code>identity</code>, <code>minimalapi</code>) | Blazor-focused rather than React-based |</p>
<p>Suite supports the official web UI stacks, including React. <a href="https://abp.io/docs/latest/studio/ai-agent">ABP Agent</a> is an extra path when you want AI to evolve those pages, not a substitute for Suite React output.</p>
<p>Suite is a fit when generated CRUD should follow the application's ABP layers, conventions, and selected UI stack.</p>
<h2>When Studio is the right tool in the stack</h2>
<p>Choose ABP Studio when the work is ABP: new solution shape, module graph, run profiles, Kubernetes-connected browse, Agent that can migrate and generate proxies, Suite for CRUD.</p>
<p>Keep your IDE open beside it for refactor, tests, and language services. Studio expects that.</p>
<p>Add Aspire when you want resource-as-code and the Aspire dashboard. Studio does not forbid it.</p>
<p>Use a generic AI IDE as an extra editor if your team already lives there. Prefer Agent for ABP-structured changes so the model is not guessing module boundaries from filenames.</p>
<p>Studio works best when ABP-specific solution workflows complement your existing IDE.</p>
<h2>FAQ</h2>
<h3>We already run .NET Aspire (AppHost + dashboard). What does Studio still do that Aspire does not?</h3>
<p>Aspire models resources (projects, containers, connection strings, OpenTelemetry) as code. Studio models an ABP solution: modules, package installation, production templates, Suite, Kubernetes browse for ABP services, and an agent that can migrate and generate proxies. Their responsibilities overlap around local orchestration, but their primary scopes are different. You can keep AppHost; microservice templates can integrate Aspire. You do not need to replace Aspire with Studio.</p>
<h3>If the team already uses Cursor or Copilot on the same repo, when is ABP Agent the better tool for a task?</h3>
<p>When the task needs ABP structure or a live run, not only a file edit: module/package scope, <code>generate_csharp_proxies</code> / <code>generate_angular_proxies</code>, <code>start_applications</code>, or <code>get_exceptions</code> / <code>get_requests</code> / <code>get_logs</code> against the <a href="https://abp.io/docs/latest/studio/running-applications">Solution Runner</a> profile. Generic IDEs can work in the same repository when given the relevant context and conventions. Agent provides first-class access to module, run-profile, and Studio tool context for changes that coordinate ABP modules and running services.</p>
<h3>After Suite generates React CRUD, how should we customize the page?</h3>
<p>For MVC, Blazor, and Angular, use Suite’s <a href="https://abp.io/docs/latest/suite/customizing-the-generated-code">customizable code</a> hook points. React pages require extra care when regenerated, so keep additional React UI in files Suite does not generate or evolve it with <a href="https://abp.io/docs/latest/studio/ai-agent">ABP Agent</a>. Suite React generation itself is template-based, not AI.</p>
<h3>Can Agent start the microservice profile, then use real HTTP/exception data in the same session?</h3>
<p>Yes, in Agent mode with a run profile: tools include <code>start_applications</code> / <code>start_containers</code> and then <code>get_requests</code>, <code>get_exceptions</code>, <code>get_logs</code>, <code>get_events</code> (<a href="https://abp.io/docs/latest/studio/ai-agent-built-in-capabilities">built-in capabilities</a>). Ask/Plan cannot mutate or start apps. If nothing is running, those telemetry tools have nothing to read. That is why “AI that sees production-like local traffic” is a Studio and Runner loop, not a chat sidebar.</p>
<h3>Does Studio Kubernetes integration replace Helm, GitOps, or our cluster deploy pipeline?</h3>
<p>No. It is for developing against a cluster (browse/health using Kubernetes service names from the run profile, manage connected services). Aspire’s publish story and your CI remain how you ship. Do not treat the Studio K8s panel as the production deployment product.</p>
<h3>Can one developer live in Rider and another in VS Code while sharing the same Studio solution?</h3>
<p>Yes. Studio holds solution/run-profile metadata; <a href="https://abp.io/docs/latest/studio/solution-explorer">Open with</a> launches whatever IDE is installed. Run profiles and Agent sessions are not tied to a single editor vendor. The constraint is ABP Studio itself on the machine, not a mandate to abandon Rider or VS.</p>
<h2>Next step</h2>
<p><a href="https://abp.io/studio">Download ABP Studio</a> · <a href="https://abp.io/docs/latest/studio/ai-agent">AI Agent docs</a> · <a href="https://abp.io/docs/latest/suite/generating-crud-page">Generate a CRUD page</a> · <a href="https://abp.io/framework">ABP Framework</a></p>
<h3>Sources</h3>
<ul>
<li><a href="https://abp.io/docs/latest/studio">ABP Studio overview</a></li>
<li><a href="https://abp.io/docs/latest/studio/ai-agent">AI Agent</a></li>
<li><a href="https://abp.io/docs/latest/studio/ai-agent-built-in-capabilities">AI Agent built-in capabilities</a></li>
<li><a href="https://abp.io/docs/latest/studio/running-applications">Solution Runner</a></li>
<li><a href="https://abp.io/docs/latest/studio/solution-explorer">Solution Explorer / Open with IDE</a></li>
<li><a href="https://abp.io/docs/latest/suite">ABP Suite</a></li>
<li><a href="https://abp.io/docs/latest/suite/generating-crud-page">Generating a CRUD page</a></li>
<li><a href="https://abp.io/docs/latest/suite/customizing-the-generated-code">Customizing the generated code</a></li>
<li><a href="https://learn.microsoft.com/en-us/aspnet/core/fundamentals/tools/dotnet-aspnet-codegenerator">ASP.NET Core <code>aspnet-codegenerator</code></a></li>
<li><a href="https://learn.microsoft.com/en-us/dotnet/aspire/get-started/aspire-overview">.NET Aspire overview</a></li>
<li><a href="https://www.radzen.com/blazor-studio/">Radzen Blazor Studio</a></li>
<li><a href="https://abp.io/docs/latest/solution-templates/microservice/aspire-integration">Microservice Aspire integration</a></li>
</ul>
<p>Product names in this article belong to their owners. Mention is for identification in a technical comparison, not affiliation or endorsement.</p>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/api/posts/cover-picture-source/3a237e85-c9f0-ab42-fdbf-59b17e9c81dc" />
      <media:content url="https://abp.io/api/posts/cover-picture-source/3a237e85-c9f0-ab42-fdbf-59b17e9c81dc" medium="image" />
    </item>
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/best-practices-guide-for-rest-api-design-oexc1euj</guid>
      <link>https://abp.io/community/posts/best-practices-guide-for-rest-api-design-oexc1euj</link>
      <a10:author>
        <a10:name>ahmet.celik</a10:name>
        <a10:uri>https://abp.io/community/members/ahmet.celik</a10:uri>
      </a10:author>
      <category>rest</category>
      <category>api</category>
      <category>http-api</category>
      <category>best-practices</category>
      <category>abp-io</category>
      <title>Best Practices Guide for REST API Design</title>
      <description>This guide covers best practices for creating robust REST APIs. It emphasises correct URI structure, the use of standard HTTP methods, and appropriate status codes. It addresses topics like caching and pagination for performance, as well as HTTPS and authorisation for security. The guide also notes the importance of versioning and documentation, and shows how modern frameworks like ABP.IO automate many of these best practices.</description>
      <pubDate>Mon, 15 Sep 2025 07:14:45 Z</pubDate>
      <a10:updated>2026-10-20T19:21:05Z</a10:updated>
      <content:encoded><![CDATA[<h1>Best Practices Guide for REST API Design</h1>
<p>This guide compiles best practices for building robust, scalable, and sustainable RESTful APIs, based on information gathered from various sources.</p>
<h2>1. Fundamentals of REST Architecture</h2>
<p>REST is based on specific constraints and principles that support features like simplicity, scalability, and statelessness. The six core principles of RESTful architecture are:</p>
<ul>
<li><p><strong>Uniform Interface</strong>: This is about consistency. You use standard HTTP methods (GET, POST, PUT, DELETE) and URIs to interact with resources. The client knows how to talk to the server without needing some custom instruction manual.</p>
</li>
<li><p><strong>Client-Server</strong>: The client (e.g., a frontend app) and the server are separate. The server handles data and logic, the client handles the user interface. They can evolve independently as long as the API contract doesn't change.</p>
</li>
<li><p><strong>Stateless</strong>: This is a big one. The server doesn't remember anything about the client between requests. Every single request must contain all the info needed to process it (like an auth token). This is key for scalability.</p>
</li>
<li><p><strong>Cacheable</strong>: Responses should declare whether they can be cached or not. Good caching can massively improve performance and reduce server load.</p>
</li>
<li><p><strong>Layered System</strong>: You can have things like proxies or load balancers between the client and the server without the client knowing. It just talks to one endpoint, and the layers in between handle the rest.</p>
</li>
<li><p><strong>Code on Demand (Optional)</strong>: This is the only optional one. It means the server can send back executable code (like JavaScript) to the client. Less common in the world of modern SPAs, but it's part of the spec.</p>
</li>
</ul>
<h2>2. URI Design and Naming Conventions</h2>
<p>The URI structure is critical for making your API understandable and intuitive.</p>
<h3>Use Nouns Instead of Verbs</h3>
<p>Your URIs should represent things (resources), not actions. The HTTP method already tells you what the action is.</p>
<ul>
<li><p><strong>Good:</strong> <code>/api/users</code></p>
</li>
<li><p><strong>Bad:</strong> <code>/api/getUsers</code></p>
</li>
</ul>
<h3>Use Plural Nouns for Resource Names</h3>
<p>Stick with plural nouns for collections. It keeps things consistent, even when you're accessing a single item from that collection.</p>
<ul>
<li><p><strong>Get all users:</strong> <code>GET /api/users</code></p>
</li>
<li><p><strong>Get a single user:</strong> <code>GET /api/users/{id}</code></p>
</li>
</ul>
<h3>Use Nested Routes to Show Relationships</h3>
<p>If a resource only exists in the context of another (like a user's orders), reflect that in the URL.</p>
<ul>
<li><p><strong>Good:</strong> <code>/api/users/{userId}/orders</code> (All orders for a user)</p>
</li>
<li><p><strong>Bad:</strong> <code>/api/orders?userId={userId}</code></p>
</li>
<li><p><strong>Good:</strong> <code>/api/users/{userId}/orders/{orderId}</code> (A specific order for a user)</p>
</li>
</ul>
<p><strong>Note:</strong> Use this structure only if the child resource is tightly coupled to the parent. Avoid nesting deeper than two or three levels, as this can complicate the URIs.</p>
<h3>Path Parameters vs. Query Parameters</h3>
<p>Use the correct parameter type based on its function.</p>
<ul>
<li><p><strong>Path Parameters (<code>/users/{id}</code>):</strong> Use these to identify a specific resource or a collection. They are mandatory for the endpoint to resolve.</p>
<ul>
<li><em>Example:</em> <code>GET /api/users/123</code> uniquely identifies user 123.</li>
</ul>
</li>
<li><p><strong>Query Parameters (<code>?key=value</code>):</strong> Use these for optional actions like filtering, sorting, or pagination on a collection.</p>
<ul>
<li><em>Example:</em> <code>GET /api/users?role=admin&amp;sort=lastName</code> filters the user collection.</li>
</ul>
</li>
</ul>
<h3>Keep the URL Structure Consistent</h3>
<ul>
<li><p><strong>Use lowercase letters:</strong> Since some systems are case-sensitive, always use lowercase in URIs for consistency.</p>
<ul>
<li><em>Example:</em> Use <code>/api/product-offers</code> instead of <code>/api/Product-Offers</code>.</li>
</ul>
</li>
<li><p><strong>Use special characters correctly:</strong> Use characters like <code>/</code>, <code>?</code>, and <code>#</code> only for their defined purposes.</p>
<ul>
<li><em>Example:</em> To get comments for a specific post, use the path <code>/posts/123/comments</code>. To filter those comments, use a query parameter: <code>/posts/123/comments?authorId=45</code>.</li>
</ul>
</li>
</ul>
<h2>3. Correct Usage of HTTP Methods</h2>
<p>Each HTTP method has a specific purpose. Sticking to these standards makes your API predictable.</p>
<p>| <strong>HTTP Method</strong> | <strong>Description</strong>                                                             | <strong>Idempotent</strong>* | <strong>Safe</strong>** |
| --------------- | --------------------------------------------------------------------------- | --------------- | ---------- |
| <strong>GET</strong>         | Retrieves a resource or a collection of resources.                          | Yes             | Yes        |
| <strong>POST</strong>        | Creates a new resource.                                                     | No              | No         |
| <strong>PUT</strong>         | Updates an existing resource completely or creates it if it does not exist. | Yes             | No         |
| <strong>PATCH</strong>       | Partially updates an existing resource.                                     | No              | No         |
| <strong>DELETE</strong>      | Deletes a resource.                                                         | Yes             | No         |</p>
<ul>
<li><p><strong>Idempotent:</strong> Doing it once has the same effect as doing it 100 times. Deleting a user is idempotent; once it's gone, it's gone.</p>
</li>
<li><p><strong>Safe:</strong> The request doesn't change anything on the server. GET is safe.</p>
</li>
</ul>
<p><strong>Example in practice:</strong></p>
<p>Let's consider a resource endpoint for a collection of articles: <code>/api/articles</code>.</p>
<ul>
<li><p><strong><code>GET /api/articles</code></strong>: Retrieves a list of all articles.</p>
</li>
<li><p><strong><code>GET /api/articles/123</code></strong>: Retrieves the specific article with ID 123.</p>
</li>
<li><p><strong><code>POST /api/articles</code></strong>: Creates a new article. The data for the new article is sent in the request body.</p>
</li>
<li><p><strong><code>PUT /api/articles/123</code></strong>: Replaces the entire article with ID 123 using the new data sent in the request body.</p>
</li>
<li><p><strong><code>PATCH /api/articles/123</code></strong>: Partially updates the article with ID 123. For example, you could send only the <code>{&quot;title&quot;: &quot;New Title&quot;}</code> in the request body to update just the title.</p>
</li>
<li><p><strong><code>DELETE /api/articles/123</code></strong>: Deletes the article with ID 123.</p>
</li>
</ul>
<h2>4. Data Exchange and Responses</h2>
<h3>Prefer the JSON Format</h3>
<p>It's the standard. It's lightweight, human-readable, and every language can parse it easily. Send and receive your data as JSON.</p>
<ul>
<li><p><em>Example Request Body:</em></p>
<pre><code>{
  &quot;title&quot;: &quot;Best Practices for APIs&quot;,
  &quot;authorId&quot;: 5,
  &quot;content&quot;: &quot;An article about designing great APIs...&quot;
}
</code></pre>
</li>
</ul>
<h3>Use Appropriate HTTP Status Codes</h3>
<p>Use standard HTTP status codes to provide clear information to the client about the outcome of their request.</p>
<ul>
<li><p><strong>2xx (Success):</strong></p>
<ul>
<li><p><code>200 OK</code>: The request was successful. (For GET, PUT, PATCH)</p>
</li>
<li><p><code>201 Created</code>: The resource was successfully created. (For POST) The response should include a <code>Location</code> header with the URI of the new resource.</p>
<ul>
<li><em>Example:</em> <code>POST /api/articles</code> responds with <code>201 Created</code> and the header <code>Location: /api/articles/124</code>.</li>
</ul>
</li>
<li><p><code>204 No Content</code>: The request was successful, but there is no response body. (For DELETE)</p>
</li>
</ul>
</li>
<li><p><strong>4xx (Client Error):</strong></p>
<ul>
<li><p><code>400 Bad Request</code>: Invalid request (e.g., missing or incorrect data).</p>
</li>
<li><p><code>401 Unauthorized</code>: Authentication is required.</p>
</li>
<li><p><code>403 Forbidden</code>: No permission.</p>
</li>
<li><p><code>404 Not Found</code>: The requested resource could not be found.</p>
</li>
</ul>
</li>
<li><p><strong>5xx (Server Error):</strong></p>
<ul>
<li><code>500 Internal Server Error</code>: An unexpected error occurred on the server.</li>
</ul>
</li>
</ul>
<h3>Provide Clear and Consistent Error Responses</h3>
<p>When something goes wrong, give back a useful JSON error message. Your future self and any developer using your API will thank you.</p>
<ul>
<li><em>Example of a detailed error response:</em></li>
</ul>
<pre><code>{
  &quot;type&quot;: &quot;[https://---.com/probs/validation-error](https://example.com/probs/validation-error)&quot;,
  &quot;title&quot;: &quot;Your request parameters didn't validate.&quot;,
  &quot;status&quot;: 400,
  &quot;detail&quot;: &quot;The 'email' field must be a valid email address.&quot;,
  &quot;instance&quot;: &quot;/api/users&quot;
}
</code></pre>
<h2>5. Performance Optimization</h2>
<p>Optimizing API performance is crucial for providing a good user experience and ensuring the scalability of your service. Key strategies include caching, efficient data retrieval, and controlling traffic.</p>
<h3>Caching</h3>
<p>Caching is one of the most effective ways to improve performance. By storing and reusing frequently accessed data, you can significantly reduce latency and server load.</p>
<ul>
<li><p><strong>How it works:</strong> Caching can be implemented at various levels (client-side, CDN, server-side). REST APIs can facilitate this by using standard HTTP caching headers.</p>
</li>
<li><p><strong>Key Headers:</strong></p>
<ul>
<li><p><code>Cache-Control</code>: Tells the client how long to cache something (e.g., <code>public, max-age=600</code>).</p>
</li>
<li><p><code>ETag</code>: A unique version identifier for a resource. The client can send this back in an <code>If-None-Match</code> header. If the data hasn't changed, you can just return <code>304 Not Modified</code> with an empty body, saving bandwidth.</p>
</li>
<li><p><code>Last-Modified</code>: Indicates when the resource was last changed. Similar to <code>ETag</code>, it can be used for conditional requests with the <code>If-Modified-Since</code> header.</p>
</li>
</ul>
</li>
<li><p><em>Example Response Header for Caching:</em></p>
<pre><code>Cache-Control: public, max-age=600
ETag: &quot;x234dff&quot;
</code></pre>
</li>
</ul>
<h3>Filtering, Sorting, and Pagination</h3>
<p>For endpoints that return lists of resources, it's inefficient to return the entire dataset at once, especially if it's large. Implementing these features gives clients more control over the data they receive.</p>
<ul>
<li><p><strong>Filtering:</strong> Allows clients to narrow down the result set based on specific criteria. This reduces the amount of data transferred and makes it easier for the client to find what it needs.</p>
<ul>
<li><em>Example:</em> <code>GET /api/orders?status=shipped&amp;customer_id=123</code></li>
</ul>
</li>
<li><p><strong>Sorting:</strong> Enables clients to request the data in a specific order. A common convention is to specify the field to sort by and the direction (ascending or descending).</p>
<ul>
<li><em>Example:</em> <code>GET /api/users?sort=lastName_asc</code> or <code>GET /api/products?sort=-price</code> (the <code>-</code> indicates descending order).</li>
</ul>
</li>
<li><p><strong>Pagination:</strong> Breaks down a large result set into smaller, manageable chunks called &quot;pages&quot;. This prevents overloading the server and client with massive amounts of data in a single response.</p>
<ul>
<li><em>Example:</em> <code>GET /api/articles?page=2&amp;pageSize=20</code> (retrieves the second page, with 20 articles per page).</li>
</ul>
</li>
</ul>
<h3>Rate Limiting</h3>
<p>Protect your API from abuse by limiting how many requests a client can make in a given time. If they exceed the limit, return a <code>429 Too Many Requests</code>.
It's also super helpful to return these headers so the client knows what's going on:</p>
<ul>
<li><p><code>X-RateLimit-Limit</code>: Total requests allowed.</p>
</li>
<li><p><code>X-RateLimit-Remaining</code>: How many requests they have left.</p>
</li>
<li><p><code>Retry-After</code>: How many seconds they should wait before trying again.</p>
</li>
</ul>
<h2>6. Security</h2>
<p>Security is not an optional feature; it must be a core part of your API design.</p>
<ul>
<li><p><strong>Always Use HTTPS (TLS):</strong> Encrypt all traffic to prevent man-in-the-middle attacks. There are no exceptions to this rule for production APIs.</p>
</li>
<li><p><strong>Authentication &amp; Authorization:</strong></p>
<ul>
<li><p><strong>Authentication</strong> (Who are you?): Use a standard like OAuth 2.0 or JWT Bearer Tokens.</p>
</li>
<li><p><strong>Authorization</strong> (What are you allowed to do?): Check permissions for every request. Just because a user is logged in doesn't mean they can delete another user's data.</p>
</li>
</ul>
</li>
<li><p><strong>Input Validation</strong>: Always validate and sanitize data coming from the client to prevent injection attacks. If the data is bad, reject it with a <code>400 Bad Request</code>.</p>
</li>
<li><p><strong>Use Security Headers</strong>: Add headers like <code>Strict-Transport-Security</code> and <code>Content-Security-Policy</code> to add extra layers of browser-level protection.</p>
</li>
</ul>
<h2>7. API Lifecycle Management</h2>
<h3>Versioning</h3>
<p>Your API will change. Versioning lets you make breaking changes without messing up existing clients. The most common way is in the URI.</p>
<ul>
<li><p><strong>URI Versioning (Most Common):</strong> <code>https://api.example.com/v1/users</code></p>
<ul>
<li><strong>Pros:</strong> Simple, explicit, and easy to explore in a browser.</li>
</ul>
</li>
<li><p><strong>Header Versioning:</strong> The client requests a version via a custom HTTP header.</p>
<ul>
<li><p><em>Example:</em> <code>Accept-Version: v1</code></p>
</li>
<li><p><strong>Pros:</strong> Keeps the URI clean.</p>
</li>
</ul>
</li>
<li><p><strong>Media Type Versioning (Content Negotiation):</strong> The version is included in the <code>Accept</code> header.</p>
<ul>
<li><p><em>Example:</em> <code>Accept: application/vnd.example.v1+json</code></p>
</li>
<li><p><strong>Pros:</strong> Technically the &quot;purest&quot; REST approach.</p>
</li>
</ul>
</li>
</ul>
<h3>Backward Compatibility &amp; Deprecation</h3>
<p>When you release v2, don't just kill v1. Keep it running for a while and communicate a clear shutdown schedule to your users.</p>
<h3>Documentation</h3>
<p>An API is only as good as its documentation. Use tools like the <strong>OpenAPI Specification (formerly Swagger)</strong> to generate interactive, machine-readable documentation. Good docs should include:</p>
<ul>
<li><p>Authentication instructions.</p>
</li>
<li><p>Clear explanations of each endpoint.</p>
</li>
<li><p>Request/response examples.</p>
</li>
<li><p>Error code definitions.</p>
</li>
</ul>
<h2>8. Monitoring and Testing</h2>
<h3>Monitoring and Logging</h3>
<p>To ensure your API is reliable, you must monitor its health and log important events.</p>
<ul>
<li><p><strong>Structured Logging:</strong> Log in a machine-readable format like JSON. Include a <code>correlationId</code> to track a single request across multiple services.</p>
</li>
<li><p><strong>Monitoring:</strong> Track key metrics like latency (response time), error rate, and requests per second. Use tools like Prometheus, Grafana, or Datadog to visualize these metrics and set up alerts.</p>
</li>
</ul>
<h3>API Testing</h3>
<p>Thorough testing is essential to prevent bugs and regressions.</p>
<ul>
<li><p><strong>Unit Tests:</strong> Test individual components and business logic in isolation.</p>
</li>
<li><p><strong>Integration Tests:</strong> Test the interaction between different parts of your API, including the database.</p>
</li>
<li><p><strong>Contract Tests:</strong> Verify that your API adheres to its documented contract (e.g., the OpenAPI spec).</p>
</li>
</ul>
<h2>9. Advanced Level: HATEOAS</h2>
<p><strong>HATEOAS (Hypermedia as the Engine of Application State)</strong> is a REST principle that allows your API to be self-documenting and more discoverable. It involves including hyperlinks in responses for actions that can be performed on the relevant resource.</p>
<p>For example, a response for a user resource might look like this:</p>
<pre><code>{
  &quot;id&quot;: 1,
  &quot;name&quot;: &quot;Deo Steel&quot;,
  &quot;links&quot;: [
    { &quot;rel&quot;: &quot;self&quot;, &quot;href&quot;: &quot;/users/1&quot;, &quot;method&quot;: &quot;GET&quot; },
    { &quot;rel&quot;: &quot;update&quot;, &quot;href&quot;: &quot;/users/1&quot;, &quot;method&quot;: &quot;PUT&quot; },
    { &quot;rel&quot;: &quot;delete&quot;, &quot;href&quot;: &quot;/users/1&quot;, &quot;method&quot;: &quot;DELETE&quot; }
  ]
}
</code></pre>
<p>This way, the client can follow the links in the response to take the next step, rather than manually constructing the URIs.</p>
<h2>10. A Practical Shortcut: Leveraging Frameworks like ABP.IO</h2>
<p>Okay, that was a lot. While it's crucial to understand all these principles, you don't have to build everything from scratch. Modern frameworks can handle a ton of this for you. I work a lot in the .NET space, and <strong>ABP Framework</strong> is a great example of this.</p>
<p>Here’s how it automates many of the things we just talked about:</p>
<ul>
<li><p><strong>Automatic API Controllers</strong>: You write your business logic in an &quot;Application Service,&quot; and ABP automatically creates the REST API endpoints for you, following all the correct naming and HTTP method conventions. (Covers sections 2 &amp; 3).</p>
</li>
<li><p><strong>Built-in Best Practices</strong>:</p>
<ul>
<li><p><strong>Standardized Error Responses</strong>: It has a built-in exception handling system that automatically generates clean, consistent JSON error responses. (Covers section 4).</p>
</li>
<li><p><strong>Input Validation</strong>: It has automatic validation for your DTOs. If a request is invalid, it returns a detailed <code>400 Bad Request</code> without you writing a single line of code for it. (Covers section 6).</p>
</li>
<li><p><strong>Paging, Sorting, Filtering</strong>: You get these out of the box by just using their predefined interfaces. (Covers section 5).</p>
</li>
</ul>
</li>
<li><p><strong>Integrated Security</strong>: It comes with a full auth system. You just add an <code>[Authorize]</code> attribute to a method, and it handles the rest. It also automatically manages database transactions per API request (Unit of Work) to ensure data consistency. (Covers section 6).</p>
</li>
<li><p><strong>Automatic Documentation</strong>: It automatically generates an OpenAPI/Swagger UI for your API, which is a massive help for anyone who needs to use it. (Covers section 7).</p>
</li>
</ul>
<p>Using a framework like this lets you focus on your core business logic, confident that the foundation is built on solid, established best practices.</p>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/api/posts/cover-picture-source/3a1c5e66-b89d-62af-101b-bf977581c4e7" />
      <media:content url="https://abp.io/api/posts/cover-picture-source/3a1c5e66-b89d-62af-101b-bf977581c4e7" medium="image" />
    </item>
  </channel>
</rss>