From dknet-minimal
Create CRUD actions (Create/Update/Delete), DTOs, validators, specs, and domain events at the AppServices layer using this project's SlimMessageBus + FluentResults + Mapster pattern. Use after domain entity and EF Core config are ready.
npx claudepluginhub baoduy/dknet.templates --plugin dknet-minimalThis skill uses the workspace's default tool permissions.
Create the application service layer — request/response DTOs, command handlers, validators, query specifications, and domain events — using SlimMessageBus Fluent patterns.
Guides Next.js Cache Components and Partial Prerendering (PPR): 'use cache' directives, cacheLife(), cacheTag(), revalidateTag() for caching, invalidation, static/dynamic optimization. Auto-activates on cacheComponents: true.
Processes PDFs: extracts text/tables/images, merges/splits/rotates pages, adds watermarks, creates/fills forms, encrypts/decrypts, OCRs scans. Activates on PDF mentions or output requests.
Share bugs, ideas, or general feedback.
Create the application service layer — request/response DTOs, command handlers, validators, query specifications, and domain events — using SlimMessageBus Fluent patterns.
[GenerateDto] for all)This project does NOT use custom repository interfaces or service classes. Instead:
Fluents.Requests.IWitResponse<TDto> (with response) or Fluents.Requests.INoResponse (without)Fluents.Requests.IHandler<TRequest, TResponse> or Fluents.Requests.IHandler<TRequest>IRepositorySpec (injected) — a generic spec-based repositorySpecification<TEntity> patternMapster via IMapper + [MapsFrom] attributeFluentResults — Result.Ok(dto), Result.Fail<T>("message")mapper.ResultOf<TDto>(entity) — maps AFTER SaveChangesAll requests extend RequestBase which provides [JsonIgnore] string? ByUser — auto-filled by SetUserIdPropertyFilter from JWT claims.
src/ApiEndpoints/Minimal.AppServices/
├── {Feature}/
│ └── V{N}/
│ ├── {Entity}Dto.cs ← Response DTO (GenerateDto)
│ ├── Actions/
│ │ ├── Create.cs ← Request + Validator + Handler
│ │ ├── Update.cs ← Request + Validator + Handler
│ │ └── Delete.cs ← Request + Handler
│ ├── Specs/
│ │ └── SpecGet{Entity}.cs ← Query specification
│ └── Events/
│ └── {Event}Handlers.cs ← Event record + handlers
├── Share/
│ ├── RequestBase.cs ← DO NOT MODIFY
│ ├── PageableQuery.cs ← DO NOT MODIFY
│ ├── IPrincipalProvider.cs ← DO NOT MODIFY
│ └── Generics/ ← Generic list/paged specs
├── Extensions/
│ ├── MapsFromAttribute.cs ← DO NOT MODIFY
│ └── LazyMapper/ ← DO NOT MODIFY
└── GlobalUsings.cs ← Global imports (Fluents, FluentResults, etc.)
global using DKNet.SlimBus.Extensions; // Fluents.Requests, Fluents.Queries, etc.
global using System.ComponentModel.DataAnnotations;
global using System.Text.Json.Serialization;
global using FluentResults;
global using FluentValidation;
global using Mapster;
global using MapsterMapper;
Create src/ApiEndpoints/Minimal.AppServices/{Feature}/V1/{Entity}Dto.cs:
using DKNet.EfCore.DtoGenerator;
using Minimal.AppServices.Extensions;
namespace Minimal.AppServices.{Feature}.V1;
[GenerateDto(typeof({Entity}), Exclude = [])]
[MapsFrom(typeof({Entity}))]
public sealed partial record {Entity}Dto;
[GenerateDto] auto-generates all properties from the entity. Use Exclude = ["InternalProp"] to hide fields.
Create src/ApiEndpoints/Minimal.AppServices/{Feature}/V1/Actions/Create.cs:
using System.Data;
using DKNet.EfCore.Specifications;
using DKNet.EfCore.Specifications.Extensions;
using Minimal.AppServices.{Feature}.V1.Events;
using Minimal.AppServices.{Feature}.V1.Specs;
using Minimal.AppServices.Extensions;
using Minimal.AppServices.Share;
namespace Minimal.AppServices.{Feature}.V1.Actions;
/// <summary>
/// Command to create a new {entity}.
/// </summary>
[MapsFrom(typeof({Entity}))]
public sealed record Create{Entity}Request : RequestBase, Fluents.Requests.IWitResponse<{Entity}Dto>
{
#region Properties
[Required] public string {RequiredField1} { get; set; } = null!;
[Required] public string {RequiredField2} { get; set; } = null!;
public string? {OptionalField} { get; set; }
// Auto-generated fields (hidden from API)
[JsonIgnore] public string {AutoField} { get; set; } = null!;
#endregion
}
/// <summary>
/// Validator for <see cref="Create{Entity}Request"/>.
/// </summary>
internal sealed class Create{Entity}RequestValidator : AbstractValidator<Create{Entity}Request>
{
public Create{Entity}RequestValidator()
{
RuleFor(a => a.{RequiredField1}).NotEmpty().Length({min}, {max});
RuleFor(a => a.{RequiredField2}).NotEmpty().EmailAddress().Length(1, {max});
}
}
/// <summary>
/// Handler: validates uniqueness, maps to entity, persists, publishes event.
/// </summary>
internal sealed class Create{Entity}Handler(
IRepositorySpec repository,
// Inject domain services if needed:
// I{Service} serviceProvider,
IMapper mapper)
: Fluents.Requests.IHandler<Create{Entity}Request, {Entity}Dto>
{
public async Task<IResult<{Entity}Dto>> OnHandle(
Create{Entity}Request request,
CancellationToken cancellationToken)
{
// 1. Auto-generate fields if needed
// if (string.IsNullOrWhiteSpace(request.{AutoField}))
// request.{AutoField} = await serviceProvider.NextValueAsync();
// 2. Check duplicates
if (await repository.AnyAsync(
new SpecGet{Entity}(by{UniqueField}: request.{UniqueField}),
cancellationToken: cancellationToken))
{
return Result.Fail<{Entity}Dto>($"{UniqueField} {request.{UniqueField}} already exists.");
}
// 3. Map request to entity
var entity = mapper.Map<{Entity}>(request);
// 3b. Defensive check — ensure auto-generated fields were mapped
// if (string.IsNullOrEmpty(entity.{AutoField}))
// throw new NoNullAllowedException(nameof(entity.{AutoField}));
// 4. Persist
await repository.AddAsync(entity, cancellationToken);
// 5. Publish domain event
entity.AddEvent(new {Entity}CreatedEvent(entity.Id, entity.{NameField}));
// 6. Return lazy-mapped DTO (resolves after SaveChanges)
return mapper.ResultOf<{Entity}Dto>(entity);
}
}
Create src/ApiEndpoints/Minimal.AppServices/{Feature}/V1/Actions/Update.cs:
using DKNet.EfCore.Specifications;
using DKNet.EfCore.Specifications.Extensions;
using Minimal.AppServices.{Feature}.V1.Specs;
using Minimal.AppServices.Extensions;
using Minimal.AppServices.Share;
namespace Minimal.AppServices.{Feature}.V1.Actions;
/// <summary>
/// Command to update an existing {entity}.
/// </summary>
[MapsFrom(typeof({Entity}))]
public record Update{Entity}Request : RequestBase, Fluents.Requests.IWitResponse<{Entity}Dto>
{
public required Guid Id { get; init; }
public string? {MutableField1} { get; init; }
public string? {MutableField2} { get; init; }
}
internal sealed class Update{Entity}Handler(
IMapper mapper,
IRepositorySpec repo) : Fluents.Requests.IHandler<Update{Entity}Request, {Entity}Dto>
{
public async Task<IResult<{Entity}Dto>> OnHandle(
Update{Entity}Request request,
CancellationToken cancellationToken)
{
if (request.Id == Guid.Empty)
return Result.Fail<{Entity}Dto>("The Id is invalid.");
var entity = await repo.FirstOrDefaultAsync(
new SpecGet{Entity}(request.Id), cancellationToken);
if (entity == null)
return Result.Fail<{Entity}Dto>($"The {Entity} {request.Id} is not found.");
// Call entity mutation method
entity.Update({mutable params}, request.ByUser!);
return Result.Ok(mapper.Map<{Entity}Dto>(entity));
}
}
Create src/ApiEndpoints/Minimal.AppServices/{Feature}/V1/Actions/Delete.cs:
using DKNet.EfCore.Specifications;
using DKNet.EfCore.Specifications.Extensions;
using Minimal.AppServices.{Feature}.V1.Specs;
using Minimal.AppServices.Share;
namespace Minimal.AppServices.{Feature}.V1.Actions;
/// <summary>
/// Command to delete a {entity} by ID.
/// </summary>
public record Delete{Entity}Request : RequestBase, Fluents.Requests.INoResponse
{
public required Guid Id { get; init; }
}
internal sealed class Delete{Entity}Handler(IRepositorySpec repository)
: Fluents.Requests.IHandler<Delete{Entity}Request>
{
public async Task<IResultBase> OnHandle(
Delete{Entity}Request request,
CancellationToken cancellationToken)
{
if (request.Id == Guid.Empty)
{
return Result.Fail("The Id is invalid.")
.WithError(new Error("The Id is invalid.") { Metadata = { ["field"] = nameof(request.Id) } });
}
var entity = await repository.FirstOrDefaultAsync(
new SpecGet{Entity}(request.Id), cancellationToken);
if (entity == null)
return Result.Fail($"The {Entity} {request.Id} is not found.");
repository.Delete(entity);
return Result.Ok();
}
}
Create src/ApiEndpoints/Minimal.AppServices/{Feature}/V1/Specs/SpecGet{Entity}.cs:
using DKNet.EfCore.Specifications;
namespace Minimal.AppServices.{Feature}.V1.Specs;
internal sealed class SpecGet{Entity} : Specification<{Entity}>
{
public SpecGet{Entity}(Guid? byId = null, string? by{UniqueField} = null)
{
var predicator = CreatePredicate();
if (byId is not null)
predicator = predicator.And(a => a.Id == byId);
if (!string.IsNullOrEmpty(by{UniqueField}))
predicator = predicator.And(a => a.{UniqueField} == by{UniqueField});
WithFilter(predicator);
}
}
Create src/ApiEndpoints/Minimal.AppServices/{Feature}/V1/Events/{Entity}CreatedEventHandlers.cs:
namespace Minimal.AppServices.{Feature}.V1.Events;
/// <summary>
/// Domain event published when a {entity} is created.
/// </summary>
public sealed record {Entity}CreatedEvent(Guid Id, string {NameField});
/// <summary>
/// In-memory handler for {Entity}CreatedEvent.
/// </summary>
internal sealed class {Entity}CreatedEventFromMemoryHandler
: Fluents.EventsConsumers.IHandler<{Entity}CreatedEvent>
{
public Task OnHandle({Entity}CreatedEvent notification, CancellationToken cancellationToken)
{
// Handle event: logging, notifications, side-effects
return Task.CompletedTask;
}
}
Edit src/ApiEndpoints/Minimal.AppServices/GlobalUsings.cs:
global using Minimal.Domains.Features.{Feature}.Entities;
CreateProfileRequest : RequestBase, Fluents.Requests.IWitResponse<CustomerProfileDto>CreateProfileCommandValidator : AbstractValidator<CreateProfileRequest>CreateProfileCommandHandler(IRepositorySpec, IMembershipService, IMapper) : Fluents.Requests.IHandler<CreateProfileRequest, CustomerProfileDto>mapper.Map<CustomerProfile>(request) → repository.AddAsync → AddEvent(new ProfileCreatedEvent(...)) → mapper.ResultOf<CustomerProfileDto>(profile)UpdateProfileRequest : RequestBase, Fluents.Requests.IWitResponse<CustomerProfileDto>entity.Update(...) → return mapper.Map<CustomerProfileDto>(entity)DeleteProfileRequest : RequestBase, Fluents.Requests.INoResponserepository.Delete(entity) → Result.Ok()[GenerateDto] + [MapsFrom] attributesFluents.Requests.IWitResponse<{Dto}>[MapsFrom(typeof({Entity}))] attributeFluents.Requests.IWitResponse<{Dto}>Fluents.Requests.INoResponseRequestBase (provides ByUser)internal sealed and extend AbstractValidator<T>internal sealed with primary constructor injectionIRepositorySpec (not custom repos)mapper.ResultOf<T>() for lazy mappingIResultBase (not IResult<T>)sealed record typesFluents.EventsConsumers.IHandler<T>internal sealed extending Specification<T>Minimal.AppServices.{Feature}.V1.Actionsdotnet build src/DKNet.Templates.sln -c Release passes| Mistake | Fix |
|---|---|
Creating custom IRepository interface | Use IRepositorySpec — it's already registered |
Using record struct for requests | Use record (reference type) — needed for bus serialization |
Making handlers public | Must be internal sealed |
Missing [MapsFrom] on create request | Mapster needs this to map request → entity |
Using Result.Ok(entity) instead of mapper.ResultOf<T>() | Lazy mapping ensures DTO reflects post-SaveChanges state |
Forgetting request.ByUser! in Update | Must pass user ID to entity mutation methods |
Not adding [JsonIgnore] on auto-fields | Fields like MembershipNo shouldn't be client-settable |
After creating AppServices actions, proceed to: → dknet-endpoint-config skill to expose these actions as REST API endpoints