name: dknet-appservices-actions description: 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.
Skill: AppServices Actions (CRUD + Business Logic)
Create the application service layer — request/response DTOs, command handlers, validators, query specifications, and domain events — using SlimMessageBus Fluent patterns.
When to Use
- After completing dknet-domain-entity + dknet-efcore-config
- Adding Create, Update, Delete actions for an entity
- Adding query specifications for filtering/searching
- Publishing domain events after mutations
Inputs Required
- Entity class (from domain): with all properties
- DTO fields: which properties to expose (or use
[GenerateDto]for all) - Create fields: what's required on creation?
- Update fields: what's mutable?
- Business rules: duplicate checks, validation constraints
- Events to publish: what happened notifications?
Project Conventions (from actual codebase)
Core Pattern: SlimMessageBus Fluent Handlers
This project does NOT use custom repository interfaces or service classes. Instead:
- Commands implement
Fluents.Requests.IWitResponse<TDto>(with response) orFluents.Requests.INoResponse(without) - Handlers implement
Fluents.Requests.IHandler<TRequest, TResponse>orFluents.Requests.IHandler<TRequest> - Data access uses
IRepositorySpec(injected) — a generic spec-based repository - Queries use
Specification<TEntity>pattern - Mapping uses
MapsterviaIMapper+[MapsFrom]attribute - Results use
FluentResults—Result.Ok(dto),Result.Fail<T>("message") - Lazy mapping:
mapper.ResultOf<TDto>(entity)— maps AFTER SaveChanges
Request Base Class
All requests extend RequestBase which provides [JsonIgnore] string? ByUser — auto-filled by SetUserIdPropertyFilter from JWT claims.
File Locations
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 Usings (already available)
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;
Step-by-Step
Step 1: Create Response DTO
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.
Step 2: Create Action — Create.cs
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);
}
}
Step 3: Create Action — Update.cs
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));
}
}
Step 4: Create Action — Delete.cs
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();
}
}
Step 5: Create Query Specification
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);
}
}
Step 6: Create Domain Events
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;
}
}
Step 7: Add Entity to GlobalUsings (if frequently referenced)
Edit src/ApiEndpoints/Minimal.AppServices/GlobalUsings.cs:
global using Minimal.Domains.Features.{Feature}.Entities;
Reference: CustomerProfile Actions (actual production code)
Create Pattern
CreateProfileRequest : RequestBase, Fluents.Requests.IWitResponse<CustomerProfileDto>CreateProfileCommandValidator : AbstractValidator<CreateProfileRequest>CreateProfileCommandHandler(IRepositorySpec, IMembershipService, IMapper) : Fluents.Requests.IHandler<CreateProfileRequest, CustomerProfileDto>- Flow: generate membership → check duplicate via spec →
mapper.Map<CustomerProfile>(request)→repository.AddAsync→AddEvent(new ProfileCreatedEvent(...))→mapper.ResultOf<CustomerProfileDto>(profile)
Update Pattern
UpdateProfileRequest : RequestBase, Fluents.Requests.IWitResponse<CustomerProfileDto>- Handler: validate Id → fetch via spec → call
entity.Update(...)→ returnmapper.Map<CustomerProfileDto>(entity)
Delete Pattern
DeleteProfileRequest : RequestBase, Fluents.Requests.INoResponse- Handler: validate Id → fetch via spec →
repository.Delete(entity)→Result.Ok()
Validation Checklist
- Response DTO uses
[GenerateDto]+[MapsFrom]attributes - Create request implements
Fluents.Requests.IWitResponse<{Dto}> - Create request has
[MapsFrom(typeof({Entity}))]attribute - Update request implements
Fluents.Requests.IWitResponse<{Dto}> - Delete request implements
Fluents.Requests.INoResponse - All requests extend
RequestBase(providesByUser) - Validators are
internal sealedand extendAbstractValidator<T> - Handlers are
internal sealedwith primary constructor injection - Handlers use
IRepositorySpec(not custom repos) - Create handler checks duplicates via Specification before adding
- Create handler uses
mapper.ResultOf<T>()for lazy mapping - Update handler fetches entity, calls mutation method, returns mapped DTO
- Delete handler returns
IResultBase(notIResult<T>) - Domain events are
sealed recordtypes - Event handlers implement
Fluents.EventsConsumers.IHandler<T> - Spec class is
internal sealedextendingSpecification<T> - Namespace follows
Minimal.AppServices.{Feature}.V1.Actions -
dotnet build src/DKNet.Templates.sln -c Releasepasses
Common Mistakes
| 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 |
Next Steps
After creating AppServices actions, proceed to: → dknet-endpoint-config skill to expose these actions as REST API endpoints