Skip to content

Replace Built-in Services

You want to change some behavior of the kernel — swap the password-hashing algorithm, slot a step into the login flow, replace the built-in dictionary module wholesale with your own. This page lays out which routes there are and how to take each one. No fork, no copying kernel code.

How big your change is decides which route you take:

  • Replace an entire service implementation (say, PBKDF2 → argon2, or the in-process cache → Redis) → register your own implementation ahead of AddSmartAdmin().
  • Change just one step in a flow (log something extra after login, route credential checks through LDAP) → subclass the built-in service and override that one virtual method.
  • Drop a whole built-in module and take it over yourself (the dictionary module's API doesn't fit at all) → disable its controller and claim the same route with your own controller.

And one related thing: seeding your own business tables with initial data, via consumer seeds. All four are below.

This page covers how to replace; why these routes work at all (the three constraints — TryAdd first-registration-wins, the virtual step split, and assembly mounting — plus the replaceability contract, ReplaceabilityTests, that locks them in) is in The Replaceability Model.

Replace an entire service: register ahead of AddSmartAdmin

Every built-in service in the kernel is registered with TryAdd* — meaning "if the container already has this interface, don't add it." So all you do is register your own implementation before AddSmartAdmin(), and when the kernel's TryAdd sees the slot already taken, it steps aside automatically.

Take swapping the password-hashing algorithm:

csharp
// Consumer Program.cs
public sealed class Argon2PasswordHasher : IPasswordHasher
{
    public string Hash(string password) => /* your algorithm */;
    public bool Verify(string password, string hashedPassword) => /* your verification */;
}

// Register your own first — claim the interface
builder.Services.AddSingleton<IPasswordHasher, Argon2PasswordHasher>();
// Then call the kernel — TryAdd sees the existing registration and skips the built-in Pbkdf2PasswordHasher
builder.Services.AddSmartAdmin(builder.Configuration);

The wrong order fails silently

Put it after AddSmartAdmin() and the built-in implementation has already taken the slot, so your TryAdd is skipped — no error, but the replacement doesn't take. To be order-independent, use builder.Services.Replace(ServiceDescriptor.Scoped<IAuthService, MyAuthService>()), which "overrides an existing registration" and wins even when written after AddSmartAdmin().

Common replacement points:

InterfaceDefault implementationWhen to swap
IPasswordHasherPbkdf2PasswordHasherFor bcrypt / argon2
ICacheProviderMemoryCacheProviderFor Redis (the SmartAdmin.Caching.Redis package is exactly this pattern)
IFileStorageLocalFileStorageFor OSS / S3
IAuthServiceAuthServiceTo customize the whole login flow
IDataScopeProviderDataScopeProviderTo customize data-scope rules
IIdGeneratorSnowflakeIdGeneratorFor DB auto-increment / GUID v7

Most replacement points are every TryAdd line in backend/src/SmartAdmin.Services/ServicesSetup.cs. The data layer and host layer each have their own batch too — IIdGenerator, for instance, is registered in backend/src/SmartAdmin.SqlSugar/SqlSugarSetup.cs. Every interface registered in any of these is a replacement point.

Change just one step: subclass and override a virtual

Replacing the whole service means re-injecting all of its dependencies, and most of the time you don't want to change that much. The kernel splits its long methods into a handful of small protected virtual steps (the template method); you subclass and override only the step you want to change, and the rest runs the base class as-is.

AuthService.LoginAsync is the template (backend/src/SmartAdmin.Services/Auth/AuthService.cs): it just orchestrates a handful of virtual steps — failed-attempt lockout check → captcha → ValidateUserAsync credential check → disabled/locked policy → password expiry → CheckSmsSecondFactorAsync SMS second factor → token issuance → OnLoginSucceededAsync success hook → BuildLoginOutput output assembly. To wire in LDAP, override only ValidateUserAsync; to add a field to the login response, override only BuildLoginOutput; to make MFA mandatory even for phone-less users, override only CheckSmsSecondFactorAsync (the kernel default skips them — see SMS verification):

csharp
// Change only the output-assembly step; the rest of the login logic (captcha/lockout/credential check/token issuance) runs the base class as-is
public sealed class MyAuthService(
    IRepository<SysUser> users, IPasswordHasher hasher, ITokenProvider tokens,
    ISessionService sessions, ILogService logService, ILoginLockService loginLock,
    ICaptchaService captcha, ISecurityPolicyProvider policy, ISmsOtpService smsOtp)
    : AuthService(users, hasher, tokens, sessions, logService, loginLock, captcha, policy, smsOtp)
{
    protected override LoginOutput BuildLoginOutput(SysUser user, TokenPair pair) =>
        base.BuildLoginOutput(user, pair) with { Name = $"{user.Name}({user.Account})" };
}

// Register with Replace, order-independent
builder.Services.AddSmartAdmin(builder.Configuration);
builder.Services.Replace(ServiceDescriptor.Scoped<IAuthService, MyAuthService>());

To find overridable steps, open the target service's source and search protected virtual — those methods are the openings left for you. When overriding, call base.Xxx() first to keep the original logic, then append your own. The payoff of overriding one step instead of copying the whole block: on a kernel upgrade you automatically pick up upstream fixes to that base step, rather than missing them because you copied an old version of the method body.

Drop a whole module: disable + take over the route

If a built-in module's controller doesn't fit at all, you can lift it out wholesale and claim the same route with your own controller. Disabling goes through Api.DisabledModules:

csharp
builder.Services.AddSmartAdmin(builder.Configuration, o =>
{
    o.ApplicationAssemblies.Add(typeof(Program).Assembly);   // mount your business assembly (see below)
    o.Api.DisabledModules = ["Dict"];   // can also go through config SmartAdmin:Api:DisabledModules
});

The disabled controller's routes are no longer registered, the original endpoints return 404, and your same-route controller can take over:

csharp
[ApiController]
[Route("api/v1/sys/dict")]   // same route as the disabled built-in DictController
public class CustomDictController : ControllerBase { /* your dictionary logic */ }

If the endpoints you take over no longer match what the built-in page calls, the frontend page has to go too. Create src/views/system/dict/index.vue in your app: it has the same page key as the built-in dictionary page, so it takes that page's place. The details are in The Frontend Template.

Only controllers annotated with [Module("Name")] can be disabled — the annotation on each controller's source is the source of truth, and today that's twelve: Cache, Config, Dashboard, Dict, ExternalAuth, Job, Meta, Monitor, Notice, RecycleBin, Upload (literally what's disabled is the file controller /api/v1/sys/file — the module name isn't the route), Log. The auth, user, org, role, menu, and portal controllers don't carry this annotation — there's no switch to turn them off, because turning them off would lock everyone out of the whole system.

Don't confuse Api.DisabledModules with the portal's "apps/modules" (the SysModule table): the former is a build-time route switch, the latter is runtime data. The latter has its own guardrails — the built-in system app hosts all the admin pages, and disabling it through the management API is refused (error code 42013 — the portal would be cut off with no UI path to recover); an app that still has menus attached can't be deleted (42023, or those top-level directories' ModuleId would dangle and the whole subtree would vanish from the portal).

Seed your own entities: consumer seeds

Your business tables can also carry initial data that's inserted automatically on first startup and idempotent on repeat startups. Implement the generic ISeedData<TEntity> (the non-generic ISeedData is just an empty marker for DI collection — don't implement it directly):

csharp
public class ProductSeed : ISeedData<BizProduct>
{
    public IEnumerable<BizProduct> HasData() =>
    [
        new() { Id = SmartSeedIds.ConsumerMin, Name = "Default product", Code = "default", Sort = 0, Enabled = true },
    ];
}

// Register in your own Program.cs
builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<ISeedData, ProductSeed>());

A seed row's fixed Id must fall within the consumer-reserved range Id >= 1000 (the lower bound is the constant SmartAdmin.Core.SmartSeedIds.ConsumerMin; [1, 999] belongs to the kernel's built-in seeds). The ceiling isn't a hardcoded number — it's the live snowflake floor computed at startup (SnowflakeIdGenerator.CurrentFloor()): stay strictly below it and this Id can never collide with a snowflake Id this instance generates from now on, and today that floor is already a 15-digit number, plenty of room for any semantic numbering scheme. Id = 0, or anything at or above that floor, is rejected on the spot by the startup check (DatabaseInitializer), and the app won't start rather than swallowing it silently. Landing inside the kernel's own [1, 999] range, though, doesn't throw — the runtime has no way to tell a consumer's seed from the kernel's, so that lower bound is on the honor system alone. Always pick numbers starting from SmartSeedIds.ConsumerMin; collide with a number the kernel claims later and the cost is a primary-key conflict on upgrade, with no way back.

Forgetting to register means it silently never runs

The kernel doesn't scan assemblies for seeds (options.ApplicationAssemblies only handles entity table creation and controller mounting — it doesn't touch seeds). Seeds must be registered explicitly; miss this line and the seed doesn't run, with no error either.

That ApplicationAssemblies line is the master switch for consumer wiring: it both joins your entities into CodeFirst table creation and brings your controllers into the same MVC pipeline — for the full chain, see Add a Business Module. Before you actually start replacing anything, take a look at backend/tests/SmartAdmin.Tests/ReplaceabilityTests.cs — it verifies each of the mechanisms above as a contract; following its shape to add a regression test around your own replacement is the safest bet.

Released under the Apache License 2.0