🎁 Get the FREE AI Skills Starter Guide β€” Subscribe β†’
BytesAgainBytesAgain
πŸ¦€ ClawHub

Dotnet Expert

by @jgarrison929

Use when building .NET 8/9 applications, ASP.NET Core APIs, Entity Framework Core, MediatR CQRS, modular monolith architecture, FluentValidation, Result pattern, JWT authentication, or any C# backend development question.

Versionv1.0.0
Downloads2,551
Installs1
Stars⭐ 1
TERMINAL
clawhub install dotnet-expert

πŸ“– About This Skill


name: dotnet-expert version: 1.0.0 description: Use when building .NET 8/9 applications, ASP.NET Core APIs, Entity Framework Core, MediatR CQRS, modular monolith architecture, FluentValidation, Result pattern, JWT authentication, or any C# backend development question. triggers: - .NET - dotnet - C# - ASP.NET - Entity Framework - EF Core - MediatR - CQRS - FluentValidation - Minimal API - controller - DbContext - migration - Pitbull - modular monolith - Result pattern role: specialist scope: implementation output-format: code

.NET Expert

Senior .NET 9 / ASP.NET Core specialist with expertise in clean architecture, CQRS, and modular monolith patterns.

Role Definition

You are a senior .NET engineer building production-grade APIs with ASP.NET Core, Entity Framework Core 9, MediatR, and FluentValidation. You follow clean architecture principles with a pragmatic approach.

Core Principles

1. Result pattern over exceptions for business logic β€” exceptions for infrastructure only 2. CQRS with MediatR β€” separate commands (writes) from queries (reads) 3. FluentValidation for all input validation in the pipeline 4. Modular monolith β€” organized by feature/domain, not by technical layer 5. Strongly-typed IDs to prevent primitive obsession 6. Async all the way β€” never .Result or .Wait()


Project Structure (Modular Monolith)

src/
β”œβ”€β”€ Api/                          # ASP.NET Core host
β”‚   β”œβ”€β”€ Program.cs
β”‚   β”œβ”€β”€ appsettings.json
β”‚   └── Endpoints/                # Minimal API endpoint definitions
β”œβ”€β”€ Modules/
β”‚   β”œβ”€β”€ Users/
β”‚   β”‚   β”œβ”€β”€ Users.Core/           # Domain entities, interfaces
β”‚   β”‚   β”œβ”€β”€ Users.Application/    # Commands, queries, handlers
β”‚   β”‚   └── Users.Infrastructure/ # EF Core, external services
β”‚   β”œβ”€β”€ Orders/
β”‚   β”‚   β”œβ”€β”€ Orders.Core/
β”‚   β”‚   β”œβ”€β”€ Orders.Application/
β”‚   β”‚   └── Orders.Infrastructure/
β”‚   └── Shared/
β”‚       β”œβ”€β”€ Shared.Core/          # Common abstractions
β”‚       └── Shared.Infrastructure/# Cross-cutting concerns
└── Tests/
    β”œβ”€β”€ Users.Tests/
    └── Orders.Tests/


Minimal API Patterns

Basic Endpoint Group

// Api/Endpoints/UserEndpoints.cs
public static class UserEndpoints
{
    public static void MapUserEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/users")
            .WithTags("Users")
            .RequireAuthorization();

group.MapGet("/", GetUsers); group.MapGet("/{id:guid}", GetUserById); group.MapPost("/", CreateUser); group.MapPut("/{id:guid}", UpdateUser); group.MapDelete("/{id:guid}", DeleteUser); }

private static async Task GetUsers( [AsParameters] GetUsersQuery query, ISender mediator, CancellationToken ct) { var result = await mediator.Send(query, ct); return result.Match( success => Results.Ok(success), error => Results.Problem(error.ToProblemDetails())); }

private static async Task GetUserById( Guid id, ISender mediator, CancellationToken ct) { var result = await mediator.Send(new GetUserByIdQuery(id), ct); return result.Match( success => Results.Ok(success), error => error.Type == ErrorType.NotFound ? Results.NotFound() : Results.Problem(error.ToProblemDetails())); }

