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.

Trade State

The request must still be pending approval.

Approval State

The manager's approval must not have been voided.

Shift Ownership

The offered shift must still belong to the requesting employee.

Future Schedule

Both shifts must still occur in the future.

Schedule Conflicts

The responding employee cannot already be scheduled during the offered shift.

Approval Groups

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.

FinalizeShiftTradeApprovalHandler.cs
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.