Overview
Every feature in FullStackHero follows the vertical slice architecture pattern. Each feature is self-contained with its own command/query, handler, validator, and endpoint.Feature Structure
Features are organized in a consistent folder structure:Complete Example: Create Group Feature
Let’s walk through a real feature from the Identity module that creates a new group.Create the command in the Contracts project. Commands represent write operations and implement
ICommand<T>.using FSH.Modules.Identity.Contracts.DTOs;
using Mediator;
namespace FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup;
public sealed record CreateGroupCommand(
string Name,
string? Description,
bool IsDefault,
List<string>? RoleIds) : ICommand<GroupDto>;
Use
ICommand<TResponse> for commands that return data. For commands with no response, use ICommand.using FluentValidation;
using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup;
namespace FSH.Modules.Identity.Features.v1.Groups.CreateGroup;
public sealed class CreateGroupCommandValidator : AbstractValidator<CreateGroupCommand>
{
public CreateGroupCommandValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Group name is required.")
.MaximumLength(256).WithMessage("Group name must not exceed 256 characters.");
RuleFor(x => x.Description)
.MaximumLength(1024).WithMessage("Description must not exceed 1024 characters.");
}
}
using FSH.Framework.Core.Context;
using FSH.Framework.Core.Exceptions;
using FSH.Modules.Identity.Contracts.DTOs;
using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup;
using FSH.Modules.Identity.Data;
using FSH.Modules.Identity.Domain;
using Mediator;
using Microsoft.EntityFrameworkCore;
namespace FSH.Modules.Identity.Features.v1.Groups.CreateGroup;
public sealed class CreateGroupCommandHandler : ICommandHandler<CreateGroupCommand, GroupDto>
{
private readonly IdentityDbContext _dbContext;
private readonly ICurrentUser _currentUser;
public CreateGroupCommandHandler(IdentityDbContext dbContext, ICurrentUser currentUser)
{
_dbContext = dbContext;
_currentUser = currentUser;
}
public async ValueTask<GroupDto> Handle(CreateGroupCommand command, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(command);
// Validate name is unique within tenant
var nameExists = await _dbContext.Groups
.AnyAsync(g => g.Name == command.Name, cancellationToken);
if (nameExists)
{
throw new CustomException($"Group with name '{command.Name}' already exists.",
(IEnumerable<string>?)null, System.Net.HttpStatusCode.Conflict);
}
// Validate role IDs exist
if (command.RoleIds is { Count: > 0 })
{
var existingRoleIds = await _dbContext.Roles
.Where(r => command.RoleIds.Contains(r.Id))
.Select(r => r.Id)
.ToListAsync(cancellationToken);
var invalidRoleIds = command.RoleIds.Except(existingRoleIds).ToList();
if (invalidRoleIds.Count > 0)
{
throw new NotFoundException($"Roles not found: {string.Join(", ", invalidRoleIds)}");
}
}
var group = Group.Create(
name: command.Name,
description: command.Description,
isDefault: command.IsDefault,
isSystemGroup: false,
createdBy: _currentUser.GetUserId().ToString());
// Add role assignments
if (command.RoleIds is { Count: > 0 })
{
foreach (var roleId in command.RoleIds)
{
_dbContext.GroupRoles.Add(GroupRole.Create(group.Id, roleId));
}
}
_dbContext.Groups.Add(group);
await _dbContext.SaveChangesAsync(cancellationToken);
// Get role names for response
var roleNames = command.RoleIds is { Count: > 0 }
? await _dbContext.Roles
.Where(r => command.RoleIds.Contains(r.Id))
.Select(r => r.Name!)
.ToListAsync(cancellationToken)
: [];
return new GroupDto
{
Id = group.Id,
Name = group.Name,
Description = group.Description,
IsDefault = group.IsDefault,
IsSystemGroup = group.IsSystemGroup,
MemberCount = 0,
RoleIds = command.RoleIds?.AsReadOnly(),
RoleNames = roleNames.AsReadOnly(),
CreatedAt = group.CreatedAt
};
}
}
Handlers must return
ValueTask<TResponse> not Task<TResponse>. This is a requirement of the Mediator library.using FSH.Framework.Shared.Identity;
using FSH.Framework.Shared.Identity.Authorization;
using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup;
using Mediator;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
namespace FSH.Modules.Identity.Features.v1.Groups.CreateGroup;
public static class CreateGroupEndpoint
{
public static RouteHandlerBuilder MapCreateGroupEndpoint(this IEndpointRouteBuilder endpoints)
{
return endpoints.MapPost("/groups", (IMediator mediator, [FromBody] CreateGroupCommand request, CancellationToken cancellationToken) =>
mediator.Send(request, cancellationToken))
.WithName("CreateGroup")
.WithSummary("Create a new group")
.RequirePermission(IdentityPermissionConstants.Groups.Create)
.WithDescription("Create a new group with optional role assignments.");
}
}
Domain Entity Pattern
Domain entities use rich domain models with factory methods and private setters:Modules.Identity/Domain/Group.cs
Key Principles
Single Responsibility
Each feature handles one specific use case with all its components in one folder
Mediator Pattern
Use
IMediator.Send() to dispatch commands and queries through the pipelineAlways Validate
Every command/query must have a validator - no exceptions
Rich Domain Models
Use factory methods and encapsulation to protect domain invariants
Feature Flow
Next Steps
Commands & Queries
Learn the difference between ICommand and IQuery
Endpoints
Master minimal API endpoint mapping
Validation
Deep dive into FluentValidation patterns
Testing
Write tests for your features
