SuperAPI AI Directory

cleanddd-dotnet-coding

Coding & development

View repository

Updated 10-16 06:50

About this skill

When to Use

  • Load when writing/modifying business features, commands, queries, endpoints, integration events, repositories, entity configurations, or related tests in this repository.

Prerequisite Inputs

  • Modeling design: CleanDDD requirements analysis and modeling have been completed, and design documents such as aggregates, commands, queries, and events have been obtained.

Quick Check Before Starting

  • Is there already a cleanddd-modeling list of aggregates/commands/events/API endpoints (Endpoints)? If missing, complete it first.
  • Confirm aggregate boundaries and invariants; use PascalCase consistently for naming, and fixed spelling for enums.
  • Conventions: command handlers do not explicitly call SaveChanges; use domain events/integration events for cross-aggregate interaction, and do not directly update across aggregates.

General Principles

  • Prefer primary constructors; all IO/repository/EF calls use async/await and pass CancellationToken.
  • Strict layering: Web → Infrastructure → Domain; aggregates and entities publish domain events, and command handlers do not explicitly call SaveChanges.
  • Strongly typed IDs are provided by EF value generators; constructors do not set IDs, use type instances directly, and avoid .Value.
  • Use KnownException for business exceptions; FastEndpoints uses attribute configuration, not Configure(); IMediator is constructor-injected, and use Send.OkAsync/CreatedAsync/NoContentAsync with .AsResponseData().

Recommended Workflow

  1. Aggregates and entities → 2) Domain events → 3) Repositories and entity configuration → 4) Commands + validators + handlers → 5) Queries + validators + handlers → 6) API endpoints (Endpoints) → 7) Domain event handlers → 8) Integration events/converters/handlers → 9) Tests.

