The thin use case
An application use case is deliberately thin. It’s a sealed class with its ports injected through the constructor and one method, Execute, that returns a Result. Its job is orchestration: guard the request at the trust boundary, build the domain objects, call the port, return the Result. The domain rules live in the domain; the IO lives behind ports; the use case just conducts.
public sealed class SaveFlightNote
{
private static readonly EjError NoFlightKeys =
new(nameof(SaveFlightNoteRequest.EjdpUniqueFlightKeys), "At least one flight is required.");
private readonly IFlightNoteRepository _flightNoteRepository;
public SaveFlightNote(IFlightNoteRepository flightNoteRepository) =>
_flightNoteRepository = flightNoteRepository;
[Trace]
public async Task<Result<SaveFlightNoteResult>> Execute(
SaveFlightNoteRequest request, CancellationToken cancellationToken)
{
if (request.EjdpUniqueFlightKeys is not { Count: > 0 })
return Result<SaveFlightNoteResult>.Failure(NoFlightKeys);
var notes = new List<FlightNoteVo>(request.EjdpUniqueFlightKeys.Count);
var errors = new List<EjError>();
foreach (var key in request.EjdpUniqueFlightKeys)
{
var noteResult = FlightNoteVo.Create(key, request.Body);
if (noteResult.IsSuccess) notes.Add(noteResult.Value);
else errors.AddRange(noteResult.Errors);
}
if (errors.Count > 0)
return Result<SaveFlightNoteResult>.Failure(errors.ToArray());
await _flightNoteRepository.SaveMany(notes, cancellationToken);
return Result<SaveFlightNoteResult>.Success(
new SaveFlightNoteResult(notes.Select(n => n.EjdpUniqueFlightKey).ToArray()));
}
}Two real details worth copying. The trust-boundary guard lives IN the use case, not in decorative DTO attributes: the code comment notes the DTO’s [Required] annotations are inert because no validation filter is wired, so the real check is the is not { Count: > 0 } guard here. And this use case accumulates its own errors in a loop rather than a FailIf chain, because it needs to build a List as it goes, which is exactly the “imperative is fine when the fluent chain is structurally incompatible” exception (convention D5, anti-pattern #21).
Try it yourself
Check the use-case shape
Verify you can recognise the anatomy.
Reading SaveFlightNote, you can point to each part: sealed class; a ctor-injected IFlightNoteRepository port (an interface, not a concrete type); an Execute(request, CancellationToken) returning Task<Result<...>>; a trust-boundary guard first; domain objects built via FlightNoteVo.Create; a single port call (SaveMany); and a Result returned on every path. No SQL, no domain calculation, no new FlightNote() DBO in sight.
Where the trust-boundary guard lives
A subtle real-world point.
In SaveFlightNote, why is the "at least one flight key" check written as an explicit guard in Execute rather than relying on a [Required]/[MinLength] attribute on the request DTO?
Reveal answer
Because those DTO annotations are inert here: no minimal-API validation filter is wired, so a request omitting the array deserializes the list to null and the attributes never fire. The real trust-boundary check has to live in the use case, so it does.