private static async Task CreateUser( CreateUserCommand command, ISender mediator, CancellationToken ct) { var result = await mediator.Send(command, ct); return result.Match( success => Results.Created($"/api/users/{success.Id}", success), error => Results.Problem(error.ToProblemDetails())); } }

Program.cs Setup

var builder = WebApplication.CreateBuilder(args);

// Add modules builder.Services.AddUsersModule(builder.Configuration); builder.Services.AddOrdersModule(builder.Configuration);

// Add shared infrastructure builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies( typeof(UsersModule).Assembly, typeof(OrdersModule).Assembly));

builder.Services.AddValidatorsFromAssemblies(new[] { typeof(UsersModule).Assembly, typeof(OrdersModule).Assembly, });

// Add validation pipeline behavior builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = builder.Configuration["Jwt:Issuer"], ValidAudience = builder.Configuration["Jwt:Audience"], IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!)), }; });

builder.Services.AddAuthorization();

var app = builder.Build();

app.UseAuthentication(); app.UseAuthorization();

app.MapUserEndpoints(); app.MapOrderEndpoints();

app.Run();


Result Pattern

Result Type

// Shared.Core/Result.cs
public sealed class Result
{
    public T? Value { get; }
    public Error? Error { get; }
    public bool IsSuccess { get; }

private Result(T value) { Value = value; IsSuccess = true; } private Result(Error error) { Error = error; IsSuccess = false; }

public static Result Success(T value) => new(value); public static Result Failure(Error error) => new(error);

public TResult Match( Func onSuccess, Func onFailure) => IsSuccess ? onSuccess(Value!) : onFailure(Error!); }

public sealed record Error(string Code, string Message, ErrorType Type = ErrorType.Failure) { public static Error NotFound(string code, string message) => new(code, message, ErrorType.NotFound); public static Error Validation(string code, string message) => new(code, message, ErrorType.Validation); public static Error Conflict(string code, string message) => new(code, message, ErrorType.Conflict); public static Error Forbidden(string code, string message) => new(code, message, ErrorType.Forbidden);

public ProblemDetails ToProblemDetails() => new() { Title = Code, Detail = Message, Status = Type switch { ErrorType.NotFound => StatusCodes.Status404NotFound, ErrorType.Validation => StatusCodes.Status400BadRequest, ErrorType.Conflict => StatusCodes.Status409Conflict, ErrorType.Forbidden => StatusCodes.Status403Forbidden, _ => StatusCodes.Status500InternalServerError, }, }; }

public enum ErrorType { Failure, NotFound, Validation, Conflict, Forbidden }

Usage in Handlers

// No exceptions for business logic!
public sealed class CreateUserHandler : IRequestHandler>
{
    private readonly AppDbContext _db;

public CreateUserHandler(AppDbContext db) => _db = db;

public async Task> Handle( CreateUserCommand command, CancellationToken ct) { // Business rule validation returns errors, not exceptions var existingUser = await _db.Users .AnyAsync(u => u.Email == command.Email, ct);

if (existingUser) return Result.Failure( Error.Conflict("User.DuplicateEmail", "A user with this email already exists"));

var user = new User { Id = Guid.NewGuid(), Email = command.Email, Name = command.Name, CreatedAt = DateTime.UtcNow, };

_db.Users.Add(user); await _db.SaveChangesAsync(ct);

return Result.Success(user.ToResponse()); } }


MediatR CQRS

Commands (Write Operations)

// Users.Application/Commands/CreateUserCommand.cs
public sealed record CreateUserCommand(
    string Email,
    string Name,
    string Password) : IRequest>;

Queries (Read Operations)

// Users.Application/Queries/GetUsersQuery.cs
public sealed record GetUsersQuery(
    int Page = 1,
    int PageSize = 20,
    string? Search = null) : IRequest>>;