Directory Structure

  • Domain:src/ProjectName.Domain/(AggregatesModel/{Aggregate}Aggregate,DomainEvents)。
  • Infrastructure:src/ProjectName.Infrastructure/(Repositories,EntityConfigurations,ApplicationDbContext)。
  • Web:src/ProjectName.Web/Application/(Commands,Queries,DomainEventHandlers,IntegrationEvents,IntegrationEventConverters,IntegrationEventHandlers);Endpoints。
  • Tests: test/* corresponds to each layer.

Unified Naming and Placement Conventions

  • Naming style: use PascalCase throughout; records/classes/interfaces follow .NET conventions; events use past tense.
  • Strongly typed ID: name it {Entity}Id, define it as public partial record, in the same file as the entity/aggregate.
  • File naming:
    • Command: {Action}{Entity}Command.cs; the same file contains the validator and handler.
    • Query: {Action}{Entity}Query.cs; the same file contains the response/DTO, validator, and handler.
    • Endpoint: {Action}{Entity}Endpoint.cs; one endpoint per file.
    • Repository: {Aggregate}Repository.cs; place the interface and implementation in the same file.
    • Entity configuration: {Entity}EntityTypeConfiguration.cs.
    • Domain events: {Aggregate}DomainEvents.cs; may contain multiple events for that aggregate.
    • Integration event: {Entity}{Action}IntegrationEvent.cs.
    • Integration event converter: {Entity}{Action}IntegrationEventConverter.cs.
    • Integration event handler: {IntegrationEvent}HandlerFor{Action}.cs.
    • Domain event handler: {DomainEvent}DomainEventHandlerFor{Action}.cs.
    • DbContext:ApplicationDbContext.cs。
  • Placement directories:
    • Aggregates/entities/domain events: src/ProjectName.Domain/AggregatesModel/{Aggregate}Aggregate and src/ProjectName.Domain/DomainEvents.
    • Repositories and configurations: src/ProjectName.Infrastructure/Repositories and src/ProjectName.Infrastructure/EntityConfigurations.
    • Application layer: src/ProjectName.Web/Application/{Commands|Queries|DomainEventHandlers|IntegrationEvents|IntegrationEventConverters|IntegrationEventHandlers}.
    • Endpoints: src/ProjectName.Web/Endpoints/{Module}/.
  • Single-file merge convention: commands/queries merge the validator, handler, and response (if any) into the same file; repository interfaces and implementations are in the same file; all other types are one type per file.

Aggregate

  • Naming rules: aggregate roots do not need the "Aggregate" suffix; child entities follow entity naming.
  • Directory: src/ProjectName.Domain/AggregatesModel/{Aggregate}Aggregate.
  • File name: {Entity}.cs (same file as {Entity}Id ).
  • Implementation points (aggregate root):
    • The aggregate root inherits Entity + IAggregateRoot, with a protected parameterless constructor.
    • Properties use private set, with default values explicitly set; include Deleted (soft delete) and RowVersion (optimistic concurrency).
    • When state changes, use this.AddDomainEvent() to publish domain events.
    • Strongly typed IDs implement IGuidStronglyTypedId (preferred) or IInt64StronglyTypedId, are automatically generated by the EF value generator, and do not need to be set manually in the constructor.
  • Implementation points (child entity):
    • Child entities are not required to inherit Entity, but inheriting is recommended; when inheriting, define their own strongly typed ID (IGuidStronglyTypedId is recommended), provide a protected parameterless constructor; foreign keys (such as the aggregate root UserId) are ordinary properties, and EF configuration uses HasForeignKey to associate the aggregate root; the ID is automatically generated by the EF value generator.
  • Rules between aggregates:
    • Aggregates do not depend on each other, and directly referencing other aggregate root instances is prohibited; cross-aggregate interaction uses domain events or domain services.
    • Child entities are not shared across aggregates.

Example: User aggregate root and strongly typed ID

namespace ProjectName.Domain.AggregatesModel.UserAggregate;

public partial record UserId : IGuidStronglyTypedId;

public class User : Entity<UserId>, IAggregateRoot
{
    protected User() { }

    public User(string name, string email)
    {
        Name = name;
        Email = email;
        this.AddDomainEvent(new UserCreatedDomainEvent(this));
    }

    public string Name { get; private set; } = string.Empty;
    public string Email { get; private set; } = string.Empty;
    public Deleted Deleted { get; private set; } = new();
    public RowVersion RowVersion { get; private set; } = new(0);

    // 一对多:User 拥有多个 RefreshToken(子实体有独立主键)
    public ICollection<UserRefreshToken> RefreshTokens { get; private set; } = [];

    public void ChangeEmail(string email)
    {
        Email = email;
        this.AddDomainEvent(new UserEmailChangedDomainEvent(this));
    }

    public UserRefreshToken AddRefreshToken(string token)
    {
        var refreshToken = new UserRefreshToken(token);
        RefreshTokens.Add(refreshToken);
        return refreshToken;
    }
}

Example: child entity with its own primary key (one-to-many, inheriting Entity<TId> is recommended)

namespace ProjectName.Domain.AggregatesModel.UserAggregate;

// 子实体推荐继承 Entity<TId>:定义强类型 ID,提供 protected 无参构造
public partial record UserRefreshTokenId : IInt64StronglyTypedId;

public class UserRefreshToken : Entity<UserRefreshTokenId>
{
    protected UserRefreshToken() { }

    public UserRefreshToken(string token)
    {
        Token = token;
        ExpiresTime = DateTimeOffset.UtcNow.AddDays(1);
    }

    public UserId UserId { get; private set; } = null!;   // 外键,EF 配置中用 HasForeignKey 关联
    public string Token { get; private set; } = string.Empty;
    public DateTimeOffset ExpiresTime { get; private set; }
    public bool IsUsed { get; private set; }

    public void Use()
    {
        if (IsUsed || ExpiresTime < DateTimeOffset.UtcNow)
            throw new KnownException("无效的刷新令牌");
        IsUsed = true;
    }
}

Domain events

  • Naming rules: {Entity}{Action}DomainEvent (past tense), use record, and the type implements IDomainEvent.
  • Directory: src/ProjectName.Domain/DomainEvents.
  • File name: {Aggregate}DomainEvents.cs (multiple events of the same aggregate may be merged).
  • Implementation points: serves only as a carrier and contains no business logic.

Example: User domain events

namespace ProjectName.Domain.DomainEvents;

public record UserCreatedDomainEvent(User User) : IDomainEvent;
public record UserEmailChangedDomainEvent(User User) : IDomainEvent;

Commands

  • Naming rules: the command record is {Action}{Entity}Command; use ICommand for the return type, and ICommand when there is no return.
  • Directory: src/ProjectName.Web/Application/Commands/{Module}s.
  • File name: {Action}{Entity}Command.cs (the same file contains the validator and handler).
  • Implementation points:
    • The handler implements ICommandHandler (or its generic version).
    • Use repositories to read/persist aggregates; fully asynchronous and pass CancellationToken.
    • Use KnownException to express business exceptions; do not manually call SaveChanges/UpdateAsync.

Example: create user command

using ProjectName.Domain.AggregatesModel.UserAggregate;

namespace ProjectName.Web.Application.Commands.Users;

public record CreateUserCommand(string Name, string Email) : ICommand<UserId>;

public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
    public CreateUserCommandValidator()
    {
        RuleFor(x => x.Name).NotEmpty().MaximumLength(50);
        RuleFor(x => x.Email).NotEmpty().EmailAddress().MaximumLength(100);
    }
}

public class CreateUserCommandHandler(IUserRepository userRepository)
    : ICommandHandler<CreateUserCommand, UserId>
{
    public async Task<UserId> Handle(CreateUserCommand command, CancellationToken cancellationToken)
    {
        if (await userRepository.EmailExistsAsync(command.Email, cancellationToken))
            throw new KnownException("邮箱已存在");

        var user = new User(command.Name, command.Email);
        await userRepository.AddAsync(user, cancellationToken);
        return user.Id;
    }
}

Queries

  • Naming rules: {Action}{Entity}Query; return IQuery or IPagedQuery.
  • Directory: src/ProjectName.Web/Application/Queries/{Module}s.
  • File name: {Action}{Entity}Query.cs (the same file contains the response/DTO, validator, and handler).
  • Implementation points:
    • The handler implements IQueryHandler; query directly using ApplicationDbContext.
    • Fully asynchronous and passes CancellationToken; use projections, WhereIf/OrderByIf/ToPagedDataAsync.
    • Pagination uses PagedData and provides a default sort order.
    • Repositories and cross-aggregate Joins are prohibited; no side effects.
    • The output type can be {Entity}Response or a DTO.

Example: Query users

using ProjectName.Domain.AggregatesModel.UserAggregate;
using ProjectName.Infrastructure;
using Microsoft.EntityFrameworkCore;

namespace ProjectName.Web.Application.Queries.Users;

public record GetUserQuery(UserId UserId) : IQuery<UserDto>;

public class GetUserQueryValidator : AbstractValidator<GetUserQuery>
{
    public GetUserQueryValidator()
    {
        RuleFor(x => x.UserId).NotEmpty();
    }
}

public class GetUserQueryHandler(ApplicationDbContext context)
    : IQueryHandler<GetUserQuery, UserDto>
{
    public async Task<UserDto> Handle(GetUserQuery request, CancellationToken cancellationToken)
    {
        return await context.Users
            .Where(x => x.Id == request.UserId)
            .Select(x => new UserDto(x.Id, x.Name, x.Email))
            .FirstOrDefaultAsync(cancellationToken)
            ?? throw new KnownException($"未找到用户,UserId = {request.UserId}");
    }
}

public record UserDto(UserId Id, string Name, string Email);

API Endpoints (Endpoints)

  • Naming rule: {Action}{Entity}Endpoint; request/response are {Action}{Entity}Request/{Action}{Entity}Response (do not use DTOs).
  • Directory: src/ProjectName.Web/Endpoints/{Module}.
  • File name: {Action}{Entity}Endpoint.cs (one endpoint per file).
  • Implementation points:
    • Use attribute routing/authorization ([HttpPost]/[AllowAnonymous]/[Tags]).
    • HandleAsync sends commands/queries through the mediator; Send.OkAsync/CreatedAsync/NoContentAsync + .AsResponseData().
    • Requests/responses can directly use strongly typed IDs, avoiding unwrapping .Value; do not use Configure().

Example: Create user API endpoint (Endpoint)

using ProjectName.Domain.AggregatesModel.UserAggregate;
using ProjectName.Web.Application.Commands.Users;
using Microsoft.AspNetCore.Authorization;

namespace ProjectName.Web.Endpoints.Users;

public record CreateUserRequest(string Name, string Email);
public record CreateUserResponse(UserId UserId);

[Tags("Users")]
[HttpPost("/api/users")]
[AllowAnonymous]
public class CreateUserEndpoint(IMediator mediator)
    : Endpoint<CreateUserRequest, ResponseData<CreateUserResponse>>
{
    public override async Task HandleAsync(CreateUserRequest req, CancellationToken ct)
    {
        var id = await mediator.Send(new CreateUserCommand(req.Name, req.Email), ct);
        await Send.OkAsync(new CreateUserResponse(id).AsResponseData(), ct);
    }
}

Domain event handlers

  • Naming rule: {DomainEvent}DomainEventHandlerFor{Action}.
  • Directory: src/ProjectName.Web/Application/DomainEventHandlers.
  • File name: {Name}.cs (one handler per file).
  • Implementation points:
    • Implement IDomainEventHandler with the method signature Handle(TEvent, CancellationToken).
    • Send commands through the mediator to drive aggregates; do not modify Db directly; comply with transactions/cancellation.
    • Inject dependencies via the primary constructor.

Example: Domain event handler triggers a command

using ProjectName.Domain.DomainEvents;
using ProjectName.Web.Application.Commands.Users;

namespace ProjectName.Web.Application.DomainEventHandlers;

public class UserCreatedDomainEventHandlerForSendWelcome(IMediator mediator)
    : IDomainEventHandler<UserCreatedDomainEvent>
{
    public async Task Handle(UserCreatedDomainEvent domainEvent, CancellationToken cancellationToken)
    {
        var command = new SendWelcomeEmailCommand(domainEvent.User.Id, domainEvent.User.Email, domainEvent.User.Name);
        await mediator.Send(command, cancellationToken);
    }
}

Repositories

  • Naming rule: interface I{Aggregate}Repository; implementation {Aggregate}Repository.
  • Directory: src/ProjectName.Infrastructure/Repositories.
  • File name: {Aggregate}Repository.cs (interface and implementation in the same file).
  • Implementation points:
    • The interface inherits IRepository<TEntity, TKey>; the implementation inherits RepositoryBase<TEntity, TKey, ApplicationDbContext>.
    • The DbContext property accesses the DbSet; prefer the base class default methods and avoid duplicate implementations.
    • Dependency injection registration is completed automatically by the framework.

Example: User repository

using ProjectName.Domain.AggregatesModel.UserAggregate;

namespace ProjectName.Infrastructure.Repositories;

public interface IUserRepository : IRepository<User, UserId>
{
    Task<bool> EmailExistsAsync(string email, CancellationToken cancellationToken = default);
}

public class UserRepository(ApplicationDbContext context)
    : RepositoryBase<User, UserId, ApplicationDbContext>(context), IUserRepository
{
    public async Task<bool> EmailExistsAsync(string email, CancellationToken cancellationToken = default)
    {
        return await DbContext.Users.AnyAsync(x => x.Email == email, cancellationToken);
    }
}

Entity configuration

  • Naming rule: {Entity}EntityTypeConfiguration.
  • Directory: src/ProjectName.Infrastructure/EntityConfigurations.
  • File name: {Entity}EntityTypeConfiguration.cs.
  • Implementation points:
    • Implement IEntityTypeConfiguration.
    • The primary key must be configured; set MaxLength for string columns; IsRequired for required columns; add HasComment annotations to fields; add indexes as needed.
    • Strongly typed IDs: IGuidStronglyTypedId → UseGuidVersion7ValueGenerator; IInt64StronglyTypedId → UseSnowFlakeValueGenerator; RowVersion requires no configuration; do not customize converters.

Example: Entity configuration (aggregate root + child entity one-to-many)

// 聚合根配置
public class UserEntityTypeConfiguration : IEntityTypeConfiguration<User>
{
    public void Configure(EntityTypeBuilder<User> builder)
    {
        builder.ToTable("users");
        builder.HasKey(x => x.Id);
        builder.Property(x => x.Id).UseGuidVersion7ValueGenerator().HasComment("用户标识");
        builder.Property(x => x.Name).IsRequired().HasMaxLength(50).HasComment("用户姓名");
        builder.HasMany(u => u.RefreshTokens).WithOne().HasForeignKey("UserId").OnDelete(DeleteBehavior.Cascade);
    }
}

// 子实体配置
internal class UserRefreshTokenEntityTypeConfiguration : IEntityTypeConfiguration<UserRefreshToken>
{
    public void Configure(EntityTypeBuilder<UserRefreshToken> builder)
    {
        builder.ToTable("user_refresh_token");
        builder.HasKey(x => x.Id);
        builder.Property(x => x.Id).UseSnowFlakeValueGenerator().HasComment("刷新令牌标识");
        builder.Property(x => x.Token).HasMaxLength(500).IsRequired().HasComment("令牌");
    }
}

DbContext

  • Naming rule: ApplicationDbContext.
  • Directory: src/ProjectName.Infrastructure.
  • File name: ApplicationDbContext.cs.
  • Implementation points:
    • Add aggregate DbSet properties (public DbSet<T> Name => Set<T>();)。
    • Automatically apply entity configurations via ApplyConfigurationsFromAssembly.

Example: DbSet registration

using ProjectName.Domain.AggregatesModel.UserAggregate;

namespace ProjectName.Infrastructure;

public partial class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options, IMediator mediator)
    : AppDbContextBase(options, mediator)
{
    public DbSet<User> Users => Set<User>();
}

Integration events

  • Naming rule: {Entity}{Action}IntegrationEvent (record).
  • Directory: src/ProjectName.Web/Application/IntegrationEvents.
  • File name: {Entity}{Action}IntegrationEvent.cs.
  • Implementation points: immutable; do not directly reference aggregate instances, avoid sensitive information; use record for complex types in the same file.

Example: user creation integration event

using ProjectName.Domain.AggregatesModel.UserAggregate;

namespace ProjectName.Web.Application.IntegrationEvents;

public record UserCreatedIntegrationEvent(UserId UserId, string Name, string Email, DateTime CreatedTime);

Integration event converters

  • Naming rule: {Entity}{Action}IntegrationEventConverter.
  • Directory: src/ProjectName.Web/Application/IntegrationEventConverters.
  • File name: {Entity}{Action}IntegrationEventConverter.cs.
  • Implementation points: implement IIntegrationEventConverter<TDomainEvent, TIntegrationEvent>, map domain events to integration events; use record.

Example: domain event to integration event

using ProjectName.Domain.DomainEvents;
using ProjectName.Web.Application.IntegrationEvents;

namespace ProjectName.Web.Application.IntegrationEventConverters;

public class UserCreatedIntegrationEventConverter
    : IIntegrationEventConverter<UserCreatedDomainEvent, UserCreatedIntegrationEvent>
{
    public UserCreatedIntegrationEvent Convert(UserCreatedDomainEvent domainEvent)
    {
        var user = domainEvent.User;
        return new UserCreatedIntegrationEvent(user.Id, user.Name, user.Email, DateTime.UtcNow);
    }
}

Integration event handlers

  • Naming rule: {IntegrationEvent}HandlerFor{Action}.
  • Directory: src/ProjectName.Web/Application/IntegrationEventHandlers.
  • File name: {Name}.cs (one handler per file).
  • Implementation points: implement IIntegrationEventHandler, drive aggregates through commands in HandleAsync, do not directly modify Db; inject dependencies via primary constructor.

Example: handling an integration event

using ProjectName.Web.Application.Commands.Users;
using ProjectName.Web.Application.IntegrationEvents;

namespace ProjectName.Web.Application.IntegrationEventHandlers;

public class UserCreatedIntegrationEventHandlerForSendWelcomeEmail(
    ILogger<UserCreatedIntegrationEventHandlerForSendWelcomeEmail> logger,
    IMediator mediator)
    : IIntegrationEventHandler<UserCreatedIntegrationEvent>
{
    public async Task HandleAsync(UserCreatedIntegrationEvent integrationEvent, CancellationToken cancellationToken)
    {
        logger.LogInformation("发送欢迎邮件:{UserId}", integrationEvent.UserId);
        var command = new SendWelcomeEmailCommand(integrationEvent.UserId, integrationEvent.Email, integrationEvent.Name);
        await mediator.Send(command, cancellationToken);
    }
}

Unit tests

  • Naming rule: {Method}{Scenario}{Expected}。
  • Directory: test/ProjectName.{Layer}.Tests/.
  • File name: {Entity}Tests.cs (or split by module).
  • Implementation points:
    • Use the AAA pattern; one scenario per unit test; cover normal/exception, domain events, state/invariants, boundaries.
    • Use Theory/InlineData; compare strongly typed IDs directly with new; use relative time comparisons (>=, etc.).
    • Use GetDomainEvents() to verify event types and counts; factories/Builders may be used to generate test data.

Pre-commit self-check

  • Commands/queries/handlers are all async and pass CancellationToken; no manual calls to SaveChanges/UpdateAsync appear.
  • Domain event publishing is complete, handlers do not directly modify data across aggregates; integration events have no aggregate references and include necessary audit information.
  • API endpoints only call mediator; requests/responses and strongly typed IDs do not unwrap .Value; routes/tags/authorization are correct. Query output may use Response or DTO.
  • EF configuration includes primary key, length, required, comments; strongly typed IDs use the standard value generator.

Example: aggregate unit test

public class UserTests
{
    [Fact]
    public void Constructor_ShouldRaiseCreatedEvent()
    {
        var user = new User("Alice", "alice@example.com");

        Assert.Equal("Alice", user.Name);
        Assert.Single(user.GetDomainEvents());
        Assert.IsType<UserCreatedDomainEvent>(user.GetDomainEvents().First());
    }

    [Fact]
    public void ChangeEmail_ShouldRaiseChangedEvent()
    {
        var user = new User("Bob", "old@example.com");
        user.ClearDomainEvents();

        user.ChangeEmail("new@example.com");

        Assert.Equal("new@example.com", user.Email);
        Assert.IsType<UserEmailChangedDomainEvent>(user.GetDomainEvents().Single());
    }
}

KnownException reference

if (Paid) throw new KnownException("Order has been paid");
var order = await orderRepository.GetAsync(request.OrderId, cancellationToken)
             ?? throw new KnownException($"未找到订单,OrderId = {request.OrderId}");
order.OrderPaid();

Common using tips

  • Web global: global using FluentValidation; MediatR; NetCorePal.Extensions.Primitives; FastEndpoints; NetCorePal.Extensions.Dto; NetCorePal.Extensions.Domain.
  • Infrastructure global: global using Microsoft.EntityFrameworkCore; Microsoft.EntityFrameworkCore.Metadata.Builders; NetCorePal.Extensions.Primitives.
  • Domain global: global using NetCorePal.Extensions.Domain; NetCorePal.Extensions.Primitives.
  • Tests global: global using Xunit; NetCorePal.Extensions.Primitives.
  • Additional: query handlers need to reference the aggregate namespace and Infrastructure; entity configurations need to reference the corresponding aggregate; endpoints need to reference the aggregate, command, and query namespaces.