This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
MyOnion is a .NET 10 clean architecture reference API template that demonstrates Domain-Driven Design patterns. The solution showcases a custom lightweight mediator, CQRS pattern, FluentValidation, Mapster for mapping, and EF Core with the Repository/Specification pattern. The project is packaged as a VSIX template for Visual Studio 2022.
# Restore dependencies
dotnet restore MyOnion.sln
# Build the solution (use Release before PRs)
dotnet build MyOnion.sln -c Release
# Run the API with hot reload
dotnet watch run --project MyOnion/src/MyOnion.WebApi/MyOnion.WebApi.csproj
# Apply code formatting
dotnet format MyOnion.slnThe API will be available at https://localhost:5001/swagger with health checks at /health.
# Create development HTTPS certificate
./MyOnion/scripts/Create-DevCert.ps1 -Password "devpassword"
# Start API + SQL Server (requires .env file - copy from .env.example)
docker compose --project-directory MyOnion up --buildAccess the containerized API at https://localhost:44378/swagger/index.html.
# Build VSIX package (outputs to Desktop by default)
./MyOnion/scripts/Build-OnionTemplate.ps1 -Configuration Release
# Install template locally for testing (copies to Visual Studio templates directory)
./MyOnion/scripts/Install-OnionTemplateLocally.ps1 -Configuration ReleaseThe build script:
- Copies all source/test projects to
MyOnion/artifacts/TemplateOnionAPI/ - Performs token replacement (
MyOnion→$safeprojectname$, namespace updates) - Normalizes project references (
..\..\src\→..\) - Generates
MyTemplate.vstemplateXML for each project - Creates
TemplateOnionAPI.zipinMyOnion/vsix/VSIXTemplateOnionAPI/ProjectTemplates/ - Builds VSIX using MSBuild if
-SkipVsixnot specified
The solution follows onion architecture with strict dependency flow:
WebApi → Infrastructure.Persistence, Infrastructure.Shared, Application → Domain
MyOnion.Domain (MyOnion/src/MyOnion.Domain/)
- Pure domain layer with zero external dependencies
- Contains entities (
Department,Employee,Position,SalaryRange) - Value objects (
PersonName,DepartmentName,PositionTitle) - sealed, immutable classes with validation - Base classes:
BaseEntity(Guid ID),AuditableBaseEntity(Created/LastModified tracking) - Domain events (
EmployeeChangedEvent,DepartmentChangedEvent, etc.)
MyOnion.Application (MyOnion/src/MyOnion.Application/)
- Business logic layer implementing CQRS pattern
- Custom lightweight mediator (
Messaging/Mediator.cs) - Feature-based organization under
Features/<FeatureName>/Commands|Queries/ - FluentValidation validators co-located with commands/queries
- Mapster mapping profiles in
Mappings/ - Pipeline behaviors for cross-cutting concerns (validation, caching)
- Repository and specification interfaces
MyOnion.Infrastructure.Persistence (MyOnion/src/MyOnion.Infrastructure.Persistence/)
- EF Core
ApplicationDbContextwith query tracking disabled by default - Generic repository implementing
IGenericRepositoryAsync<T> - Specialized repositories for domain entities
- Value object ownership configuration (e.g.,
OwnsOne()forPersonName) - Database migrations and seed data
MyOnion.Infrastructure.Shared (MyOnion/src/MyOnion.Infrastructure.Shared/)
- Cross-cutting services:
IDateTimeService,IEmailService,IMockDataService - Registered with transient lifetime
MyOnion.WebApi (MyOnion/src/MyOnion.WebApi/)
- ASP.NET Core host with controllers in
Controllers/v1/ - Service registration extensions in
Extensions/ - Middleware pipeline: error handling → request timing → Swagger → authentication → CORS → health checks
- Feature flags for authentication (
FeatureManagement:AuthEnabled) and database mode (FeatureManagement:UseInMemoryDatabase) - JWT authentication configured via
Sts:ServerUrl,Sts:Audience,Sts:ValidIssuer(optional when auth disabled)
Location: MyOnion/src/MyOnion.Application/Messaging/
The solution uses a custom lightweight mediator instead of MediatR:
public interface IRequest<TResponse> { }
public interface IRequestHandler<TRequest, TResponse> where TRequest : IRequest<TResponse>
{
Task<TResponse> HandleAsync(TRequest request, CancellationToken cancellationToken);
}
public interface IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>
{
Task<TResponse> HandleAsync(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken);
}Pipeline Execution:
- Mediator resolves handler via
IServiceProvider - Retrieves all
IPipelineBehavior<,>registrations - Builds chain of responsibility in reverse order
- Behaviors can intercept, validate, cache, or transform requests
Registered Behaviors:
ValidationBehavior<TRequest, TResponse>- Executes FluentValidation validators before handlerGetEmployeesCachingDecorator- Query-specific caching for employee queriesGetPositionsCachingBehavior- Query-specific caching for position queries
Features are organized under MyOnion/src/MyOnion.Application/Features/<FeatureName>/:
Features/
├── Employees/
│ ├── Commands/
│ │ ├── CreateEmployee/
│ │ │ ├── CreateEmployeeCommand.cs # Command with nested Handler class
│ │ │ └── CreateEmployeeCommandValidator.cs # FluentValidation rules
│ │ ├── UpdateEmployee/
│ │ └── DeleteEmployeeById/
│ └── Queries/
│ └── GetEmployees/
│ ├── GetEmployeesQuery.cs # Query with nested Handler class
│ ├── GetEmployeesQueryValidator.cs
│ ├── GetEmployeesViewModel.cs # DTO for response
│ └── GetEmployeesCachingDecorator.cs # Optional caching behavior
Pattern:
- Each command/query implements
IRequest<Result>orIRequest<Result<T>> - Handler is nested class implementing
IRequestHandler<TRequest, TResponse> - Validator extends
AbstractValidator<TRequest> - Returns
Result,Result<T>, orPagedResult<T>wrapper
Example handler invocation:
var result = await _mediator.Send(new CreateEmployeeCommand { ... }, cancellationToken);Generic Repository Interface: MyOnion/src/MyOnion.Application/Interfaces/IGenericRepositoryAsync.cs
Key methods:
GetByIdAsync(Guid id)- Single entity retrievalGetPagedReponseAsync(int pageNumber, int pageSize)- Basic paginationGetAllShapeAsync(string fields, string orderBy)- Dynamic field selectionListAsync(ISpecification<T>)- Query by specificationFirstOrDefaultAsync(ISpecification<T>)- Single entity by specificationCountAsync(ISpecification<T>)- Count entities matching specificationBulkInsertAsync(IEnumerable<T>)- Batch operations
Specification Pattern: Uses Ardalis.Specification library
MyOnion uses the battle-tested Ardalis.Specification library (v9.3.1) for implementing the Specification pattern. Specifications are located in MyOnion/src/MyOnion.Application/Specifications/<EntityName>/.
Specifications encapsulate query logic using a fluent builder API:
public class SalaryRangesByFiltersSpecification : Specification<SalaryRange>
{
public SalaryRangesByFiltersSpecification(GetSalaryRangesQuery request, bool applyPaging = true)
{
// Filtering - type-safe with compile-time validation
if (!string.IsNullOrWhiteSpace(request.Name))
{
var term = request.Name.Trim();
Query.Where(s => s.Name.Contains(term));
}
// Ordering - expression-based
Query.OrderBy(s => s.Name);
// Pagination
if (applyPaging && request.PageSize > 0)
{
Query.Skip((request.PageNumber - 1) * request.PageSize)
.Take(request.PageSize);
}
// EF Core optimizations
Query.AsNoTracking()
.TagWith("GetSalaryRangesByFilters");
}
}Specifications support:
- Type-safe filtering:
Query.Where(e => e.Name.Contains("Smith")) - Expression-based ordering:
Query.OrderBy(e => e.LastName) - Eager loading:
Query.Include(e => e.Position).ThenInclude(p => p.Department) - Pagination:
Query.Skip(10).Take(20) - EF Core features:
AsNoTracking(),AsSplitQuery(),TagWith(),IgnoreQueryFilters() - Single result marker: Inherit from
SingleResultSpecification<T>for queries returning one entity - Search helper:
Query.Search(e => e.Email, $"%{term}%")
The repository uses Ardalis.Specification.EntityFrameworkCore's SpecificationEvaluator to translate specifications into EF Core queries.
Location: MyOnion/src/MyOnion.Application/Mappings/GeneralProfile.cs
Mapster configuration is registered as a singleton using assembly scanning:
var mapsterConfig = TypeAdapterConfig.GlobalSettings;
mapsterConfig.Scan(Assembly.GetExecutingAssembly());
services.AddScoped<IMapper, ServiceMapper>();Mapping Patterns:
BiDirectional mapping:
config.NewConfig<SalaryRange, GetSalaryRangesViewModel>().TwoWays();Value object property extraction:
config.NewConfig<Employee, GetEmployeesViewModel>()
.Map(dest => dest.FirstName, src => src.Name.FirstName)
.Map(dest => dest.LastName, src => src.Name.LastName);Value object construction:
config.NewConfig<CreateEmployeeCommand, Employee>()
.Map(dest => dest.Name, src => new PersonName(src.FirstName, src.MiddleName, src.LastName));Location: MyOnion/src/MyOnion.Application/Common/Results/Result.cs
All handlers return Result or Result<T> wrappers:
public class Result
{
public bool IsSuccess { get; }
public string Message { get; }
public IReadOnlyCollection<string> Errors { get; }
public double? ExecutionTimeMs { get; }
}
public class Result<T> : Result
{
public T Value { get; }
}
public class PagedResult<T> : Result<T>
{
public int PageNumber { get; }
public int PageSize { get; }
public int RecordsFiltered { get; }
public int RecordsTotal { get; }
}Pipeline Behaviors:
GetEmployeesCachingDecorator- Caches employee query results with TTL expirationGetPositionsCachingBehavior- Caches position query results
Cache Key Pattern: Cache keys are built from normalized query parameters:
var key = $"{CacheKeyPrefixes.EmployeesAll}:{pageNumber}:{pageSize}:{filters.ToLower().Trim()}:{fields.ToLower()}";Cache Invalidation:
CacheInvalidationEventHandler subscribes to domain events and invalidates cache prefixes:
EmployeeChangedEvent→ InvalidateEmployeesAllandDashboardMetricsPositionChangedEvent→ InvalidatePositionsAllandDashboardMetrics- Etc.
When adding a new feature:
- Create feature folder:
MyOnion/src/MyOnion.Application/Features/<FeatureName>/ - Add commands/queries:
- Create
Commands/<CommandName>/<CommandName>Command.cswith nestedHandlerclass - Create
<CommandName>CommandValidator.csextendingAbstractValidator<T> - Return
Result<T>from handler
- Create
- Add DTOs: Create ViewModels/DTOs in
Queries/<QueryName>/orDTOs/ - Configure mapping: Add Mapster configuration in
GeneralProfile.cs - Add controller endpoint: Create controller in
MyOnion.WebApi/Controllers/v1/using[ApiVersion("1.0")]
Example controller pattern:
[ApiVersion("1.0")]
public class EmployeesController : BaseApiController
{
[HttpGet]
public async Task<IActionResult> Get([FromQuery] GetEmployeesQuery query)
{
var result = await Mediator.Send(query);
return result.IsSuccess ? Ok(result) : BadRequest(result);
}
}When creating value objects:
- Use sealed, immutable classes
- Add validation in constructor
- Implement
Equals()andGetHashCode()for value equality - Configure as owned entities in EF Core using
OwnsOne()
Example from PersonName.cs:23-35:
public sealed class PersonName
{
public string FirstName { get; }
public string LastName { get; }
public string FullName => $"{FirstName} {LastName}";
private PersonName() { } // EF Core
public PersonName(string firstName, string lastName)
{
FirstName = Normalize(firstName);
LastName = Normalize(lastName);
}
}EF Core configuration is in ApplicationDbContext.cs and ApplicationDbContextHelpers.cs:
- Use
OwnsOne()for value objects to store as columns in parent table - Configure cascade delete behavior explicitly (default is Restrict for critical entities)
- Set decimal precision:
HasColumnType("decimal(18, 2)") - Add indexes on foreign keys and frequently queried columns
- Override
SaveChangesAsync()to automatically set audit fields
FluentValidation rules are co-located with commands/queries:
public class CreateEmployeeCommandValidator : AbstractValidator<CreateEmployeeCommand>
{
public CreateEmployeeCommandValidator()
{
RuleFor(e => e.FirstName)
.NotEmpty().WithMessage("{PropertyName} is required.")
.MaximumLength(100).WithMessage("{PropertyName} must not exceed 100 characters.");
RuleFor(e => e.Email)
.EmailAddress().WithMessage("{PropertyName} must be a valid email.");
}
}The ValidationBehavior pipeline behavior automatically executes validators before handlers.
- Never commit secrets: Use
dotnet user-secretslocally or environment variables in Docker/CI - JWT Configuration: Set
Sts:ServerUrl,Sts:Audience,Sts:ValidIssuerinappsettings.Development.jsonor user secrets (only required whenFeatureManagement:AuthEnabledis true) - CORS: Configure allowed origins in
Cors:AllowedOrigins(JSON array) - the default open policy is only for local HTTPS development - Database: Connection string is in
appsettings.json- override with environment variableConnectionStrings__DefaultConnection
Follow imperative commit message style (examples from git log):
- "Update build script"
- "Add EasyCaching blog"
- "Minor update to blog"
Keep subject under 72 characters, start with a verb, and add scope if needed. PRs should include summary, validation steps, and screenshots/sample responses for API contract changes.
All documentation files in MyOnion/docs/ follow a consistent kebab-case naming convention with category prefixes:
Format: {category}-{descriptive-name}.md
Categories:
plan-- Planning documents for features, migrations, and upgradesblog-- Blog posts and articles (published or draft)design-- Technical design and architecture documentationrelease-notes-- Release notes for versioned releasesgithub-release-- GitHub-specific release contenttest-- Testing plans and test strategy documents
Examples:
plan-net10-upgrade.md- .NET 10 upgrade planning documentblog-template-onion-api-net10.md- Blog post about .NET 10 templatedesign-value-object.md- Value object design documentationrelease-notes-10.1.3.md- Release notes for v10.1.3test-coverage-100-percent.md- Test coverage planning
Benefits:
- Alphabetically groups by category when sorted
- Consistent lowercase for git-friendliness
- Easy to search and filter by category
- URL-friendly for potential wiki/docs sites