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
- 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.
- Add aggregate DbSet properties (
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.