Read the architecture through one feature
CRM Vertical Slice Architecture in Indotalent’s Blazor CRM Source Code organizes code around business features inside a single project. The Lead module is the clearest example: components, a service, endpoint mappings, request and response types, commands, handlers, and validators live in one feature folder under Features/Pipeline/Lead/.
This article traces the create operation end to end. It describes the actual structure instead of drawing a generic architecture diagram and assuming the product follows it. The project still has shared infrastructure, and the pipeline modules share database, authentication, and response behavior.
Locate the lead slice
Features/Pipeline/Lead/
LeadService.cs
LeadEndpoint.cs
Components/
LeadPage.razor
_LeadCreateForm.razor
_LeadUpdateForm.razor
_LeadDataTable.razor
Cqrs/
CreateLeadHandler.cs
CreateLeadValidator.cs
UpdateLeadHandler.cs
GetLeadListHandler.cs
GetLeadByIdHandler.cs
GetLeadLookupHandler.cs
DeleteLeadByIdHandler.cs
Each handler file in the Cqrs folder declares its own request DTO, response DTO, and command or query record. Reading CreateLeadHandler.cs provides the local contract and its implementation together, so a developer does not jump between layers to understand one operation.
Step 1: collect input in the Blazor component
_LeadCreateForm.razor binds a CreateLeadRequest to MudBlazor fields, validates the form, and manages a processing state. Lookup options for campaigns, sales teams, pipeline stages, and closing statuses are loaded from GetLeadLookupAsync on initialization. On submit the form calls LeadService.CreateLeadAsync; success raises a snackbar and fires the OnSuccess callback that returns the page to the table. The component owns these interface concerns and contains no EF entity construction.
Step 2: cross the service and endpoint boundary
LeadService creates a RestSharp request for api/lead, adds the DTO as JSON, and executes it through the shared BaseService. That shared code attaches the bearer token and current-user headers, processes the ApiResponse envelope, and handles a 401 by attempting a token refresh. The endpoint receives the DTO and dispatches a command through MediatR:
group.MapPost("/", async (CreateLeadRequest request, IMediator mediator) =>
{
var result = await mediator.Send(new CreateLeadCommand(request));
return result.ToApiResponse("Lead has been created successfully",
StatusCodes.Status201Created);
});
The complete route is /api/lead, mounted under the /api group in Program.cs with the current-user filter and JWT bearer authentication. This is a real HTTP boundary even though the UI and the API are compiled in the same project. The CRM API guide explains methods, response status, and direct-client behavior.
Step 3: run the use case in the handler
CreateLeadHandler receives AppDbContext through its constructor. It generates a document number for the lead, creates a new Lead entity from the request fields, adds it to the context, and awaits SaveChangesAsync:
var autoNo = await _context.GenerateAutoNumberAsync(
entityName: entityName,
prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/",
ct: cancellationToken);
var entity = new Data.Entities.Lead
{
AutoNumber = autoNo,
Title = request.Data.Title,
PipelineStage = request.Data.PipelineStage,
CampaignId = request.Data.CampaignId,
SalesTeamId = request.Data.SalesTeamId
};
_context.Lead.Add(entity);
await _context.SaveChangesAsync(cancellationToken);
The handler then returns a response containing the new Id and the generated document code. There is no duplicate-name check in the lead create handler; validation is expressed elsewhere in the slice, which the validation section below explains. The sales-team module shows the contrasting pattern with an existence check that throws when a team name is already used.
Step 4: account for shared behavior
AppDbContext supplies audit, soft delete, and automatic numbering during save. When the handler calls SaveChangesAsync, the context stamps CreatedAt and CreatedBy, converts any deleted rows into soft-delete updates, and assigns document numbers to new auto-number rows that still lack one. The response helper wraps the returned DTO, and authentication is enforced before the endpoint runs.
These shared paths affect every module, so a change to the query filter, the response envelope, or the audit logic warrants checks across all of their consumers. Vertical slices reduce navigation overhead; they do not remove the need to keep shared contracts aligned.
Place validation at an enforced boundary
CreateLeadValidator validates CreateLeadRequest, and the Blazor form invokes it directly through MudBlazor’s per-field validation. The command dispatched through MediatR is CreateLeadCommand. The generic validation behavior looks for validators of the dispatched type, so the payload-DTO validator alone does not guarantee automatic pipeline validation of the command.
If you extend server validation for direct API clients, make the relationship explicit with a command validator or an explicit validation call inside the handler, and test the API without the Blazor form. Tracing the complete slice is more useful than assuming a folder named Cqrs guarantees every cross-cutting behavior.
Extend a slice without losing the result
For a new lead field, identify the UI input and reload behavior, update the request and response DTOs, map the field in the handler, and add a length rule in the entity configuration if needed. Keep verification tied to outcomes: successful creation with a returned code, a reloaded grid that shows the campaign and team names, an invalid direct API request, and the behavior of the delete flow through the confirmation dialog.
The Blazor and MudBlazor guide examines the screens and service in more detail, and the API guide explains the endpoint contract. All of these views lead to the same Blazor CRM source-code product, where you can review the complete application. See this architecture implemented in a complete Blazor CRM application.