Backend Standards (.NET 10 kernel)
Check your work against this list before and after touching backend code — every item is a hard rule already implemented in the kernel. To see why a rule is what it is, follow its link into the corresponding deep-dive; for fuller positive/negative examples, see docs/coding-standards.md in the repo.
First principle
The kernel ships as NuGet packages, so a consumer can replace any part without touching the source. Any newly added replaceable service is registered with TryAdd*, backed by an interface, and split into virtual steps — this is a hard constraint, not a suggestion. See the replaceability model for the mechanism.
Where things go across layers
- Dependencies point downward only; skipping layers is forbidden:
Core(contracts) ←SqlSugar(data) ←Services(domain + entities) ←AspNetCore(host) ←SmartAdmin(meta-package). Decide which layer new code belongs to first. See architecture layering for the full picture. - Runtime dependencies are only SqlSugarCore + Microsoft.* — core packages pull in no other third-party framework.
- Entities live in the
Serviceslayer, not theSqlSugarlayer. - Each layer's wiring is centralized in one
*Setup.cs(SqlSugarSetup→ServicesSetup→SmartAdminSetup, the composition root) — no scattered registrations.
Replaceability (locked by ReplaceabilityTests — treat it as a contract)
- All built-in replaceable services use
TryAdd*; bareAdd*is forbidden. A consumer registering the same interface beforeAddSmartAdmin()wins. - Long methods are split into small
virtualsteps (e.g.SessionService.EnforceConcurrencyAsync) so a consumer overrides one step instead of copying the whole method. - Every service has an
I*Servicefirst, with avirtualimplementation class. - When touching entity scanning or controller registration, preserve the
options.ApplicationAssembliesmounting path (business entities' table creation, controllers'AddApplicationPart), or consumer modules fail silently. See the replaceability model for how.
Entities
- Defined under
Services/Entities/; kernel system tables are namedSys*. - Pick a base class: ordinary tables inherit
BaseEntity(primary key + the four audit fields + soft delete); tables that need org-level isolation inheritDataEntity(which also carries theCreateOrgIdanchor — see "Data access" below for what that means). - The primary key is always
Id(snowflake, filled by AOP, never assigned by hand); soft delete is alwaysIsDelete, and querying deleted rows needs an explicit.ClearFilter<ISoftDelete>(). - Extra information not reserved in the table schema goes into
ExtJson— don't add a new column. - Follow
Entities/SysDictType.csfor attributes:[SugarTable]/ unique index[SugarIndex(IsUnique=true)]/[SugarColumn(Length, ColumnDescription, IsNullable)]. - Write immutability conventions (e.g. "Code is immutable after creation") into comments and enforce them in the Service's Update (don't touch that field). See data layer & auditing for the fields and the audit mechanism.
Services
- One directory per service:
I{X}Service.cs+{X}Service.cs+{X}Models.cs(DTOs arerecords, named{X}Input/{X}PageInput/{X}Output). - Inject dependencies via the primary constructor; methods are
virtual; async methods carry theAsyncsuffix and take aCancellationTokenon hot paths. - Pagination is always
PagedList<T>+.ToPagedListAsync(current, size). - Validate with
AdminException.ThrowIf(condition, ErrorCode.X), not hand-written if-throw.
Controllers
[RolePermission]takes no argument: the permission code IS{METHOD}:/{route template}(e.g.GET:/api/v1/sys/dict/type/page). Never write magic strings like"sys:user:add"in code — permissions are granted by checking routes in the role-menu UI; super admin (sadm) is let through.- Use
[ActiveSession]for logged-in-only endpoints that need no specific permission; mark anonymous endpoints with an explicit[AllowAnonymous](the globalRequireAuthorization()is the fallback, so a forgotten attribute never silently exposes an endpoint). - Attach
[OperationLog(...)]to write operations that need auditing; add[Module("X")]to make a whole module switchable off —Api:DisabledModulesstrips the controller's routes (404, data untouched). The module record itself carries two separate delete guards: one with menus attached is always refused (ErrorCode.ModuleHasMenus, applies to self-built modules too), and the built-insystemmodule is permanently protected by a fixed Id (ErrorCode.ModuleProtected). - Controllers may
return dtodirectly (ResultEnvelopeFilterwraps the envelope as a fallback); built-in controllers returnResult<T>.Ok(...)explicitly for a clear contract. SeeControllers/DictController.csfor reference and the request pipeline for the flow.
Error handling
- Business errors are thrown as
AdminException(ErrorCode)or returned as anErrorCode, uniformly converted into an envelope byAdminExceptionFilter. ErrorCodeis a numeric enum that never carries localized text (Core/ErrorCode.cs); i18n happens entirely on the frontend, keyed bymsgKey. Adding an error code means adding both an[MsgKey("error.<module>.<meaning>")]attribute (e.g.error.dict.typeNotFound) and the matching entry in both frontend language packs — miss it, and it falls back to showing the user the rawerror.code.{number}string, andErrorCodeLocaleConsistencyTeststurns a backend test red.
Data access
- Inject
IRepository<T>; go through.AsQueryable()for complex queries, and drop to.Dbfor escape hatches (Db.Deleteable<>()/Db.Ado.UseTranAsync). - Soft delete and data scope are global filters — business code doesn't repeat the filter conditions.
- Uniqueness checks must include soft-deleted rows:
.ClearFilter<ISoftDelete>().AnyAsync(...), or you'll collide with the DB's unique index and throw a raw 500. - Wrap multi-write operations in a
Db.Ado.UseTranAsynctransaction, rolling everything back on failure; cache invalidation goes after the transaction commits. - Audit fields (
Idsnowflake,CreateTime/User/Org,UpdateTime/User) are filled by AOP; set only business fields.
CreateOrgId is the data-scope anchor
If a DataEntity row's CreateOrgId isn't set, org-scoped queries always return 0 rows for it — AOP fills it automatically; never bypass that to assign it by hand. See multi-org data permissions for how it works.
Caching
- The model is cache-aside (read-through) + explicit invalidation, not query-every-time; after a create/update/delete, both
RemoveAsyncthe cache and broadcast an event (e.g.DictService.InvalidateAsync→DictChangedEvent) for audit / push subscribers. The defaultChannelEventBusis in-process, though — events don't cross replicas. Cross-node invalidation needs either a shared cache or your ownIEventBuswired to an MQ. - Logical keys are centralized in
Core/CacheKeys.cs— no scattered magic strings; the prefixCache:KeyPrefix(defaultsmart:) is appended uniformly by the provider. - Default is the in-process
MemoryCacheProvider; for multi-instance sharing install the optionalSmartAdmin.Caching.Redispackage, andAddSmartAdminRedisCachemust be registered beforeAddSmartAdminto win overTryAdd(zero business-code changes). Order isn't the only gate: the overload takingIConfigurationonly actually takes over whenCache:Provider=Redis, otherwise it silently falls back to the in-process cache — theAddSmartAdminRedisCache(connectionString)overload, by contrast, enables unconditionally the moment it's called.
DI wiring
- Wiring goes in
*Setup.cs; built-in services use explicitTryAdd(not scanning — predictable and replaceable), and seeds useTryAddEnumerable, deduplicated by implementation type. - Stateless services are
Singleton(hashing, captcha generator, file storage, cache provider, event bus); per-request services areScoped(most business services, matching the repository).
Seed data
- Implement
ISeedData<TEntity>;HasData()returns the default rows (an empty collection is valid = "don't seed if the DB already has data"). - Fixed IDs keep it idempotent: fill in only what's missing, never overwrite existing rows — UI changes aren't clobbered by a restart. The one exception is
SyncOnUpgrade(defaultfalse): the kernel's ownDefaultMenuSeedandDefaultModuleSeedturn it on, so a seed-version bump overwrites same-Id rows, meaning UI edits to built-in menus and modules get lost on a kernel upgrade (self-built seeds are unaffected). Never turn this on for a seed whose rows users edit through the UI. - IDs must fall within a reserved range (
Core/SmartSeedIds.cs): kernel[1, 999], consumers1000and up, with the ceiling computed at startup from the live snowflake floor; out-of-range,Id=0, or a duplicate of an existing seed ID is rejected at startup.
Naming / organization
- Namespaces follow directories; one type per file; suffixes
Sys*for entities,I*for interfaces,*Service/*Provider/*Filter/*Attribute. - Nullable reference types enabled; time access goes through the injected
TimeProvider(testable), never a bareDateTime.Now, withSessionServiceas the pattern to follow. Exactly one bare call is left on purpose:SchemaVersionSeed's first-install timestamp is a static seed row with no DI clock to inject.
Package management
- Add or bump dependencies in
backend/Directory.Packages.props's<PackageVersion>, not version numbers in individual.csprojfiles; shared build/NuGet metadata lives inbackend/Directory.Build.props.
Comments
- Public types/members use
/// <summary>to state responsibility and boundaries; a design trade-off states its reasoning directly, never a design-doc section number, task number or issue number. - Inline comments explain only WHY (concurrency, transaction ordering, edge cases, cross-dialect pitfalls), not WHAT; they describe the current design, never its change history; in Chinese, matching the existing code.
- Deliberately simplified or capped implementations are flagged with
// ponytail:noting the limit and the upgrade path.