<?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>Mon, 05 Oct 2026 18:04:24 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=kfrancis%40clinicalsupportsystems.com" />
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/why-does-my-tiered-abp-app-show-an-empty-menu-while-the-user-is-still-signed-in-7g46886w</guid>
      <link>https://abp.io/community/posts/why-does-my-tiered-abp-app-show-an-empty-menu-while-the-user-is-still-signed-in-7g46886w</link>
      <a10:author>
        <a10:name>kfrancis@clinicalsupportsystems.com</a10:name>
        <a10:uri>https://abp.io/community/members/kfrancis@clinicalsupportsystems.com</a10:uri>
      </a10:author>
      <category>authorization</category>
      <category>mvc</category>
      <category>authentication</category>
      <category>openid-connect</category>
      <category>abp</category>
      <title>Why Does My Tiered ABP App Show an Empty Menu While the User Is Still Signed In?</title>
      <description>In a tiered MVC application, ABP falls back to an identity-client token when it cannot read the signed-in user's access token, so the API can return a valid but user-less response with an empty `grantedPolicies` dictionary. Replace the remote-service authenticator so an authenticated user can never take that fallback, then force a fresh OIDC challenge when a page request has a user cookie but no `access_token`</description>
      <pubDate>Wed, 29 Jul 2026 16:43:25 Z</pubDate>
      <a10:updated>2026-10-05T08:20:36Z</a10:updated>
      <content:encoded><![CDATA[<!--
title: "Why Does My Tiered ABP App Show an Empty Menu While the User Is Still Signed In?"
author: "Kori Francis"
date: "2026-07-29"
tags: ["ABP Framework", "tiered architecture", "client credentials", "access token", "empty grantedPolicies", "OpenIddict", "ASP.NET Core"]
difficulty: "Advanced"
abp_version: "10.5.0 (verified on 10.0.2 and 10.6.0)"
dotnet_version: ".NET 10"
article_type: "Troubleshooting"
estimated_read_time: "11 minutes"
--->
<h1>Why Does My Tiered ABP App Show an Empty Menu While the User Is Still Signed In?</h1>
<blockquote>
<p><strong>TL;DR</strong>: In a tiered MVC application, ABP falls back to an identity-client token when it cannot read the signed-in user's access token, so the API can return a valid but user-less response with an empty <code>grantedPolicies</code> dictionary. Replace the remote-service authenticator so an authenticated user can never take that fallback, then force a fresh OIDC challenge when a page request has a user cookie but no <code>access_token</code>.</p>
</blockquote>
<h2>The Symptom</h2>
<p>The user is visibly signed in, but the application behaves as if they have no permissions:</p>
<ul>
<li>The navigation menu is almost empty.</li>
<li>Permission-guarded buttons and partials disappear.</li>
<li>An order or product grid returns no rows.</li>
<li><code>/api/abp/application-configuration</code> contains an empty <code>auth.grantedPolicies</code> dictionary.</li>
<li>Signing out and back in fixes the session.</li>
</ul>
<p>There may be no exception, <code>401</code>, or <code>403</code>. The web tier gets a successful response and renders it.</p>
<p>We initially treated this as a permission-cache problem. Logging the granted-policy count proved that it really dropped to zero, but not why. Redis remained healthy during the affected requests. We also lost time on the simplest explanation: a role with no grants produces the same UI. Check the role's permission grants before investigating the framework.</p>
<p>The useful comparison was the claims principal at both ends of one correlated request. The web tier had a user principal. The API tier received a valid client principal with no user subject. That moved the investigation from permission resolution to the outbound HTTP call.</p>
<p>Do not use token length as the test. Decode claims without logging the raw token. A user token normally has a user subject (<code>sub</code>), while a client-credentials token represents the application.</p>
<h2>Why It Happens</h2>
<p>A tiered ABP MVC UI calls the API through <a href="https://abp.io/docs/10.6/framework/api-development/dynamic-csharp-clients">dynamic C# client proxies</a>. Before a protected proxy request is sent, ABP resolves <code>IRemoteServiceHttpClientAuthenticator</code>.</p>
<p>For an MVC or Razor Pages host that depends on <code>AbpHttpClientIdentityModelWebModule</code>, the implementation is <a href="https://github.com/abpframework/abp/blob/10.5.0/framework/src/Volo.Abp.Http.Client.IdentityModel.Web/Volo/Abp/Http/Client/IdentityModel/Web/HttpContextIdentityModelRemoteServiceHttpClientAuthenticator.cs"><code>HttpContextIdentityModelRemoteServiceHttpClientAuthenticator</code></a>. Its ABP 10.5.0 sequence is:</p>
<ol>
<li>Unless <code>RemoteServices:&lt;name&gt;:UseCurrentAccessToken</code> is <code>false</code>, call <code>IAbpAccessTokenProvider.GetTokenAsync()</code>.</li>
<li>If that returns a token, put it on the outgoing request and return.</li>
<li>If it returns <code>null</code>, call the base authenticator.</li>
</ol>
<p>The base <a href="https://github.com/abpframework/abp/blob/10.5.0/framework/src/Volo.Abp.Http.Client.IdentityModel/Volo/Abp/Http/Client/IdentityModel/IdentityModelRemoteServiceHttpClientAuthenticator.cs"><code>IdentityModelRemoteServiceHttpClientAuthenticator</code></a> calls <code>IIdentityModelAuthenticationService.TryAuthenticateAsync</code>. It selects the remote service's <code>IdentityClient</code>, then the remote-service name, with ABP's identity-client configuration ultimately able to fall back to <code>Default</code>. The official <a href="https://abp.io/docs/10.6/framework/api-development/identitymodel-clients">IdentityModel Clients documentation</a> describes that path as server-to-server authentication.</p>
<p>That fallback is intentional when no user exists:</p>
<pre><code class="language-text">page request
  -&gt; current user's access token exists
  -&gt; API receives user identity

background or app-to-app request
  -&gt; no current user token exists
  -&gt; IdentityClients supplies an application token

broken user session
  -&gt; user cookie still authenticates
  -&gt; access_token is missing
  -&gt; IdentityClients supplies an application token
  -&gt; API receives the application identity
</code></pre>
<p>The third branch is the trap. If the client has permission to call the endpoint but no user permissions, the HTTP request can succeed while the response contains no user-specific grants. A successful wrong-identity response is harder to diagnose than a failed request.</p>
<p>ASP.NET Core only stores remote access and refresh tokens in <code>AuthenticationProperties</code> when <code>SaveTokens</code> is enabled; it defaults to <code>false</code> to limit cookie size. ABP's tiered MVC template sets it to <code>true</code>. The framework's <a href="https://github.com/abpframework/abp/blob/10.5.0/framework/src/Volo.Abp.Http.Client.IdentityModel.Web/Volo/Abp/Http/Client/IdentityModel/Web/HttpContextAbpAccessTokenProvider.cs"><code>HttpContextAbpAccessTokenProvider</code></a> then uses <code>HttpContext.GetTokenAsync(&quot;access_token&quot;)</code>.</p>
<p>Why that token went missing is a separate investigation. Cookie size, a server-side ticket store, Data Protection configuration, and token-refresh handling are all possible places to look. The fix below does not repair token storage; it prevents a missing token from silently changing the caller's identity.</p>
<h2>The Fix</h2>
<p>We made the unsafe state explicit: client credentials remain valid when there is no authenticated user, but not when an authenticated user has lost their access token.</p>
<p>Add this class to the tiered web host:</p>
<pre><code class="language-csharp">using System.Threading.Tasks;
using Duende.IdentityModel.Client;
using Microsoft.Extensions.Logging;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Http.Client;
using Volo.Abp.Http.Client.Authentication;
using Volo.Abp.Http.Client.IdentityModel.Web;
using Volo.Abp.IdentityModel;
using Volo.Abp.Users;

namespace Acme.Orders.Web;

[Dependency(ReplaceServices = true)]
[ExposeServices(
    typeof(IRemoteServiceHttpClientAuthenticator),
    typeof(HttpContextIdentityModelRemoteServiceHttpClientAuthenticator))]
public class StrictRemoteServiceHttpClientAuthenticator
    : HttpContextIdentityModelRemoteServiceHttpClientAuthenticator
{
    private readonly ICurrentUser _currentUser;
    private readonly ILogger&lt;StrictRemoteServiceHttpClientAuthenticator&gt; _logger;

    public StrictRemoteServiceHttpClientAuthenticator(
        IIdentityModelAuthenticationService identityModelAuthenticationService,
        IAbpAccessTokenProvider accessTokenProvider,
        ICurrentUser currentUser,
        ILogger&lt;StrictRemoteServiceHttpClientAuthenticator&gt; logger)
        : base(identityModelAuthenticationService, accessTokenProvider)
    {
        _currentUser = currentUser;
        _logger = logger;
    }

    public override async Task Authenticate(
        RemoteServiceHttpClientAuthenticateContext context)
    {
        if (context.RemoteService.GetUseCurrentAccessToken() != false)
        {
            var accessToken = await AccessTokenProvider.GetTokenAsync();

            if (accessToken is not null)
            {
                context.Request.SetBearerToken(accessToken);
                return;
            }

            if (_currentUser.IsAuthenticated)
            {
                _logger.LogError(
                    &quot;Blocked identity-client fallback for authenticated user {UserId}. &quot; +
                    &quot;RemoteService={RemoteService}, Path={Path}&quot;,
                    _currentUser.Id,
                    context.RemoteServiceName,
                    context.Request.RequestUri?.AbsolutePath);

                // Send no bearer token. A protected API now answers 401 instead
                // of successfully running under the application's identity.
                return;
            }
        }

        // No current user, or UseCurrentAccessToken is explicitly false:
        // preserve ABP's app-to-app identity-client behavior.
        await base.Authenticate(context);
    }
}
</code></pre>
<p><code>[Dependency(ReplaceServices = true)]</code> replaces existing descriptors, while <code>[ExposeServices]</code> makes the two resolution surfaces explicit. The interface is what ABP's client proxy resolves. Exposing the framework concrete type as well prevents application code that injects that type from bypassing the replacement.</p>
<p>Failing loudly protects the API call, but a user should not remain in a broken session. Add page-only middleware that signs out the cookie and uses the application's configured default challenge scheme:</p>
<pre><code class="language-csharp">using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.Extensions.Logging;

namespace Acme.Orders.Web;

public sealed class EnsureAccessTokenMiddleware
{
    private const string RecoveryCookie = &quot;.Orders.AccessTokenRecovery&quot;;

    private static readonly PathString[] ExcludedPrefixes =
    [
        &quot;/api&quot;,
        &quot;/Abp&quot;,
        &quot;/connect&quot;,
        &quot;/hangfire&quot;,
        &quot;/health&quot;,
        &quot;/signalr&quot;,
        &quot;/swagger&quot;,
        &quot;/_&quot;
    ];

    private readonly RequestDelegate _next;
    private readonly ILogger&lt;EnsureAccessTokenMiddleware&gt; _logger;

    public EnsureAccessTokenMiddleware(
        RequestDelegate next,
        ILogger&lt;EnsureAccessTokenMiddleware&gt; logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        if (context.User.Identity?.IsAuthenticated == true &amp;&amp;
            IsPageRequest(context))
        {
            var accessToken = await context.GetTokenAsync(&quot;access_token&quot;);

            if (string.IsNullOrEmpty(accessToken))
            {
                if (context.Request.Cookies.ContainsKey(RecoveryCookie))
                {
                    context.Response.Cookies.Delete(RecoveryCookie);
                    context.Response.StatusCode =
                        StatusCodes.Status500InternalServerError;
                    context.Response.ContentType = &quot;text/plain&quot;;
                    await context.Response.WriteAsync(
                        &quot;The sign-in session could not be recovered.&quot;);
                    return;
                }

                _logger.LogWarning(
                    &quot;Authenticated page request has no access_token. Path={Path}&quot;,
                    context.Request.Path);

                context.Response.Cookies.Append(
                    RecoveryCookie,
                    &quot;1&quot;,
                    new CookieOptions
                    {
                        HttpOnly = true,
                        Secure = context.Request.IsHttps,
                        SameSite = SameSiteMode.Lax,
                        IsEssential = true,
                        MaxAge = TimeSpan.FromMinutes(2)
                    });

                var returnUrl = context.Request.GetEncodedPathAndQuery();

                await context.SignOutAsync();
                await context.ChallengeAsync(
                    new AuthenticationProperties { RedirectUri = returnUrl });
                return;
            }

            if (context.Request.Cookies.ContainsKey(RecoveryCookie))
            {
                context.Response.Cookies.Delete(RecoveryCookie);
            }
        }

        await _next(context);
    }

    private static bool IsPageRequest(HttpContext context)
    {
        if (!HttpMethods.IsGet(context.Request.Method) ||
            !context.Request.Headers.Accept.Any(
                value =&gt; value?.Contains(
                    &quot;text/html&quot;,
                    StringComparison.OrdinalIgnoreCase) == true))
        {
            return false;
        }

        var path = context.Request.Path;

        if (ExcludedPrefixes.Any(prefix =&gt; path.StartsWithSegments(prefix)))
        {
            return false;
        }

        return string.IsNullOrEmpty(Path.GetExtension(path.Value));
    }
}
</code></pre>
<p>Use the default schemes rather than hard-coding them. The ABP 10.5 tiered MVC template registers <code>&quot;Cookies&quot;</code> as <code>DefaultScheme</code> and <code>&quot;oidc&quot;</code> as <code>DefaultChallengeScheme</code>; <code>OpenIdConnectDefaults.AuthenticationScheme</code> has a different value.</p>
<p>The web module must already depend on <code>AbpHttpClientIdentityModelWebModule</code>. Register the middleware after authentication populates <code>HttpContext.User</code> and before authorization consumes it:</p>
<pre><code class="language-csharp">using Microsoft.AspNetCore.Builder;
using Volo.Abp;
using Volo.Abp.AspNetCore.Mvc;
using Volo.Abp.Http.Client.IdentityModel.Web;
using Volo.Abp.Modularity;

namespace Acme.Orders.Web;

[DependsOn(
    typeof(AbpAspNetCoreMvcModule),
    typeof(AbpHttpClientIdentityModelWebModule))]
public class OrdersWebModule : AbpModule
{
    public override void OnApplicationInitialization(
        ApplicationInitializationContext context)
    {
        var app = context.GetApplicationBuilder();

        // Keep the rest of your generated ABP pipeline in its existing order.
        app.UseRouting();
        app.UseAuthentication();

        app.UseMiddleware&lt;EnsureAccessTokenMiddleware&gt;();

        // Keep UseMultiTenancy() and UseDynamicClaims() here when enabled.
        app.UseAuthorization();
        app.UseConfiguredEndpoints();
    }
}
</code></pre>
<p>In an existing generated module, add only the <code>UseMiddleware</code> line at the shown location; do not remove its localization, correlation, static-asset, multi-tenancy, dynamic-claims, Swagger, logging, or endpoint configuration.</p>
<h2>Gotchas</h2>
<ul>
<li>Do not disable identity-client authentication globally. Background workers and app-to-app calls have no current user and legitimately need it. <code>UseCurrentAccessToken = false</code> must also retain its documented meaning.</li>
<li>Middleware order is load-bearing. Before <code>UseAuthentication</code>, there is no populated user to inspect. After <code>UseAuthorization</code>, the broken principal may already have produced an authorization result.</li>
<li>Restrict the challenge to browser page navigations. Challenging API, SignalR, health, Swagger, dashboard, framework, or static-file requests turns a clear authentication failure into redirects in places that cannot handle them. Extend the exclusions for your routes.</li>
<li>Keep a loop guard. If the OIDC round-trip returns another token-less ticket, repeatedly challenging hides the underlying fault and can overload the auth server.</li>
<li>Do not log access tokens, cookie values, client secrets, or full claims. Log the subject type, selected claim names, remote-service name, path, and correlation ID.</li>
<li>This behavior is unchanged in the 10.6.0 source. ABP 10.6 does change <code>HttpContextAbpAccessTokenProvider</code> to test <code>HttpContext.User.Identity.IsAuthenticated</code> directly. A separate ABP 10 issue affects forwarding an incoming client principal's bearer token; that produces a downstream <code>401</code> and is not this missing-user-token failure.</li>
</ul>
<h2>How to Verify It Worked</h2>
<p>First verify the diagnosis without changing production state:</p>
<ol>
<li>Correlate one web request with its API request.</li>
<li>On the web tier, record whether <code>ICurrentUser.IsAuthenticated</code> is true and whether <code>GetTokenAsync(&quot;access_token&quot;)</code> returned a value. Do not record the value.</li>
<li>On the API tier, classify the principal as user, client, or anonymous from claims. Alert when a user-facing endpoint receives a client principal.</li>
</ol>
<p>Then reproduce in a non-production tiered application. Sign in, re-issue the local authentication ticket without the <code>access_token</code> in its <code>AuthenticationProperties</code>, and request a permission-guarded page.</p>
<p>Before the change, the API receives a client token when an <code>IdentityClients</code> entry is configured, and the UI can render an empty permission map. After the change, the page middleware initiates one OIDC round-trip. If recovery is bypassed, the strict authenticator sends no bearer token and the protected API returns <code>401</code>; it never receives the application's client identity for that user request.</p>
<p>Add a counter to the <code>&quot;Blocked identity-client fallback&quot;</code> event. Its healthy steady-state value is zero. A non-zero value means the guard worked and the separate token-storage investigation still has work to do.</p>
<p>Finally, keep a regression test around the decision table:</p>
<p>| Current user | Current token | Expected result |
|---|---|---|
| Authenticated | Present | Send the user token |
| Authenticated | Missing | Do not acquire an identity-client token |
| Not authenticated | Missing | Preserve identity-client authentication |
| Any | <code>UseCurrentAccessToken = false</code> | Preserve identity-client authentication |</p>
<h2>Scope and Caveats</h2>
<p>This is for server-rendered tiered MVC or Razor Pages hosts using <code>Volo.Abp.Http.Client.IdentityModel.Web</code>. Blazor WebAssembly has a different authenticator and token-provider model.</p>
<p>The guard assumes user-facing API endpoints require authentication. An anonymous endpoint can still accept the no-token request, so endpoints whose output is user-specific must remain protected.</p>
<p>Most importantly, this is containment, not root-cause repair. Once the silent fallback is blocked, investigate why an authenticated ticket can exist without its access token. Check <code>SaveTokens</code>, cookie or ticket-store persistence, token refresh, Data Protection consistency across instances, and deployment timing. At larger scale, prefer a durable server-side ticket store over growing authentication cookies, but treat the store as security-sensitive infrastructure.</p>
<h2>References</h2>
<ul>
<li><a href="https://abp.io/docs/10.6/framework/api-development/dynamic-csharp-clients">ABP dynamic C# client proxies</a></li>
<li><a href="https://abp.io/docs/10.6/framework/api-development/identitymodel-clients">ABP IdentityModel Clients</a></li>
<li><a href="https://github.com/abpframework/abp/blob/10.5.0/framework/src/Volo.Abp.Http.Client.IdentityModel.Web/Volo/Abp/Http/Client/IdentityModel/Web/HttpContextIdentityModelRemoteServiceHttpClientAuthenticator.cs">ABP 10.5.0 <code>HttpContextIdentityModelRemoteServiceHttpClientAuthenticator</code> source</a></li>
<li><a href="https://github.com/abpframework/abp/blob/10.5.0/framework/src/Volo.Abp.Http.Client.IdentityModel/Volo/Abp/Http/Client/IdentityModel/IdentityModelRemoteServiceHttpClientAuthenticator.cs">ABP 10.5.0 <code>IdentityModelRemoteServiceHttpClientAuthenticator</code> source</a></li>
<li><a href="https://abp.io/support/questions/2823/Additional-details-on-HttpClientFactory">ABP support explanation of MVC/Razor remote-service authentication</a></li>
<li><a href="https://abp.io/support/questions/10122/Application-Configuration-Endpoint-Missing-Auth-Information-After-Upgrade-721--936">ABP support: a different cause of empty application configuration</a></li>
<li><a href="https://abp.io/support/questions/10772/ABP-10---Dynamic-HTTP-client-proxies-no-longer-forward-incoming-bearer-tokens-and-fall-back-to-IdentityClients">ABP 10 token-forwarding regression for client principals</a></li>
<li><a href="https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.authentication.remoteauthenticationoptions.savetokens?view=aspnetcore-10.0">Microsoft: <code>RemoteAuthenticationOptions.SaveTokens</code></a></li>
<li><a href="https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.authentication.authenticationhttpcontextextensions.gettokenasync?view=aspnetcore-10.0">Microsoft: <code>GetTokenAsync</code></a></li>
<li><a href="https://learn.microsoft.com/en-us/aspnet/core/security/authentication/?view=aspnetcore-10.0">Microsoft: ASP.NET Core authentication overview</a></li>
</ul>
<hr />
<h2>About the Author</h2>
<p><strong>Kori Francis</strong></p>
<p>CTO at Clinical Support Systems, working on production .NET and ABP Framework systems.</p>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/api/posts/cover-picture-source/3a22c0ef-4755-5666-0615-b32bef0a80f6" />
      <media:content url="https://abp.io/api/posts/cover-picture-source/3a22c0ef-4755-5666-0615-b32bef0a80f6" medium="image" />
    </item>
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/how-i-use-a-custom-ai-skill-to-upgrade-a-large-abp-solution-h5fllft1</guid>
      <link>https://abp.io/community/posts/how-i-use-a-custom-ai-skill-to-upgrade-a-large-abp-solution-h5fllft1</link>
      <a10:author>
        <a10:name>kfrancis@clinicalsupportsystems.com</a10:name>
        <a10:uri>https://abp.io/community/members/kfrancis@clinicalsupportsystems.com</a10:uri>
      </a10:author>
      <category>workflow</category>
      <category>update</category>
      <category>abp</category>
      <category>ai</category>
      <title>How I Use a Custom AI Skill to Upgrade a Large ABP Solution</title>
      <description>I created a project-specific AI skill that treats an ABP upgrade as a complete engineering workflow—not merely a package update. It inventories the current solution, creates recovery points, applies the upgrade, resolves known breaking changes, validates the result, and records new lessons for the next upgrade.</description>
      <pubDate>Wed, 29 Jul 2026 15:09:21 Z</pubDate>
      <a10:updated>2026-10-05T13:28:37Z</a10:updated>
      <content:encoded><![CDATA[<!--
title: "How I Use a Custom AI Skill to Upgrade a Large ABP Solution"
author: "Kori Francis"
date: "2026-07-29"
tags: ["ABP Framework", "AI Skills", "Upgrades", "Automation", "PowerShell", "Entity Framework Core"]
difficulty: "Intermediate"
abp_version: "10.x"
dotnet_version: ".NET 10"
article_type: "Best Practices"
estimated_read_time: "12 minutes"
-->
<h1>How I Use a Custom AI Skill to Upgrade a Large ABP Solution 🧭</h1>
<blockquote>
<p><strong>TL;DR</strong>: I created a project-specific AI skill that treats an ABP upgrade as a complete engineering workflow—not merely a package update. It inventories the current solution, creates recovery points, applies the upgrade, resolves known breaking changes, validates the result, and records new lessons for the next upgrade.</p>
</blockquote>
<h2>Introduction</h2>
<p>Upgrading a small application can be as simple as changing a few package versions and rebuilding. Upgrading a mature ABP solution is different.</p>
<p>My solution contains custom ABP service replacements, Entity Framework Core migrations, NuGet and npm dependencies, authentication, Redis, background processing, multiple user interfaces, and production-specific integrations. A framework upgrade can affect any of those areas. More importantly, a successful compilation does not prove that authentication, billing, localization, caching, or database behavior still works.</p>
<p>I wanted my coding agent to understand that wider context every time I asked it to perform an upgrade. Repeating a large prompt from memory was unreliable, so I captured the workflow in a custom skill. You can call yours anything; the reusable example included with this article is named <code>upgrade-abp-solution</code>.</p>
<p>The skill gives the agent:</p>
<ul>
<li>A repeatable upgrade process</li>
<li>Project-specific validation requirements</li>
<li>PowerShell helpers for inventory, backup, and verification</li>
<li>References for migration and namespace changes</li>
<li>A durable log of problems encountered in previous upgrades</li>
<li>An explicit rollback path</li>
</ul>
<p>This article explains how the skill is structured, how I use it, and why the most valuable part is not the automation—it is the memory the workflow builds over time.</p>
<h2>Why <code>abp update</code> Is Only One Step</h2>
<p>The ABP CLI does a useful job of updating ABP-related dependencies:</p>
<pre><code class="language-powershell">abp update --version 10.5.0
</code></pre>
<p>However, an update command cannot answer solution-specific questions such as:</p>
<ul>
<li>Do custom classes still match constructors in ABP base classes?</li>
<li>Has an implemented ABP interface gained a new member?</li>
<li>Does the new ABP version require an EF Core migration?</li>
<li>Are directly pinned packages now downgrading transitive dependencies?</li>
<li>Is the LeptonX version line different from the main ABP version?</li>
<li>Did a cached value become incompatible with the new application?</li>
<li>Do login, two-factor authentication, password reset, and token flows still behave correctly?</li>
<li>Can the solution be restored if a generated migration is unsafe?</li>
</ul>
<p>My skill therefore treats <code>abp update</code> as an implementation detail inside a larger process:</p>
<pre><code class="language-text">Inspect → Protect → Research → Update → Compile → Migrate → Validate → Learn
</code></pre>
<p>That change in perspective matters. The goal is not to make package files say the new version. The goal is to move the entire solution to the new version with evidence that it remains safe to ship.</p>
<h2>The Anatomy of the Skill</h2>
<p>The skill is deliberately small enough to inspect and maintain:</p>
<pre><code class="language-text">upgrade-abp-solution/
├── SKILL.md
├── agents/
│   └── openai.yaml
├── references/
│   ├── encountered-issues.md
│   └── migration-checklist.md
└── scripts/
    ├── Get-AbpInventory.ps1
    ├── New-PreUpgradeSnapshot.ps1
    └── Test-AbpUpgrade.ps1
</code></pre>
<p>Each part has a distinct responsibility.</p>
<p>You can browse or copy the complete <a href="https://github.com/kfrancis/my-abp-articles/blob/main/articles/custom-abp-upgrade-skill/upgrade-abp-solution/SKILL.md">sample <code>upgrade-abp-solution</code> skill</a> included with this article. It is generic and has no dependencies on my solution.</p>
<h3><code>SKILL.md</code>: The Operating Procedure</h3>
<p><code>SKILL.md</code> is the entry point. Its description tells the agent when the skill applies, while its body describes the ordered workflow.</p>
<p>At a high level, the instructions require the agent to:</p>
<ol>
<li>Review problems from earlier upgrades.</li>
<li>Back up the database and create a Git recovery point.</li>
<li>Identify the current and target versions.</li>
<li>Read the applicable migration guidance.</li>
<li>Update the ABP CLI and solution dependencies.</li>
<li>Update the .NET SDK and target frameworks when required.</li>
<li>Restore and compile before touching the database.</li>
<li>Generate and manually review EF Core migrations.</li>
<li>Run automated and application-level validation.</li>
<li>Add newly discovered issues to the knowledge log.</li>
</ol>
<p>The ordering is intentional. For example, generating a migration before resolving all compilation errors produces noise rather than useful evidence. Likewise, an upgrade should not begin until the recovery path is known.</p>
<h3><code>references/migration-checklist.md</code>: The Stable Checklist</h3>
<p>The migration checklist contains the things I want checked on every upgrade:</p>
<ul>
<li>Git and database recovery points</li>
<li>ABP and .NET version alignment</li>
<li>Official migration guidance</li>
<li>NuGet restore and full-solution compilation</li>
<li>EF Core migration review</li>
<li>Unit and integration tests</li>
<li>Authentication and CRUD smoke tests</li>
<li>Redis and deployment configuration</li>
</ul>
<p>It also groups recurring problems by area: Entity Framework Core, authorization, localization, caching, background jobs, and ABP system tables.</p>
<p>This file represents stable knowledge. It changes occasionally as the architecture evolves, but it is not rewritten for every upgrade.</p>
<h3>Migration References: Fast Triage for Compile Errors</h3>
<p>Framework upgrades frequently move types, add async APIs, or change module contracts. The migration checklist and encountered-issues log provide focused places to capture those changes and search patterns.</p>
<p>When the build reports a missing type, the skill directs the agent to:</p>
<ol>
<li>Search the current ABP source for the type.</li>
<li>Read the migration guide for the target version.</li>
<li>Determine whether the type moved, its package was split, or its API was replaced.</li>
<li>Apply bulk edits only after confirming the correct replacement.</li>
</ol>
<p>That sequence avoids a common failure mode: guessing a namespace that compiles but represents the wrong abstraction.</p>
<h3><code>references/encountered-issues.md</code>: The Compounding Asset</h3>
<p>The issue log is the part I value most. After every upgrade, the agent records:</p>
<ul>
<li>Source and target versions</li>
<li>The exact error or symptom</li>
<li>The solution that worked</li>
<li>Relevant files</li>
<li>Time spent</li>
<li>Runtime concerns to retest</li>
<li>Advice for the next upgrade</li>
</ul>
<p>This converts one-off debugging into reusable operational knowledge.</p>
<p>For example, the log now knows that:</p>
<ul>
<li>Visual Studio can lock project files while <code>abp update</code> is running.</li>
<li>A failing ABP CLI discovery step may justify a controlled manual package bump.</li>
<li>Custom implementations of ABP base classes and interfaces deserve early attention.</li>
<li>Direct dependency pins can become downgrade errors after transitive requirements move.</li>
<li>LeptonX can follow a different version line from the main ABP packages.</li>
<li>A green build does not resolve a test process that hangs after the test runner starts.</li>
</ul>
<p>The next upgrade begins with those lessons already in context.</p>
<h2>The Three Helper Scripts</h2>
<p>The scripts turn repetitive checks into consistent evidence. They do not replace engineering judgment; they make it easier to apply that judgment to the right information.</p>
<h3>1. Inventory the Current State</h3>
<p><code>Get-AbpInventory.ps1</code> scans the solution and reports:</p>
<ul>
<li>Resolved <code>Volo.Abp</code>, <code>Volo.CmsKit</code>, <code>Volo.Docs</code>, and <code>Volo.Blogging</code> packages</li>
<li>The installed ABP CLI version</li>
<li>The active .NET SDK</li>
<li>Target frameworks found across project files</li>
</ul>
<p>I can run it from the solution root:</p>
<pre><code class="language-powershell">.\scripts\Get-AbpInventory.ps1 -SolutionDir &quot;D:\Projects\MySolution&quot;
</code></pre>
<p>This provides a baseline before any file changes occur. It can also reveal version drift that was already present before the upgrade.</p>
<h3>2. Create a Recovery Point</h3>
<p><code>New-PreUpgradeSnapshot.ps1</code> records the state needed to understand or reverse the code-side upgrade:</p>
<pre><code class="language-powershell">.\scripts\New-PreUpgradeSnapshot.ps1 `
    -SolutionDir &quot;D:\Projects\MySolution&quot; `
    -OutputDir &quot;D:\Backups\abp-upgrades&quot;
</code></pre>
<p>The script:</p>
<ul>
<li>Warns about uncommitted changes</li>
<li>Saves the current package list</li>
<li>Copies central build and SDK configuration</li>
<li>Records the current commit</li>
<li>Creates a timestamped Git restore branch</li>
<li>Writes restore instructions alongside the backup</li>
</ul>
<p>It intentionally reminds me that the database backup is a separate operation. A Git branch can restore code; it cannot restore a database after a destructive migration.</p>
<h3>3. Validate the Result</h3>
<p><code>Test-AbpUpgrade.ps1</code> provides a repeatable first validation pass:</p>
<pre><code class="language-powershell">.\scripts\Test-AbpUpgrade.ps1 `
    -SolutionDir &quot;D:\Projects\MySolution&quot; `
    -Detailed
</code></pre>
<p>It checks:</p>
<ul>
<li>Whether the solution builds without restoring again</li>
<li>Whether obsolete API warnings appeared</li>
<li>Whether unit tests pass</li>
<li>Whether ABP package versions are consistent</li>
<li>Whether expected configuration files are present</li>
<li>Whether EF Core migrations exist</li>
<li>Whether known breaking-change patterns remain</li>
<li>Whether deployment files may still reference an older .NET version</li>
</ul>
<p>Warnings are deliberately distinct from failures. A warning means the script cannot prove something automatically; it does not mean the concern can be ignored.</p>
<p>For example, finding a migrations directory is not the same as proving the model is current. I still run an explicit drift check:</p>
<pre><code class="language-powershell">dotnet ef migrations has-pending-model-changes `
    --project src\MySolution.EntityFrameworkCore `
    --startup-project src\MySolution.DbMigrator
</code></pre>
<h2>My End-to-End Upgrade Workflow</h2>
<p>Here is how the skill guides an actual upgrade.</p>
<h3>Phase 1: Preflight</h3>
<p>First, the agent reads the issue log and applicable migration guidance. It inventories the current package graph, SDK, target frameworks, frontend packages, and explicit dependency pins.</p>
<p>It also identifies solution-specific risk areas before editing anything:</p>
<ul>
<li>ABP base classes that have been subclassed</li>
<li>ABP interfaces implemented by custom infrastructure</li>
<li>Replaced application services</li>
<li>Custom <code>DbContext</code> interfaces</li>
<li>Authentication and OpenIddict customization</li>
<li>Custom event bus implementations</li>
<li>Packages pinned to work around earlier vulnerabilities or incompatibilities</li>
</ul>
<p>This produces a risk map for the upgrade rather than waiting for the compiler to discover everything.</p>
<h3>Phase 2: Protection</h3>
<p>The working tree must be understood and a restore point must exist. I back up the database separately, then run the pre-upgrade script.</p>
<p>The key question is simple:</p>
<blockquote>
<p>If the package update or migration goes wrong, can I return both the code and the data to a known state?</p>
</blockquote>
<p>If the answer is uncertain, the upgrade does not proceed.</p>
<h3>Phase 3: Dependency Update</h3>
<p>The preferred path is the ABP updater:</p>
<pre><code class="language-powershell">dotnet tool update -g Volo.Abp.Cli
abp update --version X.Y.Z
</code></pre>
<p>The skill still expects the result to be inspected. An updater may change the primary ABP packages while leaving:</p>
<ul>
<li>npm packages on another version</li>
<li>lock files stale</li>
<li>direct NuGet pins incompatible</li>
<li>container or CI SDK versions unchanged</li>
</ul>
<p>If the CLI cannot complete, the issue log contains a bounded fallback: update the relevant packages manually, refresh lock files, and let restore surface inconsistencies. The fallback is controlled and reviewable, not a blind search-and-replace across every version string.</p>
<h3>Phase 4: Restore and Compile</h3>
<p>The next gate is:</p>
<pre><code class="language-powershell">dotnet restore
dotnet build --no-restore
</code></pre>
<p>Restore failures and compile failures are treated as different classes of problem.</p>
<p>Restore commonly exposes:</p>
<ul>
<li>A package version that does not exist</li>
<li>A direct package pin below a new transitive lower bound</li>
<li>Mixed ABP versions</li>
<li>An unreachable or slow package source</li>
</ul>
<p>Compilation commonly exposes:</p>
<ul>
<li>New constructor dependencies in ABP base classes</li>
<li>New interface members</li>
<li>Changed override signatures</li>
<li>Moved namespaces</li>
<li>New <code>DbSet</code> requirements</li>
<li>Changes to custom infrastructure contracts</li>
</ul>
<p>Separating these stages keeps the diagnosis precise.</p>
<h3>Phase 5: Database Migration</h3>
<p>Only after the solution compiles does the workflow check for model changes:</p>
<pre><code class="language-powershell">dotnet ef migrations add Upgraded_To_Abp_X_Y `
    --project src\MySolution.EntityFrameworkCore `
    --startup-project src\MySolution.DbMigrator
</code></pre>
<p>The generated migration is reviewed like production code. I specifically look for:</p>
<ul>
<li>Dropped columns or tables</li>
<li>Destructive type or length changes</li>
<li>Unexpected modifications to ABP system tables</li>
<li>Changes that conflict with custom mappings</li>
<li>Provider-specific changes that do not apply to my database</li>
</ul>
<p>An empty migration is removed. A dangerous migration is corrected or the upgrade is paused; it is never accepted simply because it was generated by a tool.</p>
<h3>Phase 6: Validation</h3>
<p>The automated validation script is only the first layer. The skill also calls for focused runtime checks:</p>
<ul>
<li>Application startup</li>
<li>Login and logout</li>
<li>Two-factor authentication and password reset</li>
<li>Basic create, read, update, and delete operations</li>
<li>Tenant switching, when applicable</li>
<li>English and French localization</li>
<li>Audit logging</li>
<li>Redis serialization and cache behavior</li>
<li>Background workers</li>
<li>Critical business integrations</li>
</ul>
<p>The exact list should reflect the solution. In my case, billing and external healthcare integrations deserve explicit checks because they are more important than a generic home-page smoke test.</p>
<h3>Phase 7: Record What Was Learned</h3>
<p>Finally, the agent adds new findings to <code>encountered-issues.md</code>.</p>
<p>This is not optional housekeeping. It closes the loop:</p>
<pre><code class="language-text">Previous upgrades inform this upgrade
             ↓
     A new issue is solved
             ↓
The solution is added to the skill
             ↓
 Future upgrades start smarter
</code></pre>
<p>Without this step, the skill is a static checklist. With it, the skill becomes a project-specific engineering memory.</p>
<h2>Examples of Lessons That Paid Off</h2>
<p>The upgrade log has already changed how later upgrades are performed.</p>
<h3>Close Development Tools Before Updating</h3>
<p>One upgrade produced confusing <code>IOException</code> failures because Visual Studio held project files open. The package changes mostly succeeded, leaving the solution in a partially updated state.</p>
<p>That experience became a preflight instruction: close Visual Studio before running the updater. A later upgrade avoided the problem entirely.</p>
<h3>Do Not Spend Unlimited Time Fighting the Preferred Tool</h3>
<p>During another upgrade, package discovery repeatedly timed out. The skill now recommends a time-bounded decision: try the CLI first, but move to a controlled manual bump when the failure is clearly environmental and repeatable.</p>
<p>This retains a preferred path without turning it into dogma.</p>
<h3>Check Custom ABP Extension Points Early</h3>
<p>Custom base-class overrides and interface implementations are frequent compile-break candidates because they mirror ABP contracts closely.</p>
<p>Past upgrades have required:</p>
<ul>
<li>Passing new dependencies to base constructors</li>
<li>Implementing newly added interface methods</li>
<li>Updating a custom event bus for string-based event contracts</li>
<li>Adding a newly required identity <code>DbSet</code></li>
</ul>
<p>The skill now inspects those seams early rather than treating each error as an unrelated surprise.</p>
<h3>Verify Behavior Even When There Are No Compile Breaks</h3>
<p>One upgrade compiled cleanly and required no EF migration, but it introduced an identity token behavior change worth retesting. The build could not prove whether the application's login, two-factor, password-reset, and account-linking experiences still met expectations.</p>
<p>That lesson reinforces the central principle of the skill: compatibility includes behavior, not only APIs.</p>
<h2>What the Skill Does Not Automate</h2>
<p>I intentionally keep several decisions human-reviewed:</p>
<ul>
<li>Approving destructive database migrations</li>
<li>Choosing when a failed updater should be replaced with a manual update</li>
<li>Deciding whether package-version differences are legitimate</li>
<li>Evaluating business-critical runtime workflows</li>
<li>Clearing production caches or authentication data</li>
<li>Deploying the upgraded solution</li>
</ul>
<p>Commands such as flushing Redis or truncating OpenIddict tables can have serious operational consequences. They may be useful recovery options, but they should never run automatically merely because an upgrade occurred.</p>
<p>The skill makes these risks visible and supplies context. It does not remove ownership of the decision.</p>
<h2>Design Principles for Your Own Upgrade Skill</h2>
<p>If you want to build a similar skill for your ABP solution, I recommend the following principles.</p>
<h3>Start with Your Architecture</h3>
<p>Generic migration guidance is useful, but the skill becomes valuable when it knows your extension points, infrastructure, deployment model, and critical user journeys.</p>
<h3>Separate Procedure, References, and Executable Checks</h3>
<p>Keep the main skill concise. Put stable checklists and historical details in reference files, and put deterministic operations in scripts. This makes each layer easier to review.</p>
<h3>Prefer Evidence-Producing Automation</h3>
<p>A good helper script should answer a question:</p>
<ul>
<li>What versions are installed?</li>
<li>Can I recover the previous state?</li>
<li>Does the solution compile?</li>
<li>Are package versions inconsistent?</li>
<li>Is the EF Core model current?</li>
</ul>
<p>Avoid automation whose only output is “done.”</p>
<h3>Make Rollback Part of the Happy Path</h3>
<p>Rollback planning should happen before the update, not after an error. Record the Git commit, create a restore branch, save dependency state, and back up the database.</p>
<h3>Treat Warnings as Unproven Conditions</h3>
<p>Not every check can be binary. A warning should clearly identify what still needs review and why automation could not settle it.</p>
<h3>Update the Skill After Every Upgrade</h3>
<p>The knowledge log is what gives the skill compounding returns. Record exact errors and proven solutions while they are fresh.</p>
<h2>Summary</h2>
<p>My custom ABP upgrade skill turns a risky, memory-driven maintenance task into a repeatable workflow:</p>
<ul>
<li>✅ It inventories the full technology stack before changes begin.</li>
<li>✅ It creates code recovery points and requires a separate database backup.</li>
<li>✅ It combines official migration guidance with project-specific knowledge.</li>
<li>✅ It distinguishes dependency, compilation, schema, and runtime validation.</li>
<li>✅ It remembers real failures so future upgrades avoid the same traps.</li>
<li>✅ It keeps destructive and business-critical decisions under deliberate review.</li>
</ul>
<p>The skill does not make framework upgrades effortless, and that is not the goal. It makes them explainable, recoverable, and progressively more predictable.</p>
<p>For a large ABP solution, that is a much more useful kind of automation.</p>
<h2>References</h2>
<ul>
<li><a href="https://abp.io/docs/latest/release-info/migration-guides">ABP Framework migration guides</a></li>
<li><a href="https://abp.io/docs/latest/release-info/release-notes">ABP Framework release notes</a></li>
<li><a href="https://github.com/abpframework/abp">ABP Framework source code</a></li>
<li><a href="https://learn.microsoft.com/ef/core/managing-schemas/migrations/">EF Core migrations overview</a></li>
<li><a href="https://learn.microsoft.com/ef/core/what-is-new/">EF Core breaking changes</a></li>
</ul>
<hr />
<h2>About the Author</h2>
<p><strong>Kori Francis</strong><br />
ABP Framework developer with experience building and operating large .NET solutions, cloud deployments, and project-specific AI engineering workflows.</p>
<p>Connect with me:</p>
<ul>
<li>🐦 Twitter: <a href="https://twitter.com/kfrancis">@kfrancis</a></li>
<li>💼 LinkedIn: <a href="https://linkedin.com/in/korifrancis">Kori Francis</a></li>
</ul>
<hr />
<p><em>Have you built a project-specific workflow for framework upgrades? I would be interested to hear which checks and lessons have saved your team the most time.</em></p>
<p><strong>Tags</strong>: #ABPFramework #DotNet #AI #Automation #EntityFrameworkCore #PowerShell</p>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/api/posts/cover-picture-source/3a22c099-25b5-21f9-5771-76f7a89d6a6d" />
      <media:content url="https://abp.io/api/posts/cover-picture-source/3a22c099-25b5-21f9-5771-76f7a89d6a6d" medium="image" />
    </item>
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/professional-email-delivery-for-abp-applications-with-postmark-integration-gvgc6pfj</guid>
      <link>https://abp.io/community/posts/professional-email-delivery-for-abp-applications-with-postmark-integration-gvgc6pfj</link>
      <a10:author>
        <a10:name>kfrancis@clinicalsupportsystems.com</a10:name>
        <a10:uri>https://abp.io/community/members/kfrancis@clinicalsupportsystems.com</a10:uri>
      </a10:author>
      <category>email-confirmation</category>
      <category>abp</category>
      <category>module-integration</category>
      <category>abpcommunity</category>
      <title>Professional Email Delivery for ABP Applications with Postmark Integration</title>
      <description>Learn how to integrate Postmark - a premium transactional email service with 93.8% deliverability - into your ABP applications using the CommunityAbp.Emailing.Postmark module. This article covers why Postmark outperforms competitors like SendGrid, how to implement templated emails using ABP's ExtraProperties system, and real-world examples for e-commerce order confirmations. Get enterprise-grade email delivery with zero changes to existing ABP email code!</description>
      <pubDate>Mon, 22 Sep 2025 16:42:55 Z</pubDate>
      <a10:updated>2026-10-05T17:23:15Z</a10:updated>
      <content:encoded><![CDATA[<!---
title: "Professional Email Delivery for ABP Applications with Postmark Integration"
author: "Kori Francis"
date: "2025-09-22"
tags: ["ABP Framework", "Postmark", "Email", "Transactional Email", "Templates", "Integration"]
difficulty: "Intermediate"
abp_version: "8.0+"
dotnet_version: ".NET 8+"
article_type: "Tutorial"
estimated_read_time: "12 minutes"
--->
<h1>Professional Email Delivery for ABP Applications with Postmark Integration 📧</h1>
<blockquote>
<p><strong>TL;DR</strong>: Learn how to integrate Postmark - a premium transactional email service - into your ABP applications using the CommunityAbp.Emailing.Postmark module. Get superior deliverability, beautiful templates, and detailed analytics while maintaining ABP's standard email sending interface.</p>
</blockquote>
<h2>Introduction</h2>
<p>Email delivery is critical for any modern application, yet many developers struggle with poor deliverability, spam issues, and complex template management. While services like SendGrid dominate the market through volume, Postmark has carved out a reputation as the <strong>developer-friendly choice</strong> for transactional emails with exceptional deliverability rates.</p>
<p>In this article, you'll discover how to integrate Postmark into your ABP applications using a community-built module that leverages ABP's ExtraProperties system for advanced templating capabilities. You'll learn why Postmark might be a better choice than alternatives, and how to implement professional email delivery with minimal code changes.</p>
<h2>What is Postmark?</h2>
<p><a href="https://postmarkapp.com/">Postmark</a> is a transactional email delivery service built specifically for developers and product teams. Postmark provides a full 45 days of both message events and full content rendering to all accounts at no charge by default, unlike many competitors that charge extra for extended logging.</p>
<h3>Key Postmark Advantages</h3>
<p><strong>Superior Deliverability</strong>: They achieved a remarkable delivery rate of 93.8%! One of the highest we have ever seen. Postmark consistently outperforms competitors in inbox placement.</p>
<p><strong>Developer Experience</strong>: Trying out @postmarkapp for my next react app blog and wow they have much better dev. experience than @SendGrid. Very clear &amp; step-by-step.</p>
<p><strong>Speed</strong>: Password reset emails delivered by @postmarkapp arrive in gmail in 1 second (vs 64 seconds for SendGrid)</p>
<p><strong>Focus on Transactional Email</strong>: Unlike SendGrid which tries to be everything to everyone, Postmark specializes exclusively in transactional emails (password resets, confirmations, receipts, notifications).</p>
<h2>Postmark vs. Popular Alternatives</h2>
<p>| Feature | Postmark | SendGrid | Mailgun | Amazon SES |
|---------|----------|----------|---------|------------|
| <strong>Primary Focus</strong> | Transactional only | Marketing + Transactional | Mixed | Infrastructure service |
| <strong>Deliverability</strong> | 93.8% | Variable | Mixed reviews | Depends on configuration |
| <strong>Developer Experience</strong> | Excellent | Complex | Good | Technical |
| <strong>Setup Complexity</strong> | Simple | Moderate | Moderate | Complex |
| <strong>Template Editor</strong> | Built-in + API | Built-in | Limited | None |
| <strong>Log Retention</strong> | 45 days free | 3 days (30 days paid) | 3 days | Manual setup |
| <strong>Support Quality</strong> | Consistently excellent | Variable by plan | Mixed | Enterprise only |
| <strong>Pricing (10K emails)</strong> | $15/month | ~$15/month | $35/month | ~$1/month* |</p>
<p>*Amazon SES requires additional infrastructure costs</p>
<h3>Why Choose Postmark?</h3>
<p>Issues with missing emails and inconsistent deliverability results are the main reasons why senders are moving away from Mailgun—and we're proud to see that those who switch to Postmark see better, more reliable delivery.</p>
<p>Choose Postmark when you need:</p>
<ul>
<li><strong>Reliable transactional emails</strong> that consistently reach inboxes</li>
<li><strong>Developer-friendly APIs</strong> with excellent documentation</li>
<li><strong>Built-in template management</strong> without external tools</li>
<li><strong>Superior support</strong> that actually helps solve problems</li>
<li><strong>Detailed analytics</strong> without additional cost</li>
</ul>
<h2>Prerequisites</h2>
<p>Before implementing Postmark in your ABP application:</p>
<ul>
<li>[ ] ABP Framework 8.0+ application</li>
<li>[ ] .NET 8+ SDK</li>
<li>[ ] <a href="https://postmarkapp.com/">Postmark account</a> with API key</li>
<li>[ ] Basic understanding of ABP's email system</li>
<li>[ ] (Optional) Postmark templates created</li>
</ul>
<h2>The CommunityAbp.Emailing.Postmark Module</h2>
<p>The <a href="https://github.com/Clinical-Support-Systems/CommunityAbp.Emailing.Postmark">CommunityAbp.Emailing.Postmark</a> module provides seamless integration between ABP Framework and Postmark, extending ABP's standard <code>IEmailSender</code> interface while adding support for advanced features like templated emails.</p>
<h3>Key Features</h3>
<ul>
<li><strong>Drop-in replacement</strong> for ABP's default email sender</li>
<li><strong>Template support</strong> using Postmark's powerful template system</li>
<li><strong>ExtraProperties integration</strong> for passing complex template data</li>
<li><strong>Automatic fallback</strong> when Postmark is disabled</li>
<li><strong>Full compatibility</strong> with existing ABP email code</li>
</ul>
<h2>Installation and Setup</h2>
<h3>Step 1: Install the NuGet Package</h3>
<p>Install the module into your ABP Domain project:</p>
<pre><code class="language-bash">Install-Package CommunityAbp.Emailing.Postmark
</code></pre>
<p>Or using the .NET CLI:</p>
<pre><code class="language-bash">dotnet add package CommunityAbp.Emailing.Postmark
</code></pre>
<h3>Step 2: Add Module Dependency</h3>
<p>Add the module dependency to your Domain module:</p>
<pre><code class="language-csharp">[DependsOn(typeof(AbpPostmarkModule))]
public class YourProjectDomainModule : AbpModule
{
    public override void ConfigureServices(ServiceConfigurationContext context)
    {
        var configuration = context.Services.GetConfiguration();
        
        // Configure Postmark options
        Configure&lt;AbpPostmarkOptions&gt;(options =&gt;
        {
            options.UsePostmark = configuration.GetValue(&quot;Postmark:Enabled&quot;, false);
            options.ApiKey = configuration.GetValue(&quot;Postmark:ApiKey&quot;, string.Empty);
        });
    }
}
</code></pre>
<h3>Step 3: Configuration</h3>
<p>Add Postmark configuration to your <code>appsettings.json</code>:</p>
<pre><code class="language-json">{
  &quot;Postmark&quot;: {
    &quot;Enabled&quot;: true,
    &quot;ApiKey&quot;: &quot;your-postmark-server-api-token-here&quot;
  }
}
</code></pre>
<p><strong>Security Note</strong>: Store your API key securely using:</p>
<ul>
<li>Azure Key Vault for production</li>
<li>User Secrets for development</li>
<li>Environment variables for containers</li>
</ul>
<pre><code class="language-bash"># Development with User Secrets
dotnet user-secrets set &quot;Postmark:ApiKey&quot; &quot;your-api-key-here&quot;
</code></pre>
<h2>Basic Usage</h2>
<p>The beauty of this integration is that it requires <strong>zero changes</strong> to your existing email code. The module transparently replaces ABP's default email sender:</p>
<pre><code class="language-csharp">// Your existing ABP email code continues to work unchanged
public class OrderService : ApplicationService
{
    private readonly IEmailSender _emailSender;
    
    public OrderService(IEmailSender emailSender)
    {
        _emailSender = emailSender;
    }
    
    public async Task SendOrderConfirmationAsync(Order order)
    {
        // This automatically uses Postmark when enabled
        await _emailSender.SendAsync(
            to: order.CustomerEmail,
            subject: &quot;Order Confirmation #&quot; + order.OrderNumber,
            body: BuildOrderEmailBody(order)
        );
    }
}
</code></pre>
<h2>Advanced Usage: Postmark Templates</h2>
<p>Postmark's real power comes from its template system. Templates allow you to:</p>
<ul>
<li><strong>Separate design from code</strong> - Designers manage templates in Postmark's editor</li>
<li><strong>Consistent branding</strong> - Reuse layouts across all emails</li>
<li><strong>Dynamic content</strong> - Inject variables and complex objects</li>
<li><strong>A/B testing</strong> - Test different template versions</li>
<li><strong>Localization</strong> - Create templates for different languages</li>
</ul>
<h3>Understanding Postmark Templates</h3>
<p>Postmark templates use a simple variable syntax:</p>
<pre><code class="language-html">&lt;!-- Template HTML --&gt;
&lt;h1&gt;Welcome, {{name}}!&lt;/h1&gt;
&lt;p&gt;Your order #{{order.number}} for {{order.total}} has been confirmed.&lt;/p&gt;
&lt;p&gt;Shipping to: {{customer.address.street}}, {{customer.address.city}}&lt;/p&gt;
</code></pre>
<p>Templates support:</p>
<ul>
<li><strong>Simple variables</strong>: <code>{{name}}</code>, <code>{{email}}</code></li>
<li><strong>Nested objects</strong>: <code>{{order.total}}</code>, <code>{{customer.address.city}}</code></li>
<li><strong>Arrays</strong>: <code>{{#each items}}{{name}} - {{price}}{{/each}}</code></li>
<li><strong>Conditionals</strong>: <code>{{#if isVip}}VIP benefits apply{{/if}}</code></li>
</ul>
<h3>Creating Templates</h3>
<p>You can create templates via:</p>
<ol>
<li><strong>Postmark Dashboard</strong> - Visual editor with preview</li>
<li><strong>API</strong> - Programmatic template management</li>
<li><strong>Template Management</strong> - Version control and deployment</li>
</ol>
<p>Here's a sample welcome email template:</p>
<pre><code class="language-json">{
  &quot;Name&quot;: &quot;User Welcome&quot;,
  &quot;Subject&quot;: &quot;Welcome to {{company_name}}, {{user_name}}!&quot;,
  &quot;HtmlBody&quot;: &quot;
    &lt;h1&gt;Welcome {{user_name}}!&lt;/h1&gt;
    &lt;p&gt;Thanks for joining {{company_name}}. Get started:&lt;/p&gt;
    &lt;ol&gt;
      &lt;li&gt;&lt;a href='https://github.com/kfrancis/my-abp-articles/blob/main/articles/abp-postmark/{{setup_url}}'&gt;Complete your profile&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href='{{docs_url}}'&gt;Read our getting started guide&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href='{{support_url}}'&gt;Contact support if you need help&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &quot;,
  &quot;TextBody&quot;: &quot;
    Welcome {{user_name}}!
    
    Thanks for joining {{company_name}}. Get started:
    1. Complete your profile: {{setup_url}}
    2. Read our guide: {{docs_url}}  
    3. Contact support: {{support_url}}
  &quot;,
  &quot;TemplateType&quot;: &quot;Standard&quot;
}
</code></pre>
<h2>Using Templates with ABP ExtraProperties</h2>
<p>The module leverages ABP's <code>ExtraProperties</code> system to pass template data to Postmark. This approach maintains clean separation between your business logic and email templates.</p>
<h3>Step 1: Create Template Model</h3>
<pre><code class="language-csharp">public class WelcomeEmailService : ApplicationService
{
    private readonly IEmailSender _emailSender;
    
    public WelcomeEmailService(IEmailSender emailSender)
    {
        _emailSender = emailSender;
    }
    
    public async Task SendWelcomeEmailAsync(User user, string companyName)
    {
        // Build template model with all required data
        var templateModel = new Dictionary&lt;string, object?&gt;
        {
            { &quot;user_name&quot;, user.Name },
            { &quot;user_email&quot;, user.Email },
            { &quot;company_name&quot;, companyName },
            { &quot;setup_url&quot;, &quot;https://yourapp.com/setup&quot; },
            { &quot;docs_url&quot;, &quot;https://yourapp.com/docs&quot; },
            { &quot;support_url&quot;, &quot;https://yourapp.com/support&quot; },
            { &quot;account_created_date&quot;, user.CreationTime.ToString(&quot;MMMM dd, yyyy&quot;) }
        };
        
        // Create ExtraProperties with template information
        var extraProperties = new Dictionary&lt;string, object?&gt;
        {
            { AbpPostmarkConsts.PostmarkTemplateId, 34989179L }, // Your template ID
            { AbpPostmarkConsts.TemplateModel, templateModel }
        };
        
        // Send using template - subject and body are ignored when using templates
        await _emailSender.SendAsync(
            to: user.Email,
            subject: null, // Template defines subject
            body: null,    // Template defines body
            additionalEmailSendingArgs: new AdditionalEmailSendingArgs
            {
                ExtraProperties = new ExtraPropertyDictionary(extraProperties)
            }
        );
    }
}
</code></pre>
<h3>Step 2: Complex Template Data</h3>
<p>For more complex scenarios, you can pass nested objects and arrays:</p>
<pre><code class="language-csharp">public async Task SendOrderSummaryAsync(Order order)
{
    var templateModel = new Dictionary&lt;string, object?&gt;
    {
        { &quot;order_number&quot;, order.Number },
        { &quot;order_date&quot;, order.CreationTime.ToString(&quot;MMMM dd, yyyy&quot;) },
        { &quot;total_amount&quot;, order.TotalAmount.ToString(&quot;C&quot;) },
        { &quot;customer&quot;, new {
            name = order.Customer.Name,
            email = order.Customer.Email,
            address = new {
                street = order.ShippingAddress.Street,
                city = order.ShippingAddress.City,
                state = order.ShippingAddress.State,
                zip = order.ShippingAddress.ZipCode
            }
        }},
        { &quot;items&quot;, order.Items.Select(item =&gt; new {
            name = item.Product.Name,
            quantity = item.Quantity,
            price = item.UnitPrice.ToString(&quot;C&quot;),
            total = (item.Quantity * item.UnitPrice).ToString(&quot;C&quot;)
        }).ToList() },
        { &quot;shipping_method&quot;, order.ShippingMethod },
        { &quot;tracking_url&quot;, $&quot;https://shipping.com/track/{order.TrackingNumber}&quot; }
    };
    
    var extraProperties = new Dictionary&lt;string, object?&gt;
    {
        { AbpPostmarkConsts.PostmarkTemplateId, 87654321L }, // Order summary template
        { AbpPostmarkConsts.TemplateModel, templateModel }
    };
    
    await _emailSender.SendAsync(
        to: order.Customer.Email,
        subject: null,
        body: null,
        additionalEmailSendingArgs: new AdditionalEmailSendingArgs
        {
            ExtraProperties = new ExtraPropertyDictionary(extraProperties)
        }
    );
}
</code></pre>
<h3>Template ID vs. Template Alias</h3>
<p>You can reference templates by ID or alias:</p>
<pre><code class="language-csharp">// Using Template ID (numeric)
{ AbpPostmarkConsts.PostmarkTemplateId, 34989179L }

// Using Template Alias (string) - more maintainable
{ AbpPostmarkConsts.PostmarkTemplateAlias, &quot;welcome-email-v2&quot; }
</code></pre>
<p><strong>Best Practice</strong>: Use template aliases in code for better maintainability. Template IDs change when you create new template versions, but aliases remain constant.</p>
<h2>Real-World Implementation Example</h2>
<h3>E-Commerce Order Confirmation System</h3>
<p>Here's a complete example of a professional order confirmation system:</p>
<pre><code class="language-csharp">public class OrderEmailService : DomainService
{
    private readonly IEmailSender _emailSender;
    private readonly IRepository&lt;Order, Guid&gt; _orderRepository;
    private readonly ISettingProvider _settingProvider;
    
    public OrderEmailService(
        IEmailSender emailSender,
        IRepository&lt;Order, Guid&gt; orderRepository,
        ISettingProvider settingProvider)
    {
        _emailSender = emailSender;
        _orderRepository = orderRepository;
        _settingProvider = settingProvider;
    }
    
    public async Task SendOrderConfirmationAsync(Guid orderId)
    {
        var order = await _orderRepository.GetAsync(orderId, includeDetails: true);
        var companyInfo = await GetCompanyInfoAsync();
        
        var templateModel = new Dictionary&lt;string, object?&gt;
        {
            // Order details
            { &quot;order_number&quot;, order.Number },
            { &quot;order_date&quot;, order.CreationTime.ToString(&quot;MMMM dd, yyyy&quot;) },
            { &quot;subtotal&quot;, order.SubtotalAmount.ToString(&quot;C&quot;) },
            { &quot;tax_amount&quot;, order.TaxAmount.ToString(&quot;C&quot;) },
            { &quot;shipping_cost&quot;, order.ShippingCost.ToString(&quot;C&quot;) },
            { &quot;total_amount&quot;, order.TotalAmount.ToString(&quot;C&quot;) },
            
            // Customer information
            { &quot;customer_name&quot;, order.Customer.FullName },
            { &quot;customer_email&quot;, order.Customer.Email },
            
            // Shipping details
            { &quot;shipping_address&quot;, new {
                name = order.ShippingAddress.FullName,
                street1 = order.ShippingAddress.Street,
                street2 = order.ShippingAddress.Street2,
                city = order.ShippingAddress.City,
                state = order.ShippingAddress.State,
                zip = order.ShippingAddress.ZipCode,
                country = order.ShippingAddress.Country
            }},
            
            // Order items
            { &quot;items&quot;, order.Items.Select(item =&gt; new {
                name = item.Product.Name,
                sku = item.Product.Sku,
                quantity = item.Quantity,
                unit_price = item.UnitPrice.ToString(&quot;C&quot;),
                total_price = (item.Quantity * item.UnitPrice).ToString(&quot;C&quot;),
                image_url = item.Product.ImageUrl
            }).ToList() },
            
            // Company information
            { &quot;company_name&quot;, companyInfo.Name },
            { &quot;company_address&quot;, companyInfo.Address },
            { &quot;support_email&quot;, companyInfo.SupportEmail },
            { &quot;support_phone&quot;, companyInfo.SupportPhone },
            
            // Action URLs
            { &quot;order_status_url&quot;, $&quot;{companyInfo.BaseUrl}/orders/{order.Number}&quot; },
            { &quot;account_url&quot;, $&quot;{companyInfo.BaseUrl}/account&quot; },
            
            // Estimated delivery
            { &quot;estimated_delivery&quot;, order.EstimatedDeliveryDate?.ToString(&quot;MMMM dd, yyyy&quot;) }
        };
        
        var extraProperties = new Dictionary&lt;string, object?&gt;
        {
            { AbpPostmarkConsts.PostmarkTemplateAlias, &quot;order-confirmation&quot; },
            { AbpPostmarkConsts.TemplateModel, templateModel }
        };
        
        await _emailSender.SendAsync(
            to: order.Customer.Email,
            subject: null, // Template defines: &quot;Order Confirmation #{{order_number}}&quot;
            body: null,
            additionalEmailSendingArgs: new AdditionalEmailSendingArgs
            {
                ExtraProperties = new ExtraPropertyDictionary(extraProperties)
            }
        );
    }
    
    private async Task&lt;CompanyInfo&gt; GetCompanyInfoAsync()
    {
        return new CompanyInfo
        {
            Name = await _settingProvider.GetOrNullAsync(&quot;Company.Name&quot;),
            Address = await _settingProvider.GetOrNullAsync(&quot;Company.Address&quot;),
            SupportEmail = await _settingProvider.GetOrNullAsync(&quot;Company.SupportEmail&quot;),
            SupportPhone = await _settingProvider.GetOrNullAsync(&quot;Company.SupportPhone&quot;),
            BaseUrl = await _settingProvider.GetOrNullAsync(&quot;Company.BaseUrl&quot;)
        };
    }
}
</code></pre>
<h2>Integration with ABP Features</h2>
<h3>Multi-Tenancy Support</h3>
<p>The module automatically handles multi-tenancy:</p>
<pre><code class="language-csharp">public class TenantEmailService : ApplicationService
{
    public async Task SendTenantWelcomeAsync(Tenant tenant, User user)
    {
        var templateModel = new Dictionary&lt;string, object?&gt;
        {
            { &quot;user_name&quot;, user.Name },
            { &quot;tenant_name&quot;, tenant.Name },
            { &quot;tenant_url&quot;, $&quot;https://{tenant.Name}.yourapp.com&quot; }
        };
        
        // Template automatically uses tenant-specific settings
        await _emailSender.SendAsync(
            to: user.Email,
            subject: null,
            body: null,
            additionalEmailSendingArgs: new AdditionalEmailSendingArgs
            {
                ExtraProperties = new ExtraPropertyDictionary(new Dictionary&lt;string, object?&gt;
                {
                    { AbpPostmarkConsts.PostmarkTemplateAlias, &quot;tenant-welcome&quot; },
                    { AbpPostmarkConsts.TemplateModel, templateModel }
                })
            }
        );
    }
}
</code></pre>
<h3>Background Jobs Integration</h3>
<p>For high-volume email sending, integrate with ABP's background job system:</p>
<pre><code class="language-csharp">public class SendTemplatedEmailJob : AsyncBackgroundJob&lt;SendTemplatedEmailArgs&gt;, ITransientDependency
{
    private readonly IEmailSender _emailSender;
    
    public SendTemplatedEmailJob(IEmailSender emailSender)
    {
        _emailSender = emailSender;
    }
    
    public override async Task ExecuteAsync(SendTemplatedEmailArgs args)
    {
        var extraProperties = new Dictionary&lt;string, object?&gt;
        {
            { AbpPostmarkConsts.PostmarkTemplateAlias, args.TemplateAlias },
            { AbpPostmarkConsts.TemplateModel, args.TemplateModel }
        };
        
        await _emailSender.SendAsync(
            to: args.ToEmail,
            subject: null,
            body: null,
            additionalEmailSendingArgs: new AdditionalEmailSendingArgs
            {
                ExtraProperties = new ExtraPropertyDictionary(extraProperties)
            }
        );
    }
}

// Usage
public class OrderService : ApplicationService
{
    private readonly IBackgroundJobManager _backgroundJobManager;
    
    public async Task CompleteOrderAsync(Guid orderId)
    {
        // Process order...
        
        // Queue email to be sent in background
        await _backgroundJobManager.EnqueueAsync(new SendTemplatedEmailArgs
        {
            ToEmail = order.Customer.Email,
            TemplateAlias = &quot;order-confirmation&quot;,
            TemplateModel = BuildOrderTemplateModel(order)
        });
    }
}
</code></pre>
<h2>Configuration Options</h2>
<h3>Advanced Configuration</h3>
<pre><code class="language-csharp">Configure&lt;AbpPostmarkOptions&gt;(options =&gt;
{
    options.UsePostmark = true;
    options.ApiKey = configuration[&quot;Postmark:ApiKey&quot;];
    
    // Optional: Configure additional Postmark client options
    options.PostmarkClientOptions = new PostmarkClientOptions
    {
        ApiToken = configuration[&quot;Postmark:ApiKey&quot;],
        RequestTimeout = TimeSpan.FromSeconds(30),
        ApiUrl = &quot;https://api.postmarkapp.com/&quot; // Default value
    };
});
</code></pre>
<h3>Environment-Specific Settings</h3>
<pre><code class="language-json">{
  &quot;Postmark&quot;: {
    &quot;Enabled&quot;: true,
    &quot;ApiKey&quot;: &quot;your-production-api-key&quot;
  }
}
</code></pre>
<pre><code class="language-json">// appsettings.Development.json
{
  &quot;Postmark&quot;: {
    &quot;Enabled&quot;: false,  // Use SMTP for development
    &quot;ApiKey&quot;: &quot;your-test-api-key&quot;
  }
}
</code></pre>
<h2>Testing and Development</h2>
<h3>Local Testing with Papercut</h3>
<p>For local development without sending real emails, use <a href="https://github.com/ChangemakerStudios/Papercut-SMTP">Papercut SMTP</a>:</p>
<pre><code class="language-json">// appsettings.Development.json
{
  &quot;Postmark&quot;: {
    &quot;Enabled&quot;: false  // Falls back to ABP's SMTP sender
  },
  &quot;Settings&quot;: {
    &quot;Abp.Mailing.Smtp.Host&quot;: &quot;localhost&quot;,
    &quot;Abp.Mailing.Smtp.Port&quot;: &quot;25&quot;,
    &quot;Abp.Mailing.DefaultFromAddress&quot;: &quot;noreply@yourapp.com&quot;
  }
}
</code></pre>
<h3>Postmark Sandbox Mode</h3>
<p>Postmark provides a sandbox mode for testing:</p>
<pre><code class="language-csharp">Configure&lt;AbpPostmarkOptions&gt;(options =&gt;
{
    options.ApiKey = &quot;POSTMARK_API_TEST&quot;; // Special test token
    options.UsePostmark = true;
});
</code></pre>
<p>With the test token, emails are processed but not actually delivered, allowing safe testing of templates and integration.</p>
<h2>Troubleshooting</h2>
<h3>Common Issues</h3>
<p><strong>Issue 1: Emails not sending</strong></p>
<ul>
<li><strong>Symptoms</strong>: No emails received, no error messages</li>
<li><strong>Solution</strong>: Check that <code>Postmark:Enabled</code> is <code>true</code> and API key is valid</li>
</ul>
<pre><code class="language-csharp">// Add logging to verify configuration
public override void ConfigureServices(ServiceConfigurationContext context)
{
    var configuration = context.Services.GetConfiguration();
    var enabled = configuration.GetValue(&quot;Postmark:Enabled&quot;, false);
    var apiKey = configuration.GetValue(&quot;Postmark:ApiKey&quot;, string.Empty);
    
    Log.Information(&quot;Postmark Configuration - Enabled: {Enabled}, HasApiKey: {HasApiKey}&quot;, 
        enabled, !string.IsNullOrWhiteSpace(apiKey));
    
    Configure&lt;AbpPostmarkOptions&gt;(options =&gt;
    {
        options.UsePostmark = enabled;
        options.ApiKey = apiKey;
    });
}
</code></pre>
<p><strong>Issue 2: Template not found</strong></p>
<ul>
<li><strong>Error</strong>: <code>&quot;The template 'xyz' is not valid or was not found&quot;</code></li>
<li><strong>Solution</strong>: Verify template ID/alias exists in your Postmark server</li>
</ul>
<p><strong>Issue 3: Template variables not rendering</strong></p>
<ul>
<li><strong>Symptoms</strong>: Emails show <code>{{variable_name}}</code> instead of values</li>
<li><strong>Solution</strong>: Check template model property names match template variables exactly</li>
</ul>
<pre><code class="language-csharp">// Template uses: {{user_name}}
// Model must have: { &quot;user_name&quot;, &quot;John Doe&quot; }
// NOT: { &quot;userName&quot;, &quot;John Doe&quot; } or { &quot;UserName&quot;, &quot;John Doe&quot; }
</code></pre>
<h2>Best Practices</h2>
<h3>Template Management</h3>
<ul>
<li><strong>Use template aliases</strong> instead of IDs for better maintainability</li>
<li><strong>Version your templates</strong> (e.g., &quot;welcome-email-v2&quot;) when making changes</li>
<li><strong>Test templates</strong> with sample data before deploying</li>
<li><strong>Keep templates in source control</strong> using Postmark's API</li>
</ul>
<h3>Performance Optimization</h3>
<ul>
<li><strong>Use background jobs</strong> for non-critical emails</li>
<li><strong>Batch template data preparation</strong> when sending multiple emails</li>
<li><strong>Cache company/system information</strong> used across templates</li>
<li><strong>Implement email preferences</strong> to reduce unnecessary sends</li>
</ul>
<h3>Security Considerations</h3>
<ul>
<li><strong>Store API keys securely</strong> (Key Vault, environment variables)</li>
<li><strong>Validate email addresses</strong> before sending</li>
<li><strong>Implement rate limiting</strong> for user-generated emails</li>
<li><strong>Use template aliases</strong> to prevent template ID enumeration</li>
</ul>
<h2>Monitoring and Analytics</h2>
<p>Postmark provides detailed analytics out of the box:</p>
<ul>
<li><strong>Delivery rates</strong> and bounce tracking</li>
<li><strong>Open and click tracking</strong> for engagement metrics</li>
<li><strong>45-day message retention</strong> for debugging</li>
<li><strong>Real-time webhook notifications</strong> for delivery events</li>
<li><strong>Spam complaint monitoring</strong></li>
</ul>
<p>Access analytics via:</p>
<ul>
<li><strong>Postmark Dashboard</strong> - Visual reports and graphs</li>
<li><strong>API endpoints</strong> - Programmatic access to stats</li>
<li><strong>Webhook integration</strong> - Real-time event notifications</li>
</ul>
<h2>Summary</h2>
<p>You've successfully learned how to integrate professional email delivery into your ABP applications using Postmark:</p>
<ul>
<li>✅ <strong>Superior deliverability</strong> compared to alternatives like SendGrid</li>
<li>✅ <strong>Template-based email system</strong> with dynamic content support</li>
<li>✅ <strong>ABP ExtraProperties integration</strong> for clean template data passing</li>
<li>✅ <strong>Zero changes</strong> to existing ABP email code required</li>
<li>✅ <strong>Professional email templates</strong> with consistent branding</li>
<li>✅ <strong>Detailed analytics and monitoring</strong> built-in</li>
</ul>
<p>The CommunityAbp.Emailing.Postmark module provides a seamless bridge between ABP Framework and Postmark's professional email delivery platform, giving you enterprise-grade email capabilities without the complexity.</p>
<h2>Next Steps</h2>
<p>To further enhance your email system:</p>
<ul>
<li>Explore <a href="https://postmarkapp.com/developer/webhooks/webhooks-overview">Postmark's webhook system</a> for delivery tracking</li>
<li>Implement email preference management for users</li>
<li>Create automated email sequences using ABP background jobs</li>
<li>Set up monitoring and alerting for email delivery issues</li>
<li>Consider implementing A/B testing for email templates</li>
</ul>
<h2>References</h2>
<ul>
<li><a href="https://postmarkapp.com/developer">Postmark Developer Documentation</a></li>
<li><a href="https://github.com/ActiveCampaign/postmark-dotnet">Official Postmark .NET Client</a></li>
<li><a href="https://postmarkapp.com/developer/api/templates-api">Postmark Templates API</a></li>
<li><a href="https://docs.abp.io/en/abp/latest/Emailing">ABP Framework Email Documentation</a></li>
<li><a href="https://github.com/Clinical-Support-Systems/CommunityAbp.Emailing.Postmark">CommunityAbp.Emailing.Postmark Repository</a></li>
</ul>
<h2>Source Code</h2>
<p>The complete CommunityAbp.Emailing.Postmark module source code is available on GitHub:
<a href="https://github.com/Clinical-Support-Systems/CommunityAbp.Emailing.Postmark">https://github.com/Clinical-Support-Systems/CommunityAbp.Emailing.Postmark</a></p>
<hr />
<h2>About the Author</h2>
<p><strong>Kori Francis</strong><br />
ABP Framework developer with expertise in cloud deployments and .NET Aspire orchestration.</p>
<p>Connect with me:</p>
<ul>
<li>🐦 Twitter: <a href="https://twitter.com/kfrancis">@kfrancis</a></li>
<li>💼 LinkedIn: <a href="https://linkedin.com/in/korifrancis">Kori Francis</a></li>
</ul>
<p>Community Projects:</p>
<ul>
<li><a href="https://github.com/Clinical-Support-Systems/CommunityAbp.Hangfire.PasswordAuthorization">Password Autorization for Hangfire</a></li>
<li><a href="https://github.com/kfrancis/CommunityAbp.AspNetZero.Emailing.Postmark">Postmark Integration for AspNetZero</a></li>
<li><a href="https://github.com/Clinical-Support-Systems/CommunityAbp.Emailing.Postmark">Postmark Integration for ABP</a></li>
<li><a href="https://github.com/kfrancis/CommunityAbp.Diagnostics.Logging">ABP Diagnostics Logging</a></li>
<li><a href="https://github.com/kfrancis/CommunityAbp.UserNotifications">WIP General Notifications for ABP</a></li>
</ul>
<hr />
<p><em>Found this solution helpful? Share it with your team! Have questions or improvements? Let's discuss in the comments below.</em></p>
<p><strong>Tags</strong>: #ABPFramework #Postmark #Email #TransactionalEmail #Templates #Integration #DotNet</p>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/images/others/blank-cover-image-150_79.png" />
      <media:content url="https://abp.io/images/others/blank-cover-image-150_79.png" medium="image" />
    </item>
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/universal-redis-configuration-for-abp-applications-with-.net-aspire-support-qp90c7u4</guid>
      <link>https://abp.io/community/posts/universal-redis-configuration-for-abp-applications-with-.net-aspire-support-qp90c7u4</link>
      <a10:author>
        <a10:name>kfrancis@clinicalsupportsystems.com</a10:name>
        <a10:uri>https://abp.io/community/members/kfrancis@clinicalsupportsystems.com</a10:uri>
      </a10:author>
      <category>azure</category>
      <category>deployment</category>
      <category>redis</category>
      <category>abp</category>
      <category>dotnet-aspire</category>
      <title>Universal Redis Configuration for ABP Applications with .NET Aspire Support</title>
      <description>Learn how to configure Redis in ABP applications so they work seamlessly across **all deployment scenarios** - standalone development, .NET Aspire orchestration, and Azure deployments. This universal configuration approach uses a smart fallback chain to automatically detect Redis connections from multiple sources and normalizes them to ABP's expected format. **No more environment-specific code changes** or broken deployments when switching between local development and production!</description>
      <pubDate>Mon, 22 Sep 2025 16:24:56 Z</pubDate>
      <a10:updated>2026-10-05T09:28:58Z</a10:updated>
      <content:encoded><![CDATA[<!--
title: "Universal Redis Configuration for ABP Applications with .NET Aspire Support"
author: "Kori Francis"
date: "2025-09-22"
tags: ["ABP Framework", "ASP.NET Core", ".NET Aspire", "Redis", "Configuration", "Deployment"]
difficulty: "Intermediate"
abp_version: "8.0+"
dotnet_version: ".NET 8+"
article_type: "Best Practices"
estimated_read_time: "8 minutes"
--->
<h1>Universal Redis Configuration for ABP Applications with .NET Aspire Support 🚀</h1>
<blockquote>
<p><strong>TL;DR</strong>: Learn how to configure Redis in ABP applications so they work seamlessly whether running standalone, with .NET Aspire orchestration, or deployed to Azure - all with a single, unified configuration approach.</p>
</blockquote>
<h2>Introduction</h2>
<p>When building ABP applications with .NET Aspire support, one common challenge is making Redis configuration work across different scenarios: local development without Aspire, Aspire orchestration, and cloud deployments. The official ABP Aspire sample works great with Aspire, but breaks when you want to run your application standalone or deploy it to Azure.</p>
<p>In this article, you'll learn how to create a <strong>universal Redis configuration</strong> that automatically adapts to your deployment scenario, eliminating the need for environment-specific code changes.</p>
<h2>The Problem</h2>
<p>The <a href="https://github.com/abpframework/abp-samples/tree/master/AspirationalAbp">official ABP Aspire sample</a> demonstrates Redis integration that only works when using Aspire orchestration:</p>
<pre><code class="language-csharp">// From the official sample - only works with Aspire
builder.AddRedisClient(&quot;redis&quot;);
</code></pre>
<p>This approach has several limitations:</p>
<ul>
<li>❌ <strong>Fails when running standalone</strong> - No Redis connection without Aspire</li>
<li>❌ <strong>Deployment complexity</strong> - Requires different configurations for different environments</li>
<li>❌ <strong>Azure deployment issues</strong> - Doesn't handle Azure Redis connection strings properly</li>
<li>❌ <strong>Developer friction</strong> - Forces developers to use Aspire even for simple local testing</li>
</ul>
<h2>Prerequisites</h2>
<p>Before implementing this solution, ensure you have:</p>
<ul>
<li>[ ] ABP Framework 8.0+ application</li>
<li>[ ] .NET 8+ SDK</li>
<li>[ ] Basic understanding of ABP modules and configuration</li>
<li>[ ] Redis server (local, containerized, or cloud-based)</li>
<li>[ ] (Optional) .NET Aspire for orchestration</li>
</ul>
<h2>The Solution: Universal Redis Configuration</h2>
<h3>Step 1: Smart Redis Client Registration</h3>
<p>First, let's modify the <code>Program.cs</code> to intelligently detect and register Redis based on available configuration:</p>
<pre><code class="language-csharp">// Program.cs - Smart Redis client registration
using Serilog;

var builder = WebApplication.CreateBuilder(args);

// Make Aspire Redis client optional. Only register if any supported configuration value is present.
var configuration = builder.Configuration;
var redisConn =
    configuration[&quot;Redis:Configuration&quot;]
    ?? configuration[&quot;ConnectionStrings:redis&quot;]
    ?? configuration[&quot;ConnectionStrings:Redis&quot;]
    ?? configuration[&quot;Aspire:StackExchange:Redis:ConnectionString&quot;]
    ?? Environment.GetEnvironmentVariable(&quot;ConnectionStrings__redis&quot;)
    ?? Environment.GetEnvironmentVariable(&quot;ConnectionStrings__Redis&quot;)
    ?? Environment.GetEnvironmentVariable(&quot;REDIS_CONNECTION&quot;)
    ?? Environment.GetEnvironmentVariable(&quot;REDIS_URL&quot;);

if (!string.IsNullOrWhiteSpace(redisConn))
{
    builder.AddRedisClient(&quot;redis&quot;, settings =&gt;
    {
        settings.ConnectionString = redisConn;
    });
    Log.Information(&quot;Redis client registered with connection: {ConnectionHint}&quot;, 
        redisConn.Substring(0, Math.Min(20, redisConn.Length)) + &quot;...&quot;);
}
else
{
    Log.Information(&quot;Redis connection not found in configuration. Skipping Aspire Redis client registration.&quot;);
}

// Continue with normal ABP application setup
await builder.AddApplicationAsync&lt;YourWebModule&gt;();
var app = builder.Build();
await app.InitializeApplicationAsync();
await app.RunAsync();
</code></pre>
<h3>Step 2: Configuration Normalization in ABP Module</h3>
<p>Next, update your web module's <code>PreConfigureServices</code> method to normalize Redis configuration:</p>
<pre><code class="language-csharp">// YourWebModule.cs
public override void PreConfigureServices(ServiceConfigurationContext context)
{
    var hostingEnvironment = context.Services.GetHostingEnvironment();
    var configuration = context.Services.GetConfiguration();
    
    // Normalize Redis configuration so ABP Redis modules rely on a single key: Redis:Configuration
    NormalizeRedisConfiguration(configuration);
    
    // ... other pre-configuration
}

private static void NormalizeRedisConfiguration(IConfiguration configuration)
{
    // If Redis:Configuration is already set, we're good to go
    if (!string.IsNullOrWhiteSpace(configuration[&quot;Redis:Configuration&quot;]))
    {
        return;
    }

    // Fallback chain in priority order:
    // 1. ConnectionStrings:Redis / ConnectionStrings:redis (standard .NET / Azure / Aspire)
    // 2. Environment variables
    // 3. Legacy environment variable names
    var redisResolved =
        configuration[&quot;ConnectionStrings:Redis&quot;]
        ?? configuration[&quot;ConnectionStrings:redis&quot;]
        ?? Environment.GetEnvironmentVariable(&quot;ConnectionStrings__Redis&quot;)
        ?? Environment.GetEnvironmentVariable(&quot;ConnectionStrings__redis&quot;)
        ?? Environment.GetEnvironmentVariable(&quot;REDIS_CONNECTION&quot;)
        ?? Environment.GetEnvironmentVariable(&quot;REDIS_URL&quot;);

    if (!string.IsNullOrWhiteSpace(redisResolved))
    {
        // Set the unified key that ABP modules expect
        configuration[&quot;Redis:Configuration&quot;] = redisResolved;
        
        Log.Information(&quot;Redis configuration normalized from fallback source&quot;);
    }
    else
    {
        Log.Warning(&quot;No Redis configuration found in any supported location&quot;);
    }
}
</code></pre>
<h2>Configuration Examples for Different Scenarios</h2>
<h3>Scenario 1: Local Development (Standalone)</h3>
<p><strong>appsettings.Development.json</strong>:</p>
<pre><code class="language-json">{
  &quot;ConnectionStrings&quot;: {
    &quot;redis&quot;: &quot;localhost:6379&quot;
  }
}
</code></pre>
<h3>Scenario 2: .NET Aspire Orchestration</h3>
<p><strong>appsettings.json</strong> (Aspire will inject the connection):</p>
<pre><code class="language-json">{
  &quot;ConnectionStrings&quot;: {
    &quot;redis&quot;: &quot;&quot;
  }
}
</code></pre>
<p><strong>AppHost/Program.cs</strong>:</p>
<pre><code class="language-csharp">var builder = DistributedApplication.CreateBuilder(args);

var redis = builder.AddRedis(&quot;redis&quot;);

builder.AddProject&lt;Projects.YourApp_Web&gt;(&quot;webfrontend&quot;)
    .WithReference(redis);

builder.Build().Run();
</code></pre>
<h3>Scenario 3: Azure Deployment</h3>
<p><strong>Azure App Service Configuration</strong> or <strong>appsettings.Production.json</strong>:</p>
<pre><code class="language-json">{
  &quot;ConnectionStrings&quot;: {
    &quot;Redis&quot;: &quot;your-azure-redis.redis.cache.windows.net:6380,password=your-key,ssl=True&quot;
  }
}
</code></pre>
<h3>Scenario 4: Docker/Container Deployment</h3>
<p><strong>Environment Variables</strong>:</p>
<pre><code class="language-bash">ConnectionStrings__Redis=redis-container:6379
# or
REDIS_CONNECTION=redis-container:6379
# or  
REDIS_URL=redis://redis-container:6379
</code></pre>
<h2>Advanced Configuration Options</h2>
<h3>Custom Redis Configuration Class</h3>
<p>For more complex scenarios, create a dedicated configuration class:</p>
<pre><code class="language-csharp">public class UniversalRedisOptions
{
    public const string SectionName = &quot;UniversalRedis&quot;;
    
    public string ConnectionString { get; set; }
    public bool EnableAspireIntegration { get; set; } = true;
    public bool FailIfNotConfigured { get; set; } = false;
    public string[] FallbackSources { get; set; } = 
    {
        &quot;Redis:Configuration&quot;,
        &quot;ConnectionStrings:Redis&quot;,
        &quot;ConnectionStrings:redis&quot;
    };
}
</code></pre>
<p>Then register and use it:</p>
<pre><code class="language-csharp">public override void PreConfigureServices(ServiceConfigurationContext context)
{
    var configuration = context.Services.GetConfiguration();
    
    Configure&lt;UniversalRedisOptions&gt;(configuration.GetSection(UniversalRedisOptions.SectionName));
    
    var redisOptions = configuration.GetSection(UniversalRedisOptions.SectionName)
        .Get&lt;UniversalRedisOptions&gt;() ?? new UniversalRedisOptions();
        
    NormalizeRedisConfiguration(configuration, redisOptions);
}
</code></pre>
<h2>Troubleshooting</h2>
<h3>Common Issues</h3>
<p><strong>Issue 1: Redis connection fails in standalone mode</strong></p>
<ul>
<li><strong>Symptoms</strong>: Application starts but Redis-dependent features don't work</li>
<li><strong>Cause</strong>: No Redis configuration found</li>
<li><strong>Solution</strong>: Verify one of the supported configuration keys is set</li>
</ul>
<pre><code class="language-bash"># Check your configuration
dotnet user-secrets list
# or check environment variables
printenv | grep -i redis
</code></pre>
<p><strong>Issue 2: Aspire orchestration not working</strong></p>
<ul>
<li><strong>Error Message</strong>: <code>&quot;Unable to resolve service for type 'IConnectionMultiplexer'&quot;</code></li>
<li><strong>Solution</strong>: Ensure the Redis service is properly referenced in your AppHost project</li>
</ul>
<pre><code class="language-csharp">// In AppHost/Program.cs
var redis = builder.AddRedis(&quot;redis&quot;);
builder.AddProject&lt;Projects.YourApp_Web&gt;(&quot;webfrontend&quot;)
    .WithReference(redis); // ← This line is crucial
</code></pre>
<p><strong>Issue 3: Azure deployment Redis SSL issues</strong></p>
<ul>
<li><strong>Error</strong>: SSL/TLS connection errors</li>
<li><strong>Solution</strong>: Ensure your Azure Redis connection string includes SSL settings</li>
</ul>
<pre><code class="language-json">{
  &quot;ConnectionStrings&quot;: {
    &quot;Redis&quot;: &quot;your-cache.redis.cache.windows.net:6380,password=key,ssl=True,abortConnect=False&quot;
  }
}
</code></pre>
<h3>Debug Logging</h3>
<p>Add this to see which Redis configuration is being used:</p>
<pre><code class="language-csharp">public override void PreConfigureServices(ServiceConfigurationContext context)
{
    var configuration = context.Services.GetConfiguration();
    
    // Log all potential Redis configuration sources for debugging
    var sources = new Dictionary&lt;string, string&gt;
    {
        [&quot;Redis:Configuration&quot;] = configuration[&quot;Redis:Configuration&quot;],
        [&quot;ConnectionStrings:Redis&quot;] = configuration[&quot;ConnectionStrings:Redis&quot;], 
        [&quot;ConnectionStrings:redis&quot;] = configuration[&quot;ConnectionStrings:redis&quot;],
        [&quot;ENV ConnectionStrings__Redis&quot;] = Environment.GetEnvironmentVariable(&quot;ConnectionStrings__Redis&quot;),
        [&quot;ENV REDIS_CONNECTION&quot;] = Environment.GetEnvironmentVariable(&quot;REDIS_CONNECTION&quot;)
    };
    
    foreach (var (key, value) in sources.Where(s =&gt; !string.IsNullOrWhiteSpace(s.Value)))
    {
        Log.Information(&quot;Found Redis config source: {Source} = {Value}&quot;, key, 
            value.Substring(0, Math.Min(30, value.Length)) + &quot;...&quot;);
    }
    
    NormalizeRedisConfiguration(configuration);
}
</code></pre>
<h2>Best Practices</h2>
<h3>Performance Considerations</h3>
<ul>
<li><strong>Connection pooling</strong>: The unified approach maintains single connection pool across scenarios</li>
<li><strong>Startup time</strong>: Configuration normalization happens once during startup</li>
<li><strong>Memory usage</strong>: No additional overhead compared to standard ABP Redis usage</li>
</ul>
<h3>Security Best Practices</h3>
<ul>
<li><strong>Connection strings</strong>: Store Redis passwords in secure configuration (Key Vault, user secrets)</li>
<li><strong>SSL/TLS</strong>: Always use SSL in production environments</li>
<li><strong>Network security</strong>: Ensure Redis is not publicly accessible</li>
</ul>
<pre><code class="language-json">{
  &quot;ConnectionStrings&quot;: {
    &quot;Redis&quot;: &quot;your-cache.redis.cache.windows.net:6380,password={from-keyvault},ssl=True&quot;
  }
}
</code></pre>
<h3>Multi-Environment Configuration</h3>
<p>Use configuration transformations for different environments:</p>
<pre><code class="language-json">// appsettings.json (base)
{
  &quot;ConnectionStrings&quot;: {
    &quot;redis&quot;: &quot;localhost:6379&quot;
  }
}

// appsettings.Production.json (override)
{
  &quot;ConnectionStrings&quot;: {
    &quot;Redis&quot;: &quot;{will-be-set-by-deployment}&quot;
  }
}
</code></pre>
<h2>Real-World Example</h2>
<h3>E-Commerce Application Scenario</h3>
<p>Imagine you're building an e-commerce ABP application that uses Redis for:</p>
<ul>
<li>Session storage</li>
<li>Distributed caching</li>
<li>SignalR backplane</li>
<li>Rate limiting</li>
</ul>
<p>With the universal configuration approach:</p>
<pre><code class="language-csharp">// Works in all scenarios without code changes
public class ProductCacheService : ITransientDependency
{
    private readonly IDistributedCache _cache;
    
    public ProductCacheService(IDistributedCache cache)
    {
        _cache = cache; // ABP automatically uses Redis when configured
    }
    
    public async Task&lt;ProductDto&gt; GetCachedProductAsync(Guid productId)
    {
        // This works whether Redis comes from:
        // - Local Redis instance
        // - Aspire orchestration  
        // - Azure Redis Cache
        // - Container deployment
        return await _cache.GetAsync&lt;ProductDto&gt;($&quot;product:{productId}&quot;);
    }
}
</code></pre>
<h2>Integration with ABP Features</h2>
<h3>Distributed Cache Integration</h3>
<p>The universal Redis configuration automatically works with ABP's distributed caching:</p>
<pre><code class="language-csharp">public override void ConfigureServices(ServiceConfigurationContext context)
{
    var configuration = context.Services.GetConfiguration();
    
    // ABP automatically uses Redis when Redis:Configuration is set
    Configure&lt;AbpDistributedCacheOptions&gt;(options =&gt;
    {
        options.KeyPrefix = &quot;MyApp:&quot;;
        options.GlobalCacheEntryOptions.SlidingExpiration = TimeSpan.FromMinutes(20);
    });
}
</code></pre>
<h3>SignalR Integration</h3>
<pre><code class="language-csharp">public override void ConfigureServices(ServiceConfigurationContext context)
{
    context.Services.AddSignalR()
        .AddStackExchangeRedis(); // Uses the same Redis connection
}
</code></pre>
<h2>Comparison with Official Sample</h2>
<p>| Aspect | Official ABP Sample | Universal Configuration |
|--------|-------------------|------------------------|
| <strong>Aspire Support</strong> | ✅ Full support | ✅ Full support |
| <strong>Standalone Running</strong> | ❌ Requires changes | ✅ Works out of box |
| <strong>Azure Deployment</strong> | ❌ Manual configuration | ✅ Automatic detection |
| <strong>Docker Deployment</strong> | ❌ Environment-specific | ✅ Environment variables |
| <strong>Developer Experience</strong> | ⚠️ Context switching needed | ✅ Seamless across scenarios |
| <strong>Configuration Complexity</strong> | ⚠️ Multiple approaches | ✅ Single unified approach |</p>
<h2>Summary</h2>
<p>You've successfully implemented a universal Redis configuration for ABP applications that:</p>
<ul>
<li>✅ <strong>Works with .NET Aspire orchestration</strong> - Full Aspire integration when available</li>
<li>✅ <strong>Supports standalone execution</strong> - No dependency on Aspire for local development</li>
<li>✅ <strong>Handles Azure deployments</strong> - Automatic detection of Azure Redis connection strings</li>
<li>✅ <strong>Supports containerized deployments</strong> - Environment variable configuration</li>
<li>✅ <strong>Maintains single codebase</strong> - No environment-specific code changes needed</li>
<li>✅ <strong>Follows ABP conventions</strong> - Uses standard <code>Redis:Configuration</code> key internally</li>
</ul>
<p>This approach eliminates the friction between development, orchestration, and deployment scenarios while maintaining the full benefits of .NET Aspire when available.</p>
<h2>Next Steps</h2>
<p>To further enhance your ABP + Aspire setup:</p>
<ul>
<li>Implement similar patterns for other services (databases, message queues)</li>
<li>Add health checks for Redis connectivity</li>
<li>Explore ABP's distributed event bus with Redis</li>
<li>Consider implementing Redis Sentinel for high availability</li>
</ul>
<h2>References</h2>
<ul>
<li><a href="https://abp.io/docs/latest/framework/fundamentals/redis-cache">ABP Framework Redis Integration</a></li>
<li><a href="https://learn.microsoft.com/en-us/dotnet/aspire/caching/stackexchange-redis-component">.NET Aspire Redis Component</a></li>
<li><a href="https://github.com/abpframework/abp-samples/tree/master/AspirationalAbp">Official ABP Aspire Sample</a></li>
<li><a href="https://abp.io/docs/latest/framework/fundamentals/caching">ABP Distributed Caching</a></li>
</ul>
<hr />
<h2>About the Author</h2>
<p><strong>Kori Francis</strong><br />
ABP Framework developer with expertise in cloud deployments and .NET Aspire orchestration.</p>
<p>Connect with me:</p>
<ul>
<li>🐦 Twitter: <a href="https://twitter.com/kfrancis">@kfrancis</a></li>
<li>💼 LinkedIn: <a href="https://linkedin.com/in/korifrancis">Kori Francis</a></li>
</ul>
<p>Community Projects:</p>
<ul>
<li><a href="https://github.com/Clinical-Support-Systems/CommunityAbp.Hangfire.PasswordAuthorization">Password Autorization for Hangfire</a></li>
<li><a href="https://github.com/kfrancis/CommunityAbp.AspNetZero.Emailing.Postmark">Postmark Integration for AspNetZero</a></li>
<li><a href="https://github.com/Clinical-Support-Systems/CommunityAbp.Emailing.Postmark">Postmark Integration for ABP</a></li>
<li><a href="https://github.com/kfrancis/CommunityAbp.Diagnostics.Logging">ABP Diagnostics Logging</a></li>
<li><a href="https://github.com/kfrancis/CommunityAbp.UserNotifications">WIP General Notifications for ABP</a></li>
</ul>
<hr />
<p><em>Found this solution helpful? Share it with your team! Have questions or improvements? Let's discuss in the comments below.</em></p>
<p><strong>Tags</strong>: #ABPFramework #NETAspire #Redis #Configuration #Deployment #BestPractices</p>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/images/others/blank-cover-image-150_79.png" />
      <media:content url="https://abp.io/images/others/blank-cover-image-150_79.png" medium="image" />
    </item>
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/how-to-integrate-wpf-template-with-an-abp-solution-including-tests-9qp7ei16</guid>
      <link>https://abp.io/community/posts/how-to-integrate-wpf-template-with-an-abp-solution-including-tests-9qp7ei16</link>
      <a10:author>
        <a10:name>kfrancis@clinicalsupportsystems.com</a10:name>
        <a10:uri>https://abp.io/community/members/kfrancis@clinicalsupportsystems.com</a10:uri>
      </a10:author>
      <category>startup-templates</category>
      <category>wpf</category>
      <title>How to integrate WPF template with an ABP solution including tests</title>
      <description>Like the WPF template but need some direction on how to integrate it into the solution? This article is for you.</description>
      <pubDate>Wed, 06 Apr 2022 19:07:35 Z</pubDate>
      <a10:updated>2026-10-05T14:02:07Z</a10:updated>
      <content:encoded><![CDATA[<h1>How to integrate WPF template with an ABP solution</h1>
<h2>Introduction</h2>
<p>ABP comes with a <a href="https://docs.abp.io/en/abp/latest/Startup-Templates/WPF">WPF template now</a>, but it's a minimal example and doesn't include implementation within a solution nor testing. That's what we'll be discussing in this article.</p>
<h2>Creating the solution</h2>
<blockquote>
<p>If you already have a project, you can skip this section.</p>
</blockquote>
<p>To begin, start by creating a solution using <code>abp new Acme.BookStore</code>. This will create the initial setup, including the tiered layers and the base testing projects we'll need later.</p>
<p>Next, we'll want to create a new solution (somewhere else) using the WPF template by running <code>abp new Acme.BookStore.Wpf -t wpf</code>. When that's complete, move the <code>Acme.BookStore.Wpf</code> into your full solution. It should look like this:</p>
<center>
  <img src="https://raw.githubusercontent.com/kfrancis/abp-wpf/main/images/22-04-msbrw-1649269264.png" alt="solution with wpf project"/>
</center>
<h3>Setting up the Wpf project</h3>
<p>First, you need to add project references to the <code>*.Application</code> and <code>*.EntityFrameworkCore</code> projects. That'll allow this app to interact with the system.</p>
<p>Second, modify the <code>*Module.cs</code> class and add the Application and EntityFrameworkCore modules as dependencies for this module, like so:</p>
<pre><code class="language-c#">[DependsOn(typeof(AbpAutofacModule),
    typeof(BookStoreApplicationModule),                          // &lt;&lt;- add
    typeof(BookStoreEntityFrameworkCoreModule))]                 // &lt;&lt;- add
public class BookStoreWpfModule : AbpModule
{
    public override void ConfigureServices(ServiceConfigurationContext context)
    {
        Configure&lt;AbpAuditingOptions&gt;(options =&gt; options.IsEnabled = false);
        Configure&lt;AbpBackgroundJobOptions&gt;(options =&gt; options.IsJobExecutionEnabled = BackgroundJobConsts.IsEnabled);
        Configure&lt;AbpBackgroundWorkerOptions&gt;(options =&gt; options.IsEnabled = BackgroundJobConsts.IsEnabled);
    }
}
</code></pre>
<h2>Creating the test project</h2>
<p>Go ahead and open the test solution folder and create a new project. It should basically be a copy of the <code>*.Web.Tests</code> project but change <code>Web</code> to <code>Wpf</code></p>
<center>
  <img src="https://raw.githubusercontent.com/kfrancis/abp-wpf/main/images/22-04-d679t-1649269587.png" alt="new wpf test project"/>
</center>
<p>The test project should reference the following packages:</p>
<ul>
<li><a href="https://www.nuget.org/packages/Microsoft.NET.Test.Sdk">Microsoft.NET.Test.Sdk</a></li>
<li><a href="https://www.nuget.org/packages/Abp.TestBase/">Abp.TestBase</a></li>
<li><a href="https://www.nuget.org/packages?q=Volo.Abp.TestBase">Volo.Abp.TestBase</a></li>
<li><a href="https://www.nuget.org/packages/Shouldly/">Shouldly</a></li>
<li><a href="https://www.nuget.org/packages/xunit.runner.visualstudio/">xunit.runner.visualstudio</a></li>
<li><a href="https://www.nuget.org/packages/Xunit.StaFact/">Xunit.StaFact</a></li>
</ul>
<p>The test project should reference the following solution projects:</p>
<ul>
<li>The <code>*.Application.Tests</code> project</li>
<li>The <code>*.Wpf</code> app project</li>
</ul>
<p>Next, we'll need to create the module and wpf test base classes.</p>
<h3>Module Class</h3>
<pre><code class="language-c#">namespace Acme.BookStore;

[DependsOn(
    typeof(BookStoreWpfModule),
    typeof(BookStoreApplicationTestModule)
)]
public class BookStoreWpfTestModule : AbpModule
{
    public override void PreConfigureServices(ServiceConfigurationContext context)
    {

    }

    public override void ConfigureServices(ServiceConfigurationContext context)
    {
        ConfigureLocalizationServices(context.Services);
    }

    private static void ConfigureLocalizationServices(IServiceCollection services)
    {
        var cultures = new List&lt;CultureInfo&gt; { new CultureInfo(&quot;en&quot;), new CultureInfo(&quot;tr&quot;) };
        services.Configure&lt;RequestLocalizationOptions&gt;(options =&gt;
        {
            options.DefaultRequestCulture = new RequestCulture(&quot;en&quot;);
            options.SupportedCultures = cultures;
            options.SupportedUICultures = cultures;
        });

        services.Configure&lt;AbpLocalizationOptions&gt;(options =&gt;
        {
            options.Resources
                .Get&lt;BookStoreResource&gt;()
                .AddBaseTypes(
                    typeof(AbpValidationResource),
                    typeof(AbpUiResource)
                );
        });
    }
}
</code></pre>
<p>The test base class that all tests will inherit.</p>
<pre><code class="language-c#">public abstract class BookStoreWpfTestBase : BookStoreTestBase&lt;BookStoreWpfTestModule&gt;
{
    /// &lt;summary&gt;
    /// Shortcut for adding an NSubstitute.For a type and including the reference as a singleton
    /// &lt;/summary&gt;
    /// &lt;typeparam name=&quot;T&quot;&gt;The interface to substitute&lt;/typeparam&gt;
    /// &lt;param name=&quot;backingStore&quot;&gt;The local backing variable&lt;/param&gt;
    /// &lt;param name=&quot;services&quot;&gt;The service collection to add the singleton to&lt;/param&gt;
    public static void AddTestSubstitution&lt;T&gt;(ref T backingStore, IServiceCollection services)
        where T : class
    {
        backingStore = Substitute.For&lt;T&gt;();
        services.AddSingleton(backingStore);
    }
}
</code></pre>
<p>That should be all you need to start creating tests, however there are a few caveats.</p>
<ol>
<li>Testing WPF requires special threading to be used, which is where the <code>Xunit.StaFact</code> package comes in (<a href="https://github.com/AArnott/Xunit.StaFact">details</a>) but mainly the only difference when writing tests is that you use <code>[UIFact]</code> instead of <code>[Fact]</code> and the package will handle running the tests on an STA thread with the context synchronized.</li>
<li>You're going to want to abstract the Dispatcher pretty quickly. In a WPF app, the dispatcher will be <code>Application.Current.Dispatcher</code> however you don't get access to that when running tests so do the following:</li>
</ol>
<h3>Dispatcher Abstraction</h3>
<p>In the WPF app, implement the types you see I've done <a href="https://github.com/kfrancis/abp-wpf/tree/main/src/Acme.BookStore.Wpf/Core/Threading">here</a> including the <code>WpfDispatcher</code> which you'll use within the app to abstract to <code>Application.Current.Dispatcher</code></p>
<p>Then, in your view models you can just inject <code>IDispatcher dispatcher</code> to run anything on the dispatcher.</p>
<p>In the test project, you can just implement a <code>SimpleDispatcher</code> which runs the code directly:</p>
<pre><code class="language-c#">/// &lt;summary&gt;
/// A basic &lt;see cref=&quot;IDispatcher&quot;/&gt; which simply runs the given code.
/// &lt;/summary&gt;
public class SimpleDispatcher : IDispatcher, ISingletonDependency
{
    /// &lt;inheritdoc/&gt;
    public DispatcherType DispatcherType { get; }

    /// &lt;summary&gt;
    /// Creates a new &lt;see cref=&quot;SimpleDispatcher&quot;/&gt; with the specified flags.
    /// &lt;/summary&gt;
    /// &lt;param name=&quot;dispatcherType&quot;&gt;A set of &lt;see cref=&quot;DispatcherType&quot;/&gt; flags indicating whether this &lt;see cref=&quot;IDispatcher&quot;/&gt; manages special kinds of threads, which can (and should) be utilized in scenarios such as updating UI elements from code.&lt;/param&gt;
    public SimpleDispatcher(DispatcherType dispatcherType = DispatcherType.Main)
    {
        DispatcherType = dispatcherType;
    }

    /// &lt;inheritdoc/&gt;
    public async Task RunAsync(Action execute)
    {
        execute();
    }

    /// &lt;inheritdoc/&gt;
    public async Task&lt;T&gt; RunAsync&lt;T&gt;(Func&lt;T&gt; execute)
    {
        return execute();
    }

    /// &lt;inheritdoc/&gt;
    public async Task RunAsync(Func&lt;Task&gt; execute)
    {
        await execute();
    }

    /// &lt;inheritdoc/&gt;
    public async Task&lt;T&gt; RunAsync&lt;T&gt;(Func&lt;Task&lt;T&gt;&gt; execute)
    {
        return await execute();
    }

    public void Run(Action execute)
    {
        execute();
    }
}
</code></pre>
<p>You can then create tests that look like the following:</p>
<pre><code class="language-c#">public class MainWindow_Tests : BookStoreWpfTestBase
{
    private readonly ILoggerFactory _loggerFactory;
    private readonly IStringLocalizer&lt;BookStoreResource&gt; _localizer;
    private ISnackbarService _snackbarService;
    private ICurrentTenant _fakeCurrentTenant;
    private readonly IDispatcher _dispatcher;
    private MainWindowViewModel _viewModel;

    public MainWindow_Tests()
    {
        _loggerFactory = GetRequiredService&lt;ILoggerFactory&gt;();
        _localizer = GetRequiredService&lt;IStringLocalizer&lt;BookStoreResource&gt;&gt;();
        _snackbarService = GetRequiredService&lt;ISnackbarService&gt;();
        _fakeCurrentTenant = GetRequiredService&lt;ICurrentTenant&gt;();
        _dispatcher = GetRequiredService&lt;IDispatcher&gt;();
        _viewModel = new();
    }

    protected override void AfterAddApplication(IServiceCollection services)
    {
        AddTestSubstitution(ref _fakeCurrentTenant, services);
        AddTestSubstitution(ref _snackbarService, services);
    }

    private void GivenEmptyViewModel()
    {
        _viewModel = new MainWindowViewModel(_dispatcher, _loggerFactory, _localizer, _snackbarService);
    }

    [UIFact]
    public void CanInstantiate()
    {
        GivenEmptyViewModel();

        _viewModel.ShouldSatisfyAllConditions(
            vm =&gt; vm.ShouldNotBeNull(),
            vm =&gt; vm.IsBusy.ShouldBeFalse(),
            vm =&gt; vm.IsNotBusy.ShouldBeTrue()
        );
    }
}
</code></pre>
<h2>MVVM Setup</h2>
<p>I've approached this article and the accompanying sample with MVVM in mind, so in that regard I prefer to utilize the <a href="https://github.com/jamesmontemagno/mvvm-helpers">Refactored.MvvmHelpers</a> as a sort of super light boilerplate reduction, in particular the <code>BaseViewModel</code> though to make it work a little easier here I'm going to be inheriting it and adding some of the AspNetZero mobile bits:</p>
<pre><code class="language-c#">public abstract class AppViewModel : BaseViewModel, ITransientDependency
{
    private readonly ILogger&lt;AppViewModel&gt; _logger;
    private readonly IStringLocalizer&lt;BookStoreResource&gt; _localizer;

    public IStringLocalizer&lt;BookStoreResource&gt; L =&gt; _localizer;

    public List&lt;IDispatcher&gt; Dispatchers { get; }

    protected AppViewModel(ILogger&lt;AppViewModel&gt; logger, IStringLocalizer&lt;BookStoreResource&gt; localizer, IDispatcher dispatcher)
        : this()
    {
        _logger = logger;
        _localizer = localizer;

        Dispatchers = new List&lt;IDispatcher&gt; { dispatcher };
    }

    protected AppViewModel()
    {

    }

    public virtual async Task InitializeAsync(object navigationData)
    {
        await Task.FromResult(false);
    }

    public object GetPropertyValue(string propertyName)
    {
        return GetType().GetProperty(propertyName).GetValue(this, null);
    }

    public T GetPropertyValue&lt;T&gt;(string propertyName)
    {
        return (T)Convert.ChangeType(GetPropertyValue(propertyName), typeof(T));
    }

    public bool LogException(Exception ex, bool shouldCatch = false, bool shouldDisplay = false)
    {
        if (ex == null) return shouldCatch;

        _logger?.LogException(ex.Demystify());
        if (shouldDisplay)
        {
            //Dispatcher.CurrentDispatcher.Invoke(() =&gt;
            //{
            //    _ = Task.Run(() =&gt; _dialogCoordinator.ShowMessageAsync(this, _localizer?[&quot;Error&quot;] ?? &quot;Error&quot;, (_localizer?[&quot;Failed&quot;] ?? &quot;Failed&quot;) + $&quot;: {ex.ToStringDemystified()}&quot;));
            //});
        }

        return shouldCatch;
    }

    public async Task SetBusyAsync(Func&lt;Task&gt; func, string loadingMessage = null, bool showException = true)
    {
        IsBusy = true;
        try
        {
            await func();
        }
        catch (Exception ex) when (LogException(ex, true, showException))
        {
        }
        finally
        {
            IsBusy = false;
        }
    }
}
</code></pre>
<p>An example of a view model for a dialog might be something like this then:</p>
<pre><code class="language-c#">public partial class BookDetailViewModel : AppViewModel
{
    private readonly Func&lt;Task&gt; _closeAction;

    public BookDetailViewModel()
        : base()
    {
        Title = nameof(BookDetailViewModel);
    }

    public BookDetailViewModel(ILogger&lt;AppViewModel&gt; logger,
                               IStringLocalizer&lt;BookStoreResource&gt; localizer,
                               IDispatcher dispatcher,
                               Func&lt;Task&gt; closeAction = null)
        : base(logger, localizer, dispatcher)
    {
        _closeAction = closeAction;
    }

    [ICommand]
    public async Task CloseAsync()
    {
        if (_closeAction != null)
        {
            await _closeAction();
        }
    }
}
</code></pre>
<p>I'm also using the <a href="https://www.nuget.org/packages/CommunityToolkit.Mvvm">CommunityToolkit.Mvvm</a> to further reduce the need to write all the command code.</p>
<h2>Code</h2>
<p>The code for this article and project is available here: https://github.com/kfrancis/abp-wpf where you can see the app looks like this:</p>
<p><img src="https://raw.githubusercontent.com/kfrancis/abp-wpf/main/images/example.png" alt="example" /></p>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/images/others/blank-cover-image-150_79.png" />
      <media:content url="https://abp.io/images/others/blank-cover-image-150_79.png" medium="image" />
    </item>
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/how-to-integrate-the-telerik-ui-for-asp.net-core-kendo-components-with-the-abp-mvc-ui-u2voab2a</guid>
      <link>https://abp.io/community/posts/how-to-integrate-the-telerik-ui-for-asp.net-core-kendo-components-with-the-abp-mvc-ui-u2voab2a</link>
      <a10:author>
        <a10:name>kfrancis@clinicalsupportsystems.com</a10:name>
        <a10:uri>https://abp.io/community/members/kfrancis@clinicalsupportsystems.com</a10:uri>
      </a10:author>
      <category>mvc</category>
      <category>telerik-kendo-ui</category>
      <title>How to Integrate the Telerik UI for ASP.NET Core (Kendo) components with the ABP MVC UI</title>
      <description>https://github.com/kfrancis/telerik-and-abpio/</description>
      <pubDate>Wed, 17 Mar 2021 16:56:10 Z</pubDate>
      <a10:updated>2026-10-05T11:06:15Z</a10:updated>
      <content:encoded><![CDATA[<h1>How to Integrate the Telerik UI for ASP.NET Core (Kendo) components with the ABP MVC UI?</h1>
<h2>Introduction</h2>
<p>Hi, in this step by step article, we will see how we can integrate the Telerik UI for ASP.NET Core (Kendo) components with our Abp MVC app.</p>
<h2>Creating the Solution</h2>
<blockquote>
<p>ABP Framework offers startup templates to get into business faster.</p>
</blockquote>
<p>In this article, I will create a new startup template with EF Core as a database provider and MVC for UI framework. But if you already have a project with MVC UI, you don't need to create a new startup template, you can directly implement the following steps to your existing project.</p>
<blockquote>
<p>If you already have a project with the MVC UI, you can skip this section.</p>
</blockquote>
<ul>
<li>Before starting to development, we will create a solution named <code>TelerikComponents</code> (or whatever you want). We will create a new startup template with EF Core as a database provider and MVC for UI framework by using <a href="https://docs.abp.io/en/abp/latest/CLI">ABP CLI</a>:</li>
</ul>
<pre><code class="language-bash">abp new TelerikComponents --ui mvc --database-provider ef
</code></pre>
<ul>
<li>Our project boilerplate will be ready after the download is finished. Then, we can open the solution in the Visual Studio (or any other IDE) and run the <code>TelerikComponents.DbMigrator</code> to create the database and seed initial data (which creates the admin user, admin role, permissions, etc.). I prefer changing the db connection string in the DbMigrator project to <code>(localdb)\\mssqllocaldb</code> to be able to just run right away.</li>
</ul>
<pre><code class="language-json">&quot;ConnectionStrings&quot;: {
    &quot;Default&quot;: &quot;Server=(localdb)\\mssqllocaldb;Database=TelerikComponents;Trusted_Connection=True&quot;
  },
</code></pre>
<ul>
<li>After the database and initial data created,</li>
<li>Run the <code>TelerikComponents.Web</code> project to see our UI working.</li>
</ul>
<blockquote>
<p><em>Default login credentials for admin: username is <strong>admin</strong> and password is <strong>1q2w3E*</strong></em></p>
</blockquote>
<h2>Implementation</h2>
<h3>Pre-requisite</h3>
<ul>
<li><p>First thing we need to do is downloading the <a href="https://www.telerik.com/download-trial-file/v2/control-panel?_ga=2.212029332.1667119438.1607582144-1944255175.1605161949">Progress Control Panel</a> to get Telerik Kendo components on our development machine.</p>
</li>
<li><p>If you are using Telerik UI for ASP.NET Core components for the first time or you don't have an active license you can click <a href="https://www.telerik.com/login/v2/download-b?ReturnUrl=https%3a%2f%2fwww.telerik.com%2fdownload-trial-file%2fv2-b%2fui-for-blazor%3f_ga%3d2.212029332.1667119438.1607582144-1944255175.1605161949#register">here</a> to download free trial.</p>
</li>
</ul>
<blockquote>
<p>You can find the more installation details from <a href="https://docs.telerik.com/aspnet-core/getting-started/first-steps">here</a>.</p>
</blockquote>
<h3>Step 1 (Configuration)</h3>
<ul>
<li>We need to install the <code>Telerik.UI.for.AspNet.Core</code> NuGet package to our web project (<code>*.Web</code>). We need to choose the Telerik feed package source to see the package.</li>
<li>If you're using the trial, install <code>Telerik.UI.for.AspNet.Core.Trial</code> package via NuGet.</li>
</ul>
<p><img src="https://raw.githubusercontent.com/kfrancis/telerik-and-abpio/main/screen1.png" alt="files we need to modify/add" /></p>
<ul>
<li>In package.json, add the kendo script component as a dependency. This will get the scripts in your <code>node_modules</code> directory. A note, the npm package from Telerik needs to be transpiled to be able to be used so we're basically following their npm+webpack documentation <a href="https://docs.telerik.com/aspnet-core/installation/npm">here</a> with a few adjustments for abp.io setup:</li>
</ul>
<pre><code class="language-json">{
    {
        &quot;version&quot;: &quot;1.0.0&quot;,
        &quot;name&quot;: &quot;my-app&quot;,
        &quot;private&quot;: true,
        &quot;main&quot;: &quot;main.js&quot;,
        &quot;devDependencies&quot;: {
        &quot;webpack&quot;: &quot;^5.26.3&quot;,
        &quot;webpack-cli&quot;: &quot;^4.5.0&quot;
        },
        &quot;dependencies&quot;: {
        &quot;@volo/abp.aspnetcore.mvc.ui.theme.lepton&quot;: &quot;^4.2.2&quot;,
        &quot;@volo/account&quot;: &quot;^4.2.2&quot;,
        &quot;@volo/audit-logging&quot;: &quot;^4.2.2&quot;,
        &quot;@volo/identity&quot;: &quot;^4.2.2&quot;,
        &quot;@volo/saas&quot;: &quot;^4.2.2&quot;,
        &quot;@progress/kendo-theme-bootstrap&quot;: &quot;4.33.0&quot;,
        &quot;@progress/kendo-ui&quot;: &quot;2021.1.225&quot;,
        &quot;css-loader&quot;: &quot;^5.1.3&quot;,
        &quot;expose-loader&quot;: &quot;^2.0.0&quot;,
        &quot;style-loader&quot;: &quot;^2.0.0&quot;
        },
        &quot;scripts&quot;: {
        &quot;build&quot;: &quot;webpack&quot;
        }
    }
}
</code></pre>
<p>Create a <code>main.js</code> in the root directory, this is what webpack will use to know what to include in the <a href="https://scotch.io/tutorials/javascript-transpilers-what-they-are-why-we-need-them">transpiled</a> js bundle.</p>
<pre><code class="language-javascript">import $ from 'jquery';
window.jQuery = $; window.$ = $;

import &quot;@progress/kendo-ui&quot;;
import &quot;@progress/kendo-ui/js/kendo.aspnetmvc&quot;;
import &quot;@progress/kendo-ui/js/kendo.timezones&quot;;
import &quot;@progress/kendo-theme-bootstrap/dist/all.css&quot;;
</code></pre>
<p>Finally, add a <code>webpack.config.js</code> file to the root directory of your web project with the following content:</p>
<pre><code class="language-javascript">&quot;use strict&quot;
{
    const path = require('path');
    const webpack = require('webpack');

    module.exports = {
        entry: './main.js',
        output: {
            filename: 'kendo-bundle.js',
            path: path.resolve(__dirname, 'wwwroot/libs/kendo/dist')
        },
        module: {
            rules: [
                {
                    test: /\.css$/,
                    use: [{ loader: 'style-loader' }, { loader: 'css-loader' }]
                },
                {
                    test: /jquery.+\.js$/,
                    use: [{
                        loader: 'expose-loader',
                        options: 'jQuery'
                    }, {
                        loader: 'expose-loader',
                        options: '$'
                    }]
                }
            ]
        },
        externals: {
            jquery: 'jQuery'
        }
    }
}
</code></pre>
<p>What this will do is create a single js file that contains all of kendo, transpiled so you don't get 'module' errors. The use of <code>expose-loader</code>, <code>externals jquery</code> and the jquery rules are all there so that abp.io scripts, the way jquery is added and how kendo's js generally includes jquery doesn't conflict with each other.</p>
<ul>
<li>In the <code>abp.resourcemapping.js</code> file, add the necessary entries that look like the following:</li>
</ul>
<pre><code class="language-javascript">module.exports = {
    aliases: {
    },
    mappings: {
        &quot;@node_modules/@progress/kendo-ui/css/**/*&quot;: &quot;@libs/kendo/css&quot;,
        &quot;@node_modules/@progress/kendo-ui/js/**/*&quot;: &quot;@libs/kendo/js&quot;
    }
};
</code></pre>
<p>This will ensure the scripts and styles we need are packed up and in the right place for the next steps.</p>
<ul>
<li><p>Run <code>yarn</code> (to get the package)</p>
</li>
<li><p>Run <code>gulp</code> (to execute the mapping)</p>
</li>
<li><p>Run <code>npm build</code> (to execute the conversion of the js from Telerik into something usable here)</p>
</li>
<li><p>In the <code>TelerikComponents.Web</code> project, under <code>/Bundling</code>, create a new directory we'll call <code>Kendo</code>- i.e. <code>/Bundling/Kendo</code></p>
</li>
</ul>
<p><img src="https://raw.githubusercontent.com/kfrancis/telerik-and-abpio/main/screen2.png" alt="Adding in components/bundles for abp" /></p>
<ul>
<li>In <code>/Bundling/Kendo</code>, create the class file <code>KendoScriptContributer.cs</code> with the following content:</li>
</ul>
<pre><code class="language-csharp">namespace TelerikComponents.Web.Bundling
{
    [DependsOn(
        typeof(JQueryScriptContributor)
        )]
    public class KendoScriptContributor : BundleContributor
    {
        public override void ConfigureBundle(BundleConfigurationContext context)
        {
            context.Files.AddIfNotContains(&quot;/libs/kendo/dist/kendo-bundle.js&quot;); // This is the output of our webpack step
        }
    }
}
</code></pre>
<ul>
<li>In <code>/Bundling/Kendo</code>, create the class file <code>KendoStyleContributer.cs</code> with the following content:</li>
</ul>
<pre><code class="language-csharp">namespace TelerikComponents.Web.Bundling
{
  public class KendoStyleContributor : BundleContributor
  {
      public override void ConfigureBundle(BundleConfigurationContext context)
      {
          context.Files.AddIfNotContains(&quot;/libs/kendo/css/web/kendo.common-bootstrap.min.css&quot;);
          context.Files.AddIfNotContains(&quot;/libs/kendo/css/web/kendo.bootstrap-v4.min.css&quot;);
      }
  }
}
</code></pre>
<ul>
<li>In the <code>TelerikComponents.Web</code> project, under <code>/Components</code>, create a new directory we'll call <code>Kendo</code> - i.e. <code>/Components/Kendo</code></li>
<li>Create a <code>Default.cshtml</code> with the following content:</li>
</ul>
<pre><code class="language-html">@using TelerikComponents.Web.Bundling.Kendo 
@addTagHelper *, Volo.Abp.AspNetCore.Mvc.UI.Bundling

&lt;!-- Kendo --&gt;
&lt;abp-script type=&quot;typeof(KendoScriptContributor)&quot; /&gt;
</code></pre>
<ul>
<li>Create a KendoViewComponent.cs with the following content:</li>
</ul>
<pre><code class="language-csharp">public class KendoViewComponent : AbpViewComponent
{
    public IViewComponentResult Invoke()
    {
        return View(&quot;/Components/Kendo/Default.cshtml&quot;);
    }
}
</code></pre>
<ul>
<li>In <code>/Pages/_ViewImports.cshtml</code> add the tag helper:</li>
</ul>
<pre><code class="language-csharp">@using Kendo.Mvc.UI
@addTagHelper &quot;*, Kendo.Mvc&quot;
</code></pre>
<ul>
<li>Finally, in your <code>TelerikComponentsWebModule.cs</code> file - let's add the following bits:
<ul>
<li>In <code>ConfigureServices</code>, add <code>ConfigureKendo(context.Services);</code>. The method implementation looks simply like this:</li>
</ul>
</li>
</ul>
<pre><code class="language-csharp">private void ConfigureKendo(IServiceCollection services)
{
    services.AddKendo();
}
</code></pre>
<ul>
<li>Find the area <code>Configure&lt;AbpBundlingOptions&gt;</code> and after the <code>Global</code> add, also add our style contributor <code>.AddContributors(typeof(KendoStyleContributor))</code>:</li>
</ul>
<pre><code class="language-csharp">Configure&lt;AbpBundlingOptions&gt;(options =&gt;
{
    options
        .StyleBundles
        .Get(StandardBundles.Styles.Global)
        .AddContributors(typeof(KendoStyleContributor)); // add this
});
</code></pre>
<p>If you don't see this section, add it to <code>ConfigureServices()</code>.
* Add a new configure to setup a <a href="https://docs.abp.io/en/abp/latest/UI/AspNetCore/Layout-Hooks">layout hook</a>, where we add the kendo scripts to the bottom of the page:</p>
<pre><code class="language-csharp">Configure&lt;AbpLayoutHookOptions&gt;(options =&gt;
{
    options.Add(
        LayoutHooks.Head.Last, //The hook name
        typeof(KendoViewComponent) //The component to add
    );
});
</code></pre>
<h3>Step 2 - Checking the Setup</h3>
<p>If we've done everything right, then we should now be able to use the components. On any page, you should now be able to use either the tag helpers or razor syntax:</p>
<pre><code class="language-html">&lt;kendo-numerictextbox name=&quot;currency&quot; format=&quot;c&quot; min=&quot;0&quot;
      enable=&quot;true&quot; max=&quot;100&quot; value=&quot;30&quot;&gt;
&lt;/kendo-numerictextbox&gt;
</code></pre>
<pre><code class="language-html">@(Html.Kendo().NumericTextBox()
      .Name(&quot;currency&quot;)
      .Format(&quot;c&quot;)
      .Min(0) // Set the min value of the NumericTextBox.
      .Max(100) // Set the min value of the NumericTextBox.
      .Value(30) // Set the value of the NumericTextBox.
)
</code></pre>
<h1>Done!</h1>
<p><img src="https://raw.githubusercontent.com/kfrancis/telerik-and-abpio/main/numkendo.gif" alt="kendo wee" /></p>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/api/posts/cover-picture-source/1a69229a-1951-6b2b-5f0a-39fb534a9355" />
      <media:content url="https://abp.io/api/posts/cover-picture-source/1a69229a-1951-6b2b-5f0a-39fb534a9355" medium="image" />
    </item>
  </channel>
</rss>