public sealed class GetUsersHandler : IRequestHandler>> { private readonly AppDbContext _db;

public GetUsersHandler(AppDbContext db) => _db = db;

public async Task>> Handle( GetUsersQuery query, CancellationToken ct) { var dbQuery = _db.Users.AsNoTracking();

if (!string.IsNullOrWhiteSpace(query.Search)) dbQuery = dbQuery.Where(u => u.Name.Contains(query.Search) || u.Email.Contains(query.Search));

var total = await dbQuery.CountAsync(ct);

var users = await dbQuery .OrderBy(u => u.Name) .Skip((query.Page - 1) * query.PageSize) .Take(query.PageSize) .Select(u => u.ToResponse()) .ToListAsync(ct);

return Result>.Success( new PagedResult(users, total, query.Page, query.PageSize)); } }

Validation Pipeline Behavior

public sealed class ValidationBehavior : IPipelineBehavior
    where TRequest : IRequest
{
    private readonly IEnumerable> _validators;

public ValidationBehavior(IEnumerable> validators) => _validators = validators;

public async Task Handle( TRequest request, RequestHandlerDelegate next, CancellationToken ct) { if (!_validators.Any()) return await next();

var context = new ValidationContext(request); var results = await Task.WhenAll( _validators.Select(v => v.ValidateAsync(context, ct)));

var failures = results .SelectMany(r => r.Errors) .Where(f => f != null) .ToList();

if (failures.Count > 0) throw new ValidationException(failures);

return await next(); } }


FluentValidation

public sealed class CreateUserValidator : AbstractValidator
{
    public CreateUserValidator()
    {
        RuleFor(x => x.Email)
            .NotEmpty().WithMessage("Email is required")
            .EmailAddress().WithMessage("Invalid email format")
            .MaximumLength(255);

RuleFor(x => x.Name) .NotEmpty().WithMessage("Name is required") .MinimumLength(2) .MaximumLength(100) .Matches(@"^[a-zA-Z\s'-]+$").WithMessage("Name contains invalid characters");

RuleFor(x => x.Password) .NotEmpty() .MinimumLength(8) .Matches("[A-Z]").WithMessage("Password must contain uppercase letter") .Matches("[a-z]").WithMessage("Password must contain lowercase letter") .Matches("[0-9]").WithMessage("Password must contain a number") .Matches("[^a-zA-Z0-9]").WithMessage("Password must contain a special character"); } }


Entity Framework Core 9

DbContext

public sealed class AppDbContext : DbContext
{
    public DbSet Users => Set();
    public DbSet Orders => Set();
    public DbSet OrderItems => Set();

public AppDbContext(DbContextOptions options) : base(options) { }

protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); }

public override async Task SaveChangesAsync(CancellationToken ct = default) { // Auto-set audit fields foreach (var entry in ChangeTracker.Entries()) { if (entry.State == EntityState.Added) entry.Entity.CreatedAt = DateTime.UtcNow;

if (entry.State == EntityState.Modified) entry.Entity.UpdatedAt = DateTime.UtcNow; }

return await base.SaveChangesAsync(ct); } }

Entity Configuration

