Building a Feature Slice — End to End
Build a complete vertical slice from endpoint to database: command, validator, handler, domain logic, persistence, and response — a full CreatePrescription feature as a worked example.
A Complete Feature Slice
A vertical slice for CreatePrescription includes:
1. Endpoint: receives HTTP POST, sends Command to MediatR
2. Command: immutable record with all input data
3. Validator: FluentValidation rules for the command
4. Handler: business logic + persistence
5. Response: what the endpoint returns to the client
All five files live in Features/Prescriptions/CreatePrescription/
No other folder needs to change.Step 1 — Command and Response
// Features/Prescriptions/CreatePrescription/CreatePrescriptionCommand.cs
public sealed record CreatePrescriptionCommand(
Guid PatientId,
string MedicationName,
decimal DoseAmount,
string DoseUnit,
Guid PrescriberId,
string? Instructions) : IRequest<Result<CreatePrescriptionResponse>>;
// Features/Prescriptions/CreatePrescription/CreatePrescriptionResponse.cs
public sealed record CreatePrescriptionResponse(
Guid PrescriptionId,
string MedicationName,
decimal DoseAmount,
string DoseUnit,
DateTime PrescribedAt);Step 2 — Validator
// Features/Prescriptions/CreatePrescription/CreatePrescriptionValidator.cs
public sealed class CreatePrescriptionValidator
: AbstractValidator<CreatePrescriptionCommand>
{
public CreatePrescriptionValidator()
{
RuleFor(c => c.PatientId)
.NotEmpty()
.WithMessage("PatientId is required.");
RuleFor(c => c.MedicationName)
.NotEmpty()
.MaximumLength(200)
.WithMessage("Medication name must be between 1 and 200 characters.");
RuleFor(c => c.DoseAmount)
.GreaterThan(0)
.LessThanOrEqualTo(10000)
.WithMessage("Dose amount must be between 0 and 10000.");
RuleFor(c => c.DoseUnit)
.NotEmpty()
.Must(u => new[] { "mg", "mcg", "g", "units/kg", "IU" }.Contains(u))
.WithMessage("Dose unit must be a recognized unit.");
RuleFor(c => c.PrescriberId)
.NotEmpty()
.WithMessage("PrescriberId is required.");
}
}Step 3 — Handler
// Features/Prescriptions/CreatePrescription/CreatePrescriptionHandler.cs
public sealed class CreatePrescriptionHandler
: IRequestHandler<CreatePrescriptionCommand, Result<CreatePrescriptionResponse>>
{
private readonly ApplicationDbContext _db;
private readonly IUnitOfWork _uow;
private readonly IMediator _mediator;
public CreatePrescriptionHandler(
ApplicationDbContext db,
IUnitOfWork uow,
IMediator mediator)
{
_db = db;
_uow = uow;
_mediator = mediator;
}
public async Task<Result<CreatePrescriptionResponse>> Handle(
CreatePrescriptionCommand command, CancellationToken ct)
{
// 1. Load patient — fail fast if not found
var patient = await _db.Patients
.FirstOrDefaultAsync(p => p.Id == new PatientId(command.PatientId), ct);
if (patient is null)
return Result.Failure<CreatePrescriptionResponse>(DomainErrors.Patient.NotFound);
// 2. Load prescriber — fail fast if not found
var prescriber = await _db.Clinicians
.FirstOrDefaultAsync(c => c.Id == new ClinicianId(command.PrescriberId), ct);
if (prescriber is null)
return Result.Failure<CreatePrescriptionResponse>(DomainErrors.Clinician.NotFound);
// 3. Domain: create prescription
var prescriptionResult = Prescription.Create(
patient.Id,
command.MedicationName,
new DosageValue(command.DoseAmount, command.DoseUnit),
prescriber.Id,
command.Instructions);
if (prescriptionResult.IsFailure)
return Result.Failure<CreatePrescriptionResponse>(prescriptionResult.Error);
var prescription = prescriptionResult.Value;
// 4. Deactivate previous active prescriptions for same medication
await _db.Prescriptions
.Where(p => p.PatientId == patient.Id &&
p.MedicationName == command.MedicationName &&
p.IsActive)
.ExecuteUpdateAsync(
s => s.SetProperty(p => p.IsActive, false), ct);
// 5. Persist
await _db.Prescriptions.AddAsync(prescription, ct);
await _uow.SaveChangesAsync(ct);
// 6. Publish domain event (fire-and-forget side effects)
await _mediator.Publish(new PrescriptionCreatedNotification(
prescription.Id.Value, patient.Id.Value, command.MedicationName), ct);
return Result.Success(new CreatePrescriptionResponse(
prescription.Id.Value,
prescription.MedicationName,
prescription.Dose.Amount,
prescription.Dose.Unit,
prescription.PrescribedAt));
}
}Step 4 — Endpoint
// Features/Prescriptions/CreatePrescription/CreatePrescriptionEndpoint.cs
public sealed class CreatePrescriptionEndpoint : IEndpoint
{
public void MapEndpoint(IEndpointRouteBuilder app)
{
app.MapPost("api/prescriptions",
async (CreatePrescriptionRequest request, ISender sender, CancellationToken ct) =>
{
var command = new CreatePrescriptionCommand(
request.PatientId,
request.MedicationName,
request.DoseAmount,
request.DoseUnit,
request.PrescriberId,
request.Instructions);
var result = await sender.Send(command, ct);
return result.IsSuccess
? Results.CreatedAtRoute("GetPrescription",
new { prescriptionId = result.Value.PrescriptionId },
result.Value)
: result.Error == DomainErrors.Patient.NotFound
? Results.NotFound(new { error = result.Error.Description })
: Results.UnprocessableEntity(new { error = result.Error.Description });
})
.WithName("CreatePrescription")
.WithSummary("Create a new prescription for a patient")
.Produces<CreatePrescriptionResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status422UnprocessableEntity)
.RequireAuthorization("CanPrescribe");
}
}
// Separate request DTO (decoupled from the command)
public sealed record CreatePrescriptionRequest(
Guid PatientId,
string MedicationName,
decimal DoseAmount,
string DoseUnit,
Guid PrescriberId,
string? Instructions);Pipeline Execution Flow
HTTP POST /api/prescriptions
→ Endpoint receives request, maps to Command
→ ISender.Send(command)
→ LoggingBehavior: logs "Handling CreatePrescriptionCommand"
→ ValidationBehavior: runs CreatePrescriptionValidator
→ If invalid: throws ValidationException → 400
→ CreatePrescriptionHandler.Handle()
→ Load patient, prescriber
→ Create domain entity
→ Deactivate old prescriptions
→ SaveChanges
→ Publish PrescriptionCreatedNotification
→ LoggingBehavior: logs "Handled CreatePrescriptionCommand in 45ms"
→ Endpoint maps Result to 201 Created or error responseProduction issue I've seen: A team put all the prescription creation logic directly in a controller action method — 80 lines of code including validation, database queries, business rules, and event publishing. When a second endpoint (a bulk import endpoint) needed the same logic, they copied and pasted it. Six months later, a business rule changed, and only one copy was updated. The feature slice pattern with a command handler makes the logic discoverable and reusable — call the same handler from any endpoint, background job, or test.
Key Takeaway
A complete feature slice contains: command (input), validator (rules), handler (logic + persistence), endpoint (HTTP mapping), and response (output) — all in one folder. The handler is the single source of truth for the feature's business logic. The pipeline behaviors run cross-cutting concerns without the handler knowing about them. Testing the handler directly (no HTTP context needed) gives fast, focused unit tests.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.