Context
Finalizing a shift trade is a stateful workflow operation. By the time a manager makes an approval decision, the original trade request, the responding employee, and the underlying schedule may all have changed.
The handler therefore performs a series of validations before allowing the approval to affect the schedule. Once the approval is finalized, the workflow determines whether the remaining approval requirements have also been satisfied.
The handler protects the integrity of the schedule by re-validating the trade at the moment an approval is finalized.
Engineering Challenge
The complexity comes from the number of conditions that must remain true before a schedule change can occur.
The request must still be pending approval.
The manager's approval must not have been voided.
The offered shift must still belong to the requesting employee.
Both shifts must still occur in the future.
The responding employee cannot already be scheduled during the offered shift.
Every required approval group must contain a finalized approval before the trade is completed.
Approach
The handler follows a deliberate sequence: validate the workflow state, validate the approval and related entities, verify the schedule, persist the decision, and then determine whether the overall trade can be finalized.
The same operation handles both give-up and swap requests. A give-up transfers the requesting shift to the responding employee, while a swap updates both employees' shifts.
Production Implementation
The following is the actual handler implementation used for the workflow.
public class FinalizeShiftTradeApprovalHandler(
IScheduleRepository scheduleRepository,
IShiftTradeRepository shiftTradeRepository)
: IRequestHandler<FinalizeShiftTradeApprovalCommand, Result<bool>>
{
private readonly IScheduleRepository _scheduleRepository =
scheduleRepository;
private readonly IShiftTradeRepository _shiftTradeRepository =
shiftTradeRepository;
public async Task<Result<bool>> Handle(
FinalizeShiftTradeApprovalCommand command,
CancellationToken cancellationToken)
{
// Verify trade request is still pending approval
var tradeRequest =
await _shiftTradeRepository.GetShiftTradeRequest(
command.LocationId,
command.TradeRequestId);
if (tradeRequest == null)
return Result<bool>.Fail(
ShiftTradeResources.ErrorMessages
.ShiftTradeNotFound);
if (tradeRequest.TradeStatus != TradeStatus.PendingApproval)
return Result<bool>.Fail(
ShiftTradeResources.ErrorMessages
.TradeRequestStatusNotPendingApproval);
// Verify this user's approval has not been voided
var userTradeApproval =
await _shiftTradeRepository.GetShiftTradeApproval(
command.TradeApprovalId);
if (userTradeApproval == null)
return Result<bool>.Fail(
ShiftTradeResources.ErrorMessages
.TradeApprovalNotFound);
if (userTradeApproval.Decision == TradeDecision.Void)
return Result<bool>.Fail(
ShiftTradeResources.ErrorMessages
.TradeApprovalVoided);
var tradeResponse =
await _shiftTradeRepository.GetShiftTradeResponse(
userTradeApproval.TradeResponseId);
if (tradeResponse == null)
return Result<bool>.Fail(
ShiftTradeResources.ErrorMessages
.TradeResponseNotFound);
var offeredShift =
await _scheduleRepository.GetShiftForLocationAsync(
command.LocationId,
tradeRequest.ShiftId);
if (offeredShift == null)
return Result<bool>.Fail(
ShiftTradeResources.ErrorMessages
.ShiftNotFound);
if (offeredShift.UserId != tradeRequest.RequestingUserId)
return Result<bool>.Fail(
ShiftTradeResources.ErrorMessages
.ShiftUserNotOwner);
// Verify offered shift is still in the future
if (offeredShift.StartDatetime < DateTime.UtcNow)
return Result<bool>.Fail(
ShiftTradeResources.ErrorMessages
.FinalizeTradeApprovalFailed);
// Verify responding user does not have a scheduled shift
// during the time range
var respondingUserIsScheduled =
await _scheduleRepository
.CheckShiftExistsBetweenTimeRangeForUser(
command.LocationId,
tradeResponse.RespondingUserId,
offeredShift.StartDatetime,
offeredShift.EndDatetime);
if (respondingUserIsScheduled)
return Result<bool>.Fail(
ShiftTradeResources.ErrorMessages
.RespondingUserShiftScheduledDuringTimeRange);
if (tradeRequest.RequestType == TradeRequestType.Swap &&
tradeResponse.RespondingUserShiftId.HasValue)
{
var respondingUserShift =
await _scheduleRepository.GetShiftForLocationAsync(
command.LocationId,
tradeResponse.RespondingUserShiftId.Value);
if (respondingUserShift == null)
return Result<bool>.Fail(
ShiftTradeResources.ErrorMessages
.RespondingShiftNotFound);
// Verify responding user's offered shift
// is still in the future
if (respondingUserShift.StartDatetime < DateTime.UtcNow)
return Result<bool>.Fail(
ShiftTradeResources.ErrorMessages
.FinalizeTradeApprovalFailed);
var requestingUserIsScheduled =
await _scheduleRepository
.CheckShiftExistsBetweenTimeRangeForUser(
command.LocationId,
tradeRequest.RequestingUserId,
respondingUserShift.StartDatetime,
respondingUserShift.EndDatetime);
if (requestingUserIsScheduled)
return Result<bool>.Fail(
ShiftTradeResources.ErrorMessages
.RequestingUserShiftScheduledDuringTimeRange);
}
// Update approval with decision
userTradeApproval.IsFinalizer = true;
userTradeApproval.Decision = command.Decision;
userTradeApproval.DecisionReason =
string.IsNullOrEmpty(command.DecisionReason)
? null
: command.DecisionReason.Trim();
var success =
await _shiftTradeRepository
.UpdateShiftTradeApproval(userTradeApproval);
if (!success)
return Result<bool>.Fail(
ShiftTradeResources.ErrorMessages
.FinalizeTradeApprovalFailed);
// Void any other approvals in the same approval group
_ = await _shiftTradeRepository
.VoidShiftTradeApprovalsForGroup(
userTradeApproval.ApprovalGroup);
// If denied, set request back to open
// and void all other approvals
if (userTradeApproval.Decision == TradeDecision.Denied)
{
_ = await _shiftTradeRepository
.VoidShiftTradeApprovalsForRequest(
command.TradeRequestId);
tradeResponse.ResponseStatus =
TradeResponseStatus.Rejected;
_ = await _shiftTradeRepository
.UpdateShiftTradeResponse(tradeResponse);
tradeRequest.TradeStatus =
TradeStatus.Open;
_ = await _shiftTradeRepository
.UpdateShiftTradeRequest(tradeRequest);
}
else
{
var allApprovals =
await _shiftTradeRepository
.GetAllApprovalsForTradeRequestAndResponse(
command.TradeRequestId,
userTradeApproval.TradeResponseId);
var groupedApprovals =
allApprovals.GroupBy(
a => a.ApprovalGroup);
// Check if all groups have a finalized approval
if (groupedApprovals.All(
ga => ga.Any(
a => a.IsFinalizer &&
a.Decision == TradeDecision.Approved)))
{
tradeRequest.TradeStatus =
TradeStatus.Approved;
_ = await _shiftTradeRepository
.UpdateShiftTradeRequest(tradeRequest);
// Make shift change(s)
if (tradeRequest.RequestType ==
TradeRequestType.GiveUp)
{
var shift =
await _scheduleRepository
.GetShiftForLocationAsync(
command.LocationId,
tradeRequest.ShiftId);
shift!.UserId =
tradeResponse!.RespondingUserId;
_ = await _scheduleRepository
.UpdateShiftAsync(
shift,
command.ApprovalUserId);
}
else if (tradeRequest.RequestType ==
TradeRequestType.Swap)
{
var requestingShift =
await _scheduleRepository
.GetShiftForLocationAsync(
command.LocationId,
tradeRequest.ShiftId);
requestingShift!.UserId =
tradeResponse!.RespondingUserId;
_ = await _scheduleRepository
.UpdateShiftAsync(
requestingShift,
command.ApprovalUserId);
if (tradeResponse.RespondingUserShiftId.HasValue)
{
var respondingShift =
await _scheduleRepository
.GetShiftForLocationAsync(
command.LocationId,
tradeResponse
.RespondingUserShiftId.Value);
respondingShift!.UserId =
tradeRequest.RequestingUserId;
_ = await _scheduleRepository
.UpdateShiftAsync(
respondingShift,
command.ApprovalUserId);
}
}
}
}
return Result<bool>.Ok(true);
}
}
What This Sample Demonstrates
- State validation: the handler revalidates the trade before applying a schedule change.
- Workflow integrity: voided approvals and stale requests cannot continue through the workflow.
- Concurrency-aware validation: schedule conflicts are checked against the current schedule rather than relying solely on the state at the time the trade was created.
- Approval groups: once a finalizer approves an approval group, other approvals in that group can be voided.
- Multiple approval requirements: the trade is not finalized until every approval group contains a finalized approval.
- Multiple workflow types: the same handler supports both give-up and swap operations.
- Separation of concerns: schedule operations and shift-trade operations remain behind their respective repositories.
- Business-rule driven workflow: the handler coordinates a sequence of domain rules rather than simply updating a status value.
Why the Handler Matters
The interesting part of this implementation isn't the amount of C# code. It is the number of business rules represented by the operation.
A manager approving a trade is effectively making a decision against a changing scheduling environment. The handler therefore acts as the final validation boundary before that decision is allowed to modify the schedule.
This is one of the reasons the shift trade workflow was modeled as a set of explicit requests, responses, approvals, approval groups, and schedule operations rather than as a single status transition.