Indotalent Enterprise Kit Documentation
A comprehensive guide to the architecture, features, and development workflow of the Indotalent ASP.NET Core Blazor Server enterprise starter kit.
1. Architecture Overview
Indotalent uses Vertical Slice Architecture (VSA) with ASP.NET Core Areas. Each feature lives in its own self-contained folder, including its Razor pages, components, CQRS handlers, validators, and API endpoints. This eliminates the need to jump between multiple projects when working on a single feature.
Key Architectural Decisions
| Aspect | Implementation |
|---|---|
| Architecture | Vertical Slice via ASP.NET Core Areas |
| Backend API | Minimal API (retained for HTTP clients, downloads, and OpenAPI) |
| CQRS | Plain handlers (no MediatR dependency) |
| Database | EF Core with multi-provider (InMemory / SQL Server / PostgreSQL) |
| Primary Keys | String (GUID) - no auto-increment |
| Soft Delete | IHasIsDeleted + global query filter |
| Audit | IHasAudit + auto-populated on SaveChanges |
| Validation | FluentValidation (server) + component-side Validate() (UX mirror) |
| Frontend | Blazor Web App (interactive server render mode) + MudBlazor components |
| UI data access | HandlerInvoker - fresh DI scope per call, circuit user seeded for audit fields |
| Auth | ASP.NET Core Identity + JWT with Refresh Token Rotation + Firebase SSO |
| Rate Limiting | System.Threading.RateLimiting - 4 policies |
| CORS | Config-gated via appsettings.json (CorsSettings:IsUsed) - disabled by default |
| Background Jobs | Hangfire with built-in dashboard |
2. Project Structure
The project is organized into ASP.NET Core Areas. Each area groups features by access level:
| Area | Purpose | Auth Required |
|---|---|---|
Areas/Public/ | Public-facing pages (Home, Privacy, Documentation) | No |
Areas/Identity/ | ASP.NET Core Identity Razor Pages (Login, Register, Manage) | Mixed |
Areas/Admin/ | Admin-only features (User, Role, Tax, Currency, etc.) | Admin role |
Areas/Main/ | Member features (Todo, Todo Dashboard, etc.) | Member role |
Areas/Blazor/ | Blazor shell: App.razor, Routes.razor, layouts, circuit services, shared components | N/A |
Areas/Components/ | Shared dialogs (standard delete confirmation, etc.) | N/A |
Feature Folder Convention (VSA)
Every feature follows this convention:
+-- Pages/
| +-- Index.razor
| +-- Create.razor
| +-- Edit.razor
| +-- Detail.razor
+-- Components/
| +-- {EntityName}ModuleInfo.razor
+-- Cqrs/
| +-- Get{EntityName}ListHandler.cs
| +-- Get{EntityName}ByIdHandler.cs
| +-- Create{EntityName}Handler.cs + Validator.cs
| +-- Update{EntityName}Handler.cs + Validator.cs
| +-- Delete{EntityName}Handler.cs
+-- Endpoints/{EntityName}Endpoint.cs
+-- {EntityName}Fields.cs (declarative FieldDescriptor sets)
Master-Detail features (like Areas/Main/Todo) add feature-local components
(Form, ItemsEditor, ItemDialog, Models.cs)
and hand-written pages instead of the descriptor-driven Fields.cs.
3. Application Name
The application name - displayed in the browser title bar, top-left logo, footer, and sidebar logo -
is configured centrally through appsettings.json. This allows you to rebrand the entire
application without editing any layout files manually.
| File / Path | Description |
|---|---|
Areas/Blazor/App.razor | Document shell: renders the app name in the browser title, navbar logo, and global assets |
Areas/Blazor/Layouts/MainLayout.razor | Authenticated shell: app bar, sidebar logo, Module Information drawer |
appsettings.json → AppSettings | Central application name configuration |
Configure the application name in appsettings.json under AppSettings:
"AppSettings": {
"Name": "Indotalent"
}
To rebrand the application, simply change the "Name" value. The layouts read this value
at runtime via @Configuration["AppSettings:Name"], so the title, logo, and footer update
automatically across both the public area and the authenticated area layouts.
Enterprise Features
Authentication
Full-featured authentication with ASP.NET Core Identity, JWT access tokens with refresh token rotation, and optional Firebase SSO.
| File / Path | Description |
|---|---|
Infrastructures/Authentications/Jwt/JwtService.cs | JWT token generation, refresh token creation, hashing, and validation |
Infrastructures/Authentications/Jwt/JwtAuthEndpoints.cs | Minimal API endpoints: POST /api/auth/* |
Infrastructures/Authentications/Firebase/ | Firebase token verification on server side |
Areas/Identity/Pages/Account/ | Razor Pages for Login, Register, Manage, etc. |
| Config | appsettings.json → JwtSettings |
// 1. Login → POST /api/auth/login with email+password
// 2. Response returns: { token, refreshToken, expiresAt, user }
// 3. When access token expires → POST /api/auth/refresh
// with { refreshToken } → new token pair (rotation)
// 4. Refresh token is hashed (SHA256) and stored in DBSSO Firebase
Indotalent supports Firebase Single Sign-On (SSO) as an optional authentication method.
When enabled, users can sign in using their Google account via Firebase Authentication.
The Firebase configuration is stored in appsettings.json under the SsoFirebase section.
| File / Path | Description |
|---|---|
Infrastructures/Authentications/Firebase/ | Firebase token verification service |
appsettings.json → SsoFirebase | Firebase project configuration |
To enable Firebase SSO, configure the following in appsettings.json:
"SsoFirebase": {
"IsUsed": true,
"ProjectId": "xxx",
"ApiKey": "xxx",
"AuthDomain": "xxx.firebaseapp.com",
"StorageBucket": "xxx.firebasestorage.app",
"MessagingSenderId": "xxx",
"AppId": "xxx"
}
Set "IsUsed": true to enable Firebase SSO. Replace the placeholder values (xxx)
with your actual Firebase project credentials from the Firebase Console.
Set "IsUsed": false to disable Firebase SSO and use only the built-in Identity authentication.
AutoNumber Generation
Entities implementing IHasAutoNumber get auto-generated codes like COMP-0001.
| File / Path | Description |
|---|---|
Data/Interfaces/IHasAutoNumber.cs | Interface definition |
Infrastructures/AutoNumberGenerator/AutoNumberGeneratorService.cs | Number generation service |
| Usage | Add : BaseEntity, IHasAutoNumber to entity |
Background Jobs (Hangfire)
Hangfire with built-in dashboard at /hangfire (Admin only). Supports recurring, fire-and-forget, and delayed jobs.
| File / Path | Description |
|---|---|
Infrastructures/BackgroundJobs/DI.cs | Hangfire configuration + storage |
Infrastructures/BackgroundJobs/HangfireAuthorizationFilter.cs | Admin-only dashboard access |
Infrastructures/BackgroundJobs/Jobs/SerilogCleanupJob.cs | Sample recurring job |
Multi-Database
Switch between InMemory, SQL Server, and PostgreSQL with a single config change. The application supports three database providers - simply toggle "IsUsed" to switch between them.
| File / Path | Description |
|---|---|
Infrastructures/Databases/DatabaseSettingsModel.cs | Configuration model |
Infrastructures/Databases/DI.cs | EF Core provider registration |
appsettings.json | Set "IsUsed": true for your provider |
Configure your database provider in appsettings.json under DatabaseSettings:
"DatabaseSettings": {
// InMemory (default, no external DB needed)
"InMemory": {
"IsUsed": true,
"ConnectionString": "IndotalentDb",
"TimeoutInSeconds": 1800
},
// Microsoft SQL Server
"MsSQL": {
"IsUsed": false,
"ConnectionString": "Server=localhost\\SQLEXPRESS;Database=MyDb;Trusted_Connection=True;TrustServerCertificate=True",
"TimeoutInSeconds": 1800
},
// PostgreSQL
"PostgreSQL": {
"IsUsed": false,
"ConnectionString": "Host=localhost;Database=MyDb;Username=postgres;Password=yourpassword",
"TimeoutInSeconds": 1800
}
}
To switch providers, set the desired provider's "IsUsed" to true and the others to false.
Only one provider can be active at a time. Update the ConnectionString to match your database server credentials.
Demo Mode
Indotalent includes a Demo Mode feature that, when enabled, automatically seeds the database with dummy demo data on application startup. This is useful for testing, presentations, or evaluation purposes without needing to manually enter data.
| File / Path | Description |
|---|---|
Infrastructures/Databases/DatabaseSeeder.cs | Seeds demo data when Demo Mode is active |
appsettings.json → DemoMode | Toggle Demo Mode on/off |
Configure Demo Mode in appsettings.json:
"DemoMode": {
"IsDemo": true
}
Set "IsDemo": true to enable Demo Mode - the application will seed dummy data
(sample users, roles, and demo records) on every startup.
Set "IsDemo": false to disable it and start with a clean database.
AI Chat
Indotalent includes an AI Chat feature that can be enabled by configuring your preferred AI provider's API key. The application supports multiple AI providers including ChatGPT, Claude, Gemini, and DeepSeek.
| File / Path | Description |
|---|---|
appsettings.json → AiSettings | AI provider selection and API keys |
Configure AI Chat in appsettings.json under AiSettings:
"AiSettings": {
// Choose your provider: "ChatGPT", "Claude", "Gemini", or "DeepSeek"
"Provider": "ChatGPT",
"ChatGPT": {
"ApiKey": "sk-your-chatgpt-api-key",
"Model": "gpt-4o"
},
"Claude": {
"ApiKey": "sk-ant-your-claude-api-key",
"Model": "claude-3-opus-20240229"
},
"Gemini": {
"ApiKey": "your-gemini-api-key",
"Model": "gemini-1.5-pro"
},
"DeepSeek": {
"ApiKey": "your-deepseek-api-key",
"Model": "deepseek-v4-flash"
}
}
To enable AI Chat, set the "Provider" field to your chosen provider name and
fill in the corresponding "ApiKey" with your actual API key from that provider.
Leave the API keys empty to disable the AI Chat feature.
Email Delivery
Multi-provider email service supporting SendGrid, Mailgun, SMTP, and Mailjet. Toggle "IsUsed" to switch between providers.
| File / Path | Description |
|---|---|
Infrastructures/Email/EmailSettingsModel.cs | Provider selection + API keys |
Infrastructures/Email/EmailService.cs | Main email service with templates |
Infrastructures/Email/SendGrid/, Mailgun/, etc. | Provider implementations |
Infrastructures/Email/IdentityEmailSenderAdapter.cs | Identity integration |
Configure email delivery in appsettings.json under EmailSettings:
"EmailSettings": {
// SendGrid
"SendGrid": {
"IsUsed": false,
"ApiKey": "SG.your-sendgrid-api-key",
"FromEmail": "noreply@email.com"
},
// Mailgun
"Mailgun": {
"IsUsed": false,
"ApiKey": "key-your-mailgun-api-key",
"Domain": "mg.yourdomain.com",
"FromEmail": "noreply@email.com"
},
// Mailjet
"Mailjet": {
"IsUsed": false,
"ApiKey": "mj-your-public-key",
"ApiSecret": "mj-your-private-key",
"FromEmail": "noreply@email.com"
},
// SMTP (default)
"Smtp": {
"IsUsed": true,
"Host": "smtp.gmail.com",
"Port": 465,
"UserName": "your-email@gmail.com",
"Password": "your-app-password",
"FromAddress": "your-email@gmail.com",
"FromName": "no-reply"
}
}
To switch email providers, set the desired provider's "IsUsed" to true and the others to false.
Only one provider can be active at a time. Fill in the API keys and credentials for your chosen provider.
File Upload / Download
File storage service supporting local file system with upload, download, delete operations.
| File / Path | Description |
|---|---|
Infrastructures/File/FileStorageService.cs | Core service |
Infrastructures/File/FileStorageSettingsModel.cs | Storage path, allowed extensions, max size |
Infrastructures/File/Local/ | Local file system implementation |
Health Checks
Built-in health check endpoints, hardened for container probes (for example Docker or a load balancer),
plus a System Monitoring dashboard at /Admin/HealthCheck/Index.
| File / Path | Description |
|---|---|
Infrastructures/HealthChecks/DI.cs | Health check registration and endpoint mapping |
Program.cs | Error interceptor bypasses /health* so probes keep raw 200/503 |
appsettings.json - HealthCheckSettings | Toggles for the database and storage checks |
Endpoints
| Endpoint | Purpose | Behavior |
|---|---|---|
/health/live | Liveness (no dependencies) | Always 200 while the process is up |
/healthz | Legacy alias of liveness | Same as /health/live |
/health/ready | Readiness (database + storage) | 200 when Healthy/Degraded, 503 when Unhealthy |
/ready | Legacy alias of readiness | Same as /health/ready |
/health | Full JSON detail for the dashboard | 200/503 with one entry per check |
All probe endpoints are public (no authentication, no rate limiting) so orchestrators can call
them without credentials. The response is never redirected to the error page: an unhealthy readiness check
returns a raw 503, which is exactly what container probes observe.
Degraded vs Unhealthy. Readiness maps Degraded to 200 on purpose:
container probes (for example Docker) only inspect the HTTP status code, so a degraded dependency must not fail a
container healthcheck. Only Unhealthy (for example, the database cannot be reached) produces a
503. Do not expect Degraded to fail a probe - only 503 does.
Docker container probe (example)
A HEALTHCHECK that probes the readiness endpoint - only a 503 response
marks the container unhealthy.
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
CMD curl -f http://localhost:80/health/ready || exit 1curl must exist in the runtime image; orchestrators can alternatively point a
readiness probe at /health/ready.
The dashboard also shows in-memory traffic metrics - see System Monitoring Dashboard (Ops Metrics) below.
System Monitoring Dashboard (Ops Metrics)
The Admin dashboard at /Admin/HealthCheck/Index shows the health cards plus an
Application card, a rolling Traffic summary, and the most recent endpoints.
It is fed by the Admin-only endpoint GET /api/ops/metrics.
| File / Path | Description |
|---|---|
Infrastructures/OpsMetrics/RequestMetricStore.cs | Thread-safe in-memory rolling store (per-minute buckets + bounded latency histogram + recent requests) |
Infrastructures/OpsMetrics/RequestMetricsMiddleware.cs | Records each request; excludes health probes, static assets, and the metrics endpoint itself |
Areas/Admin/HealthCheck/Endpoints/OpsEndpoint.cs | GET /api/ops/metrics minimal API (Admin role) |
Infrastructures/OpsMetrics/DI.cs | AddOpsMetricsService + UseRequestMetricsMiddleware |
appsettings.json - OpsMetricsSettings | Window size, bucket count, recent-request cap |
Configure the rolling window in appsettings.json:
"OpsMetricsSettings": {
"WindowMinutes": 5,
"MaxBuckets": 10,
"MaxRecentRequests": 200
}The snapshot returned by /api/ops/metrics uses camelCase keys:
{
"app": {
"name", "version", "environment", "startedAtUtc", "uptimeSeconds"
},
"window": {
"minutes", "totalRequests", "success", "errors", "errorRate",
"avgMs", "p95Ms", "p99Ms",
"byStatus": { "2xx", "3xx", "4xx", "5xx" }
},
"recent": [
{ "method", "path", "status", "durationMs", "timestampUtc" }
],
"sinceStart": {
"totalRequests", "success", "errors", "errorRate",
"avgMs", "p95Ms", "p99Ms"
}
}successcounts 2xx responses;errorscounts 5xx responses; latency is in milliseconds.- P95/P99 are approximated from a bounded per-minute histogram, so memory stays flat under load.
- Health probes, static assets, and dashboard polls are excluded from the counts.
- Numbers reset whenever the web process restarts - the store is in-memory only.
OpenTelemetry (OTLP Export)
A vendor-neutral observability foundation that exports metrics and traces over
OTLP. It is disabled by default: no exporter runs and no diagnostic listeners start until you set
OpenTelemetrySettings:Enabled to true. The in-memory traffic recording for the System
Monitoring dashboard always runs and is independent of this exporter.
NuGet packages
| Package | Purpose |
|---|---|
OpenTelemetry.Extensions.Hosting | DI integration (services.AddOpenTelemetry()) |
OpenTelemetry.Instrumentation.AspNetCore | Inbound HTTP server metrics and traces |
OpenTelemetry.Instrumentation.Http | Outbound HttpClient metrics and traces |
OpenTelemetry.Exporter.OpenTelemetryProtocol | OTLP exporter (gRPC by default) |
| File / Path | Description |
|---|---|
Infrastructures/OpenTelemetry/ApiMetrics.cs | Static meter Indotalent.Api with counters and a duration histogram |
Infrastructures/OpenTelemetry/DI.cs | AddOpenTelemetryService - only registers providers when enabled |
Infrastructures/OpenTelemetry/OpenTelemetrySettingsModel.cs | Settings model for OpenTelemetrySettings |
Infrastructures/DI.cs | Registration inside AddInfrastructureDI |
appsettings.json - OpenTelemetrySettings | Master switch + OTLP endpoint and headers |
Configure OpenTelemetry in appsettings.json:
"OpenTelemetrySettings": {
"Enabled": false,
"Otlp": {
"Endpoint": "",
"Headers": ""
}
}- Both
Enabled: trueand a non-emptyOtlp:Endpointare required before any exporter starts. - Exports the ASP.NET Core and HttpClient instrumentations plus the app meter
Indotalent.Apiwithindotalent.api.requests,indotalent.api.errors, andindotalent.api.request.duration(taghttp.response.status.family). - The OTLP exporter defaults to the gRPC protocol, so a plain
http://host:4317endpoint works with configuration only. - To use an HTTP/protobuf gateway, some telemetry backends publish an OTLP HTTP gateway endpoint - add
otlp.Protocol = OtlpExportProtocol.HttpProtobuf;inside theAddOtlpExporter(...)call inInfrastructures/OpenTelemetry/DI.cs. - The default
service.nameresource attribute is the assembly name (Indotalent) - use it to identify the application in your telemetry backend.
Logging (Serilog)
Structured logging with Serilog. Writes to rolling files with automatic 3-day cleanup via Hangfire.
| File / Path | Description |
|---|---|
Infrastructures/Logging/Serilog/ | Serilog configuration |
wwwroot/data/serilog/ | Log file output directory |
Infrastructures/BackgroundJobs/Jobs/SerilogCleanupJob.cs | Auto-cleanup job (daily at midnight) |
Rate Limiting
Four rate limiting policies using System.Threading.RateLimiting, configurable via appsettings.json.
| Policy | Scope | Default |
|---|---|---|
| Global | All requests | 100 req/min |
| Authenticated | Authenticated users | 200 req/min |
| Write | POST/PUT/DELETE | 50 req/min |
| Admin | Admin role | 500 req/min |
CORS (Cross-Origin Resource Sharing)
Browsers block cross-origin requests unless the server returns the appropriate
CORS response headers. This project is a monolith - the frontend and
the Web API are served from the same origin - so CORS is not needed today. The setting
exists only to prepare for a future scenario where the Web API (/api/*) is consumed from a
different domain (for example a separate SPA or a partner site). It is disabled by default,
so there is zero behavior change and zero overhead until it is enabled.
| File / Path | Description |
|---|---|
Infrastructures/Cors/CorsSettingsModel.cs | Settings model for CorsSettings |
Infrastructures/Cors/DI.cs | AddCorsService / UseCorsService - both no-ops when disabled |
Infrastructures/DI.cs | AddCorsService registration inside AddInfrastructureDI |
Program.cs | UseCorsService between UseRouting and UseAuthorization |
appsettings.json - CorsSettings | Master switch + origin / method / header allow-list |
| Setting | Default | Description |
|---|---|---|
IsUsed | false | Master switch. When off, no CORS service, middleware, or headers. |
AllowedOrigins | [] | Empty (or "*") allows any origin; a non-empty list acts as an allow-list. |
AllowedMethods | [] | Empty allows any HTTP method. |
AllowedHeaders | [] | Empty allows any request header. |
ExposedHeaders | [] | Response headers the browser may expose to client code; applied only when non-empty. |
AllowCredentials | false | Only honored with an explicit origin allow-list (CORS spec forbids allow-any-origin + credentials). |
Default configuration in appsettings.json:
"CorsSettings": {
"IsUsed": false,
"AllowedOrigins": [],
"AllowedMethods": [],
"AllowedHeaders": [],
"ExposedHeaders": [],
"AllowCredentials": false
}Example - enabling access for one cross-origin single-page app:
"CorsSettings": {
"IsUsed": true,
"AllowedOrigins": [ "https://app.example.com" ],
"AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ],
"AllowedHeaders": [ "Content-Type", "Authorization" ],
"AllowCredentials": true
}IsUsed: falsemeans no CORS service, no CORS middleware, and no CORS headers - the default.- An empty
AllowedOrigins(or one containing"*") allows any origin and cannot be combined with credentials. AllowCredentialsis applied only when an explicit origin allow-list is configured.- The policy is registered globally as the default policy, so it covers
/api/*and the authentication endpoints. - Override per environment in
appsettings.Development.jsonor with the environment variableCorsSettings__IsUsed=true. - After enabling, a request from an allowed origin returns
Access-Control-Allow-Originand the preflightOPTIONSrequest succeeds - verify in the browser DevTools Network tab or withcurl -H "Origin: https://app.example.com" -i.
6. CQRS Pattern (Step-by-Step)
Every feature uses a simple CQRS pattern with plain C# handlers (no MediatR).
Each CRUD operation has its own handler class with a single HandleAsync() method.
Step 1: List Handler
public class GetTaxListHandler
{
private readonly AppDbContext _context;
public GetTaxListHandler(AppDbContext context) => _context = context;
public async Task<ApiResponse<object>> HandleAsync(DataTableRequest request, CancellationToken cancellationToken = default)
{
var query = _context.Tax.AsQueryable();
// Apply search filter
if (!string.IsNullOrWhiteSpace(request.Search))
query = query.Where(x => x.Name.Contains(request.Search) || x.Code.Contains(request.Search));
var projected = query.Select(x => new TaxListItem { ... });
// Shared extension: paging + total + response shape
return await projected.ToDataTableAsync(request, "Tax list retrieved successfully", cancellationToken);
}
}Step 2: Create Handler
public class CreateTaxHandler
{
public async Task<ApiResponse<CreateTaxResponse>> HandleAsync(CreateTaxRequest request, CancellationToken cancellationToken = default)
{
// 1. Validate with FluentValidation
var result = await _validator.ValidateAsync(request, cancellationToken);
if (!result.IsValid)
return ApiResponse<CreateTaxResponse>.Fail(
"Validation failed", result.ToDictionary());
// 2. Check for duplicate Code
if (await _context.Tax.AnyAsync(x => x.Code == request.Code, cancellationToken))
return ApiResponse<CreateTaxResponse>.Fail("Code already exists");
// 3. Save to database
var entity = new Tax
{
Code = request.Code,
Name = request.Name,
PercentageValue = request.PercentageValue,
Description = request.Description
};
_context.Tax.Add(entity);
await _context.SaveChangesAsync(cancellationToken);
return ApiResponse<CreateTaxResponse>.Ok(
new CreateTaxResponse { Id = entity.Id, Code = entity.Code },
"Tax has been created successfully");
}
}Step 3: Update Handler
Similar to Create but loads existing entity, validates it exists, updates properties, and saves - all with the same CancellationToken discipline.
Step 4: Delete Handler
public class DeleteTaxHandler
{
public async Task<ApiResponse<object>> HandleAsync(string id, CancellationToken cancellationToken = default)
{
var entity = await _context.Tax.FindAsync(id, cancellationToken);
if (entity == null)
return ApiResponse<object>.Fail("Tax not found");
// Soft delete only - the global query filter hides the row
_context.SoftDelete(entity);
await _context.SaveChangesAsync(cancellationToken);
return ApiResponse<object>.Ok(new { id }, "Tax deleted successfully");
}
}
The same handlers power both surfaces: Blazor pages resolve them through
HandlerInvoker (fresh DI scope per call), while the Minimal API endpoints
inject them and map the returned ApiResponse<T> to HTTP results.
Standard API Response
All handlers return ApiResponse<T> which wraps the result:
public class ApiResponse<T>
{
public bool Success { get; set; }
public string? Message { get; set; }
public T? Data { get; set; }
public IDictionary<string, string[]>? Errors { get; set; }
}7. Minimal API Endpoints
The HTTP API surface uses ASP.NET Core Minimal API. Blazor pages call the same CQRS handlers
in-process through HandlerInvoker; the endpoints remain for HTTP clients, file
downloads, and the OpenAPI/Swagger surface. Each feature registers its endpoints
in a single {EntityName}Endpoint.cs file, on ONE feature-scoped group
(/api/{entityLower}).
| Method | Route | Action | Auth |
|---|---|---|---|
| GET | /api/{entity} | Paginated list with search & sort | Required |
| GET | /api/{entity}/{id} | Get by ID | Required |
| GET | /api/{entity}/export | Full Excel export (same search filter) | Required |
| GET | /api/{entity}/{lookup}-lookup | Lookup list for autocomplete | Required |
| GET | /api/{entity}/{id}/items | Master-detail items (master-detail features) | Required |
| POST | /api/{entity} | Create new record | Required |
| PUT | /api/{entity} | Update existing record | Required |
| DELETE | /api/{entity}/{id} | Soft-delete record | Required |
Endpoints are registered in the area mapping (Areas/{Area}/Map{Area}Endpoints.cs) via app.Map{EntityName}Endpoints(); - never directly in Program.cs.
When the API is consumed from a different origin, enable CORS via CorsSettings - see the CORS section above.
8. Blazor Components Tutorial
The frontend is a Blazor Web App using interactive server render mode + MudBlazor.
Every page is a Razor component (Pages/*.razor) rendered on the server; UI events travel
over the SignalR circuit. There is no SPA routing, no client-side build tooling, and no per-page
JavaScript - interactions are C# event handlers, and data access goes through CQRS handlers.
How the UI is Hosted
App.razor -> document shell + global assets (MudBlazor, SheetJS, jsPDF, ECharts, interop helpers)
Routes.razor -> router (interactive server render mode, MainLayout)
Layouts/ -> MainLayout.razor (app bar, sidebar, Module Information drawer) + NavMenu.razor
Services/ -> HandlerInvoker, PageState, ModuleInfoState, CircuitUserState
Shared/ -> reusable components (Crud* pages, AuditTrailCard, LookupAutocomplete, chips, ...)
MudBlazor providers (theme, dialog, snackbar, popover) live in MainLayout.razor, so every
page rendered inside the layout can use dialogs and snackbars without extra setup.
Basic Page Component Pattern
Every feature page follows this pattern:
@* Routable page: keeps the legacy /Area/Entity/Index URL *@
@page "/Main/Todo/Index"
@page "/Main/Todo"
@layout MainLayout
@attribute [Authorize(Roles = $"{ApplicationRoles.AdminConst},{ApplicationRoles.MemberConst}")]
@inject HandlerInvoker Invoker
@inject PageState PageState
<PageTitle>Todo Management - @Configuration["AppSettings:Name"]</PageTitle>
@if (!_ready)
{
<div class="ui-empty" style="padding-top:4rem;">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Medium" />
<p class="u-text-muted">Loading...</p>
</div>
}
else
{
@* page content *@
}
@code {
private bool _ready;
protected override void OnInitialized() => PageState.Set("Todo Management");
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender || _ready) return;
await Task.Delay(500); // page gate (Section 3.15.1)
await LoadAsync();
_ready = true;
StateHasChanged();
}
}
Data access always goes through HandlerInvoker.InvokeAsync(sp => sp.GetRequiredService<GetTodoListHandler>().HandleAsync(...)) -
never a direct handler instantiation and never a fetch call.
Example 1: Index Page (Search + Paged Grid + Export)
Pure Master and Lookup features use the declarative CrudIndex component - the page only wires descriptors and handler lambdas (see Areas/Admin/Country/Pages/Index.razor).
<CrudIndex TItem="CountryListItem"
Title="Country List"
EntityLabel="country"
SearchPlaceholder="Search by Code, Name, or Description..."
EmptyText="No country records found."
CreateUrl="/Admin/Country/Create"
DetailUrlFormat="/Admin/Country/Detail/{0}"
EditUrlFormat="/Admin/Country/Edit/{0}"
Columns="_columns"
Loader="LoadAsync"
Exporter="ExportAsync"
ExportFileNamePrefix="country"
ExportSheetName="Countries"
IdSelector="@(item => item.Id)"
DefaultOrderColumn="2">
<ModuleInfoContent>
<CountryModuleInfo />
</ModuleInfoContent>
</CrudIndex>
@code {
private static readonly FieldDescriptor[] _columns =
{
new() { Name = nameof(CountryListItem.AutoNumber), Label = "Auto Number", ShowInIndex = true, SortOrder = 1, Format = CellFormat.Dash },
new() { Name = nameof(CountryListItem.Code), Label = "Code", ShowInIndex = true, SortOrder = 2 },
new() { Name = nameof(CountryListItem.Name), Label = "Name", ShowInIndex = true, SortOrder = 3 },
new() { Name = nameof(CountryListItem.Description), Label = "Description", ShowInIndex = true, SortOrder = 4, Format = CellFormat.Dash }
};
private async Task<DataTableResponse<CountryListItem>?> LoadAsync(DataTableRequest request, CancellationToken cancellationToken)
{
var result = await Invoker.InvokeAsync(sp =>
sp.GetRequiredService<GetCountryListHandler>().HandleAsync(request, cancellationToken));
return result.Success && result.Data is DataTableResponse<CountryListItem> data ? data : null;
}
}private async Task<GridData<TodoListItem>> LoadServerDataAsync(GridState<TodoListItem> state, CancellationToken cancellationToken)
{
var sort = state.SortDefinitions.FirstOrDefault();
var request = new DataTableRequest
{
Search = _search,
Page = state.Page + 1,
PageSize = state.PageSize,
Start = 0, // zero so ResolvePaging() cannot overwrite Page/PageSize
Length = 0,
OrderColumn = MapSortColumn(sort?.SortBy) ?? 1,
OrderDir = sort is null ? "asc" : sort.Descending ? "desc" : "asc"
};
var result = await Invoker.InvokeAsync(sp =>
sp.GetRequiredService<GetTodoListHandler>().HandleAsync(request, cancellationToken));
return result.Success && result.Data is DataTableResponse<TodoListItem> data
? new GridData<TodoListItem> { Items = data.Items, TotalItems = data.Total }
: new GridData<TodoListItem> { Items = Array.Empty<TodoListItem>(), TotalItems = 0 };
}
The search box is debounced in the page (300ms + cancelled request), sorting maps the grid column to the raw
DataTableRequest.OrderColumn index, and the Export XLSX button builds friendly-header rows and calls
blazorExport.xlsx(fileName, sheetName, rows) from the shared interop file.
Example 2: Create Form with Validation
Bespoke features use a {Entity}Form component (see Areas/Main/Todo/Components/TodoForm.razor); pure/lookup features get this for free from CrudEditPage + CrudForm.
// Defaults live in property initializers - no post-render patching
public class TodoEditModel
{
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public TodoPriority? Priority { get; set; }
public DateTime? DueDate { get; set; }
public TimeSpan? DueTime { get; set; }
public decimal Progress { get; set; }
public string? OwnerUserId { get; set; }
public bool IsCompleted { get; set; }
public List<TodoItemModel> Items { get; set; } = new();
}
// Page fields: _model, _ready, _saving, _created, _createdId, _generalError, _errorspublic bool Validate()
{
ClearErrors();
if (string.IsNullOrWhiteSpace(Model.Name))
_errors["Name"] = "Todo Name is required";
else if (Model.Name.Length > 100)
_errors["Name"] = "Todo Name must not exceed 100 characters";
if (Model.Description?.Length > 500)
_errors["Description"] = "Description must not exceed 500 characters";
if (Model.Progress < 0 || Model.Progress > 100)
_errors["Progress"] = "Progress must be between 0 and 100";
StateHasChanged();
return _errors.Count == 0;
}
public void SetServerErrors(string? message, IDictionary<string, string[]>? errors)
{
ClearErrors();
if (errors is not null)
foreach (var (key, messages) in errors)
_errors[key] = messages.FirstOrDefault() ?? string.Empty;
_generalError = message;
StateHasChanged();
}private async Task SubmitAsync()
{
if (_form is null || !_form.Validate()) return;
_saving = true;
_form.ClearErrors();
StateHasChanged();
try
{
await Task.Delay(2000); // Simulated processing delay
var request = _model.ToCreateRequest();
var result = await Invoker.InvokeAsync(sp =>
sp.GetRequiredService<CreateTodoHandler>().HandleAsync(request));
if (result.Success && result.Data is not null)
{
_created = true;
_createdId = result.Data.Id;
Snackbar.Add($"Todo \"{_model.Name}\" has been created successfully.", Severity.Success);
}
else
{
_form.SetServerErrors(result.Message, result.Errors);
Snackbar.Add(result.Message ?? "Failed to create todo", Severity.Error);
}
}
catch
{
Snackbar.Add("An error occurred while submitting the form.", Severity.Error);
}
finally
{
_saving = false;
StateHasChanged();
}
}Example 3: Loading States Pattern
Every page declares the same UI state fields for a polished UX:
// Page gate + spinner states (bound to Disabled / spinner visibility)
private bool _ready; // controls the @if (!_ready) loading gate
private bool _saving; // "Save" -> "Saving..." + spinner
private bool _exporting; // "Export XLSX" -> "Exporting..." + spinner
private bool _deleting; // "Delete" -> "Deleting..." + spinner
private string _error = string.Empty; // inline error banner, auto-cleared after 4s
// Delays: page gate 500ms; submit + dialogs 2000ms; export + refresh 500msExample 4: Standard Delete Confirmation Dialog
Delete operations use the shared _DeleteConfirmationDialog component (never confirm()):
private async Task ConfirmDeleteAsync()
{
if (_data is null) return;
var confirmed = await _DeleteConfirmationDialog.ShowAsync(
DialogService,
"Delete Todo",
"Are you sure you want to delete this todo record?");
if (!confirmed) return;
_deleting = true;
StateHasChanged();
try
{
var result = await Invoker.InvokeAsync(sp =>
sp.GetRequiredService<DeleteTodoHandler>().HandleAsync(Id));
if (result.Success)
{
_deleted = true;
Snackbar.Add($"Todo \"{_data.Name}\" has been deleted successfully.", Severity.Success);
await Task.Delay(800);
Navigation.NavigateTo("/Main/Todo/Index");
}
else
{
Snackbar.Add(result.Message ?? "Failed to delete", Severity.Error);
}
}
finally
{
_deleting = false;
StateHasChanged();
}
}Blazor Page Checklist
When creating a new Blazor page, ensure you include:
@page + @layout MainLayout + @attribute [Authorize(Roles = ...)]_ready gate + PageState.Set(...) in OnInitializedHandlerInvoker - never new, never fetchOnAfterRenderAsync (charts, export, PDF)ui-* CSS classes; no per-page <script>/<style>Disabled + spinner + text change)_DeleteConfirmationDialog (not confirm())Snackbar; error banners auto-clear after 4s9. AI-Assisted Development
Indotalent ships with an automatic AI-assisted development pipeline driven by the
.ai-assisted/ folder. The only file the developer writes is
.ai-assisted/DATA-DICTIONARY.md - the AI generates everything else: the feature
specification, the technical PRD, and the complete application.
How to Start the Development Sequence (automatic)
- Fill
.ai-assisted/DATA-DICTIONARY.md- application name, persona, and feature description. - Start the sequence - tell your AI coding agent exactly this command:
start coding - The AI runs the whole chain automatically: Gate 0 (identity check) → DATA-DICTIONARY review → Phase 0 (
FEATURE.md) → Phase 1 (PRD.md) → Phase 2 (build the application). - Done! A ready-to-use application, verified with
dotnet build(0 errors) after every feature.
Important - before you start: make sure .ai-assisted/DATA-DICTIONARY.md
has been updated to match the new application you are about to build. The AI builds
exactly what that file describes - template placeholders ([ ... ]), the
## EXAMPLE app, or data from a previous project would be built as-is.
What the AI Generates Automatically
One command produces three deliverables:
FEATURE.md - business source of truth (Phase 0)PRD.md - technical blueprint / build backlog (Phase 1)Entity Types Auto-Detected by AI
| Pattern in Entity | Detected Type |
|---|---|
public ICollection<T>? Items { get; set; } | Master-Detail |
public string {X}Id { get; set; } + navigation property | With Lookup |
| Neither pattern above | Pure Master Data |
: BaseEntity, IHasAutoNumber | Adds auto-numbering |
Each Feature Is Generated With 16+ Files (Blazor vertical slice)
For every feature, the AI creates the full vertical slice - no controllers, no per-page JavaScript:
Maintenance Mode - Adding a Single Feature
Once the application is customized (AppSettings:Name is no longer
Indotalent), the pipeline is inactive. To add a single feature, work directly with
.ai-assisted/SKILL-SOFTWARE-ENGINEERING.md: create the entity class in
Data/Entities/{Entity}.cs and let the AI generate the feature following the skill.
Prompt Examples - Copy & Use (maintenance mode)
These per-feature prompts apply when the pipeline is inactive (maintenance mode). For a greenfield
project, use the single command start coding instead. Replace {Entity}
with your entity name.
Generate a simple CRUD feature with no relationships:
Generate full CRUD for {Entity}. Follow the skill.Generate a feature that references another entity via foreign key:
Generate full CRUD for {Entity} with lookup to {LookupEntity}. Follow the skill.Generate a header-detail feature (e.g., Sales Order with line items):
Generate full CRUD for {MasterEntity} with {DetailEntity}. Follow the skill.Generate a feature with pre-populated seed data:
Generate full CRUD for {Entity} with seed data. Follow the skill.Smart Prompt Strategies
To get the best results from your AI agent and save tokens, use these strategies:
"Read Areas/Admin/Currency/ and generate a new feature following the same pattern."
This restricts the AI to just the Currency feature folder, saving thousands of tokens.
"Generate full CRUD for Category. Use Tax as the template. Follow the skill."
The AI will use Tax as a reference and adapt it for Category.
"Make me a CRUD" → Too vague. The AI doesn't know your patterns.
"Generate full CRUD for Category. Follow the skill." → The AI knows exactly what to do.
"Generate full CRUD for Category, Product, and Customer. Follow the skill."
One prompt, multiple entities. The AI processes each independently.
For the complete set of ready-to-use prompts (Options 1–5), open
.ai-assisted/SKILL-SOFTWARE-ENGINEERING.md → section
"For Users: What to Say to Your AI".
Indotalent Enterprise Kit - Technical Documentation v1.0
Built with ASP.NET Core Blazor Server 10 · MudBlazor · Hangfire · Serilog · EF Core · VSA Architecture