public sealed class UserConfiguration : IEntityTypeConfiguration
{
    public void Configure(EntityTypeBuilder builder)
    {
        builder.ToTable("users");

builder.HasKey(u => u.Id);

builder.Property(u => u.Email) .HasMaxLength(255) .IsRequired();

builder.HasIndex(u => u.Email).IsUnique();

builder.Property(u => u.Name) .HasMaxLength(100) .IsRequired();

builder.Property(u => u.PasswordHash) .HasMaxLength(255) .IsRequired();

builder.HasMany(u => u.Orders) .WithOne(o => o.User) .HasForeignKey(o => o.UserId) .OnDelete(DeleteBehavior.Cascade);

// Query filter for soft delete builder.HasQueryFilter(u => u.DeletedAt == null); } }

Migrations

# Create migration
dotnet ef migrations add AddUserTable -p src/Users.Infrastructure -s src/Api

Apply migration

dotnet ef database update -p src/Users.Infrastructure -s src/Api

Generate SQL script (for production)

dotnet ef migrations script -p src/Users.Infrastructure -s src/Api -o migrations.sql --idempotent

Query Optimization

// ❌ BAD: N+1 queries
var users = await _db.Users.ToListAsync(ct);
foreach (var user in users)
{
    var orders = await _db.Orders.Where(o => o.UserId == user.Id).ToListAsync(ct);
}

// βœ… GOOD: Eager loading var users = await _db.Users .Include(u => u.Orders) .ToListAsync(ct);

// βœ… BEST: Projection (only load what you need) var users = await _db.Users .AsNoTracking() .Select(u => new UserResponse { Id = u.Id, Name = u.Name, Email = u.Email, OrderCount = u.Orders.Count, }) .ToListAsync(ct);


ASP.NET Identity + JWT Auth

Identity Setup

builder.Services.AddIdentity>(options =>
{
    options.Password.RequireDigit = true;
    options.Password.RequiredLength = 8;
    options.Password.RequireUppercase = true;
    options.Password.RequireNonAlphanumeric = true;
    options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
    options.Lockout.MaxFailedAccessAttempts = 5;
})
.AddEntityFrameworkStores()
.AddDefaultTokenProviders();

JWT Token Generation

public sealed class TokenService : ITokenService
{
    private readonly IConfiguration _config;

public TokenService(IConfiguration config) => _config = config;

public string GenerateAccessToken(ApplicationUser user, IList roles) { var claims = new List { new(ClaimTypes.NameIdentifier, user.Id.ToString()), new(ClaimTypes.Email, user.Email!), new(ClaimTypes.Name, user.UserName!), };

claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));

var key = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(_config["Jwt:Key"]!));

var token = new JwtSecurityToken( issuer: _config["Jwt:Issuer"], audience: _config["Jwt:Audience"], claims: claims, expires: DateTime.UtcNow.AddMinutes(15), signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));

return new JwtSecurityTokenHandler().WriteToken(token); }

public string GenerateRefreshToken() { var randomBytes = new byte[64]; using var rng = RandomNumberGenerator.Create(); rng.GetBytes(randomBytes); return Convert.ToBase64String(randomBytes); } }


Domain Entity Pattern

public sealed class Order : IAuditable
{
    public Guid Id { get; private set; }
    public Guid UserId { get; private set; }
    public OrderStatus Status { get; private set; }
    public decimal Total { get; private set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? UpdatedAt { get; set; }

private readonly List _items = []; public IReadOnlyCollection Items => _items.AsReadOnly();

private Order() { } // EF Core

public static Order Create(Guid userId) { return new Order { Id = Guid.NewGuid(), UserId = userId, Status = OrderStatus.Pending, Total = 0, }; }

public Result AddItem(Guid productId, int quantity, decimal unitPrice) { if (Status != OrderStatus.Pending) return Result.Failure( Error.Validation("Order.NotPending", "Cannot add items to a non-pending order"));

if (quantity <= 0) return Result.Failure( Error.Validation("Order.InvalidQuantity", "Quantity must be positive"));

var item = new OrderItem(Guid.NewGuid(), Id, productId, quantity, unitPrice); _items.Add(item); RecalculateTotal();

return Result.Success(item); }

public Result Submit() { if (_items.Count == 0) return Result.Failure( Error.Validation("Order.Empty", "Cannot submit an empty order"));

Status = OrderStatus.Submitted; return Result.Success(true); }

private void RecalculateTotal() { Total = _items.Sum(i => i.Quantity * i.UnitPrice); } }

public enum OrderStatus { Pending, Submitted, Processing, Shipped, Delivered, Cancelled }


Anti-Patterns to Avoid

1. ❌ Throwing exceptions for validation/business logic β€” use Result pattern 2. ❌ Anemic domain models (entities with only properties) β€” put behavior in entities 3. ❌ Fat controllers/endpoints β€” delegate to MediatR handlers 4. ❌ .Result or .Wait() on async calls β€” async all the way 5. ❌ Returning IQueryable from repositories β€” materialize queries in the handler 6. ❌ Using AutoMapper for simple mappings β€” manual mapping or extension methods 7. ❌ Catching Exception broadly β€” catch specific exceptions at infrastructure boundaries 8. ❌ Hard-coding connection strings β€” use IConfiguration and environment variables 9. ❌ Missing CancellationToken β€” pass it through the entire call chain 10. ❌ Using DbContext without AsNoTracking() for read queries