Deep Dive into Timesheet Submission Architecture in ASP.NET Core

Introduction

Building a reliable timesheet submission system for enterprise applications involves much more than saving hours to a database. It requires careful orchestration of data persistence, multi-layered validation, approval status management, and event-driven notifications. In this article, we dissect a production-grade timesheet submission architecture built with ASP.NET Core, Entity Framework Core, and MediatR, focusing on how entities transform through each phase of the submission pipeline.

System Overview

The system follows the CQRS (Command Query Responsibility Segregation) pattern using MediatR. When a user submits their weekly timesheet, the request flows through four distinct phases:

  1. Save - Persist the latest hour entries
  2. Validate - Enforce business rules
  3. Submit - Transition approval statuses
  4. Notify - Raise domain events for email delivery

Entity Model

The core data model revolves around three entities:

public class WorkItem
{
    public Guid Id { get; set; }
    public string Title { get; set; }
    public Guid ProjectAllocationId { get; set; }
    public Guid? ApproverId { get; set; }
    public string CategoryCode { get; set; }
    public DateTime WeekStartDate { get; set; }
    public string RejectionNote { get; set; }
    public int SortOrder { get; set; }
    public ICollection<TimeRecord> TimeRecords { get; set; }
}

public class TimeRecord
{
    public Guid Id { get; set; }
    public Guid? WorkItemId { get; set; }
    public Guid? AbsenceRequestId { get; set; }
    public decimal Hours { get; set; }
    public DateTime RecordDate { get; set; }
    public byte? ApprovalStatus { get; set; }
    public DateTime? LastModifiedAt { get; set; }
    public DateTime? SubmittedAt { get; set; }
    public DateTime? ApprovedAt { get; set; }
}

public class AbsenceRequest
{
    public Guid Id { get; set; }
    public Guid EmployeeId { get; set; }
    public string AbsenceTypeCode { get; set; }
    public DateTime WeekStartDate { get; set; }
    public string Reason { get; set; }
    public Guid? ApproverId { get; set; }
    public ICollection<TimeRecord> TimeRecords { get; set; }
}

Each WorkItem or AbsenceRequest contains exactly TimeRecord entries representing Monday through Sunday. The ApprovalStatus field on each TimeRecord drives the entire workflow:

Value Status Meaning
null Draft User can freely edit
0 Pending Awaiting manager approval
1 Approved Manager confirmed
2 Rejected Manager sent back for revision
5 OvertimePending Approved by PM, awaiting PMO confirmation

Phase 1: Save Before Submit

A critical design decision is that every submission saves data first before validating or changing statuses. This guarantees the database reflects the user's latest input regardless of whether validation passes.

public class SubmitWeeklyTimeCommand : IRequest<Unit>
{
    public PersistTimeCommand PersistCommand { get; set; }
    public List<DayOfWeek> SelectedDays { get; set; }
}

public class SubmitWeeklyTimeHandler : IRequestHandler<SubmitWeeklyTimeCommand, Unit>
{
    public async Task<Unit> Handle(SubmitWeeklyTimeCommand request, CancellationToken ct)
    {
        // Phase 1: Save current data
        var sheet = await _mediator.Send(request.PersistCommand);

        // Phase 2: Load rules and validate
        var rule = await _dbContext.WorkingHourRules
            .FirstOrDefaultAsync(r => r.Region == _context.RegionCode);

        sheet.ConfigureRules(requiredHours: rule.DailyRequiredHours);
        sheet.RunSubmissionValidation(request.SelectedDays);

        var budgetError = await ValidateBudgetLimits(request, sheet);
        if (budgetError != null) throw new ValidationException(budgetError);

        // Phase 3: Change statuses
        sheet.ProcessSubmission(request.SelectedDays);

        // Phase 4: Publish events and persist
        foreach (var evt in sheet.PendingEvents)
            await _mediator.Publish(evt);

        await _dbContext.SaveChangesAsync();
        return Unit.Value;
    }
}

The PersistCommand handler loads the existing timesheet from the database, maps frontend row data into domain objects, updates the EF Core tracked entities, and calls SaveChangesAsync().

Phase 2: Validation Pipeline

The validation phase enforces a strict chain of business rules. Each rule runs sequentially, and violations are collected into a composite exception that reports errors per day of the week.

public void RunSubmissionValidation(List<DayOfWeek> days)
{
    EnsureNotEmpty();
    EnsureNotAlreadySubmitted(days);
    EnforceMaxHoursPerDay(days);          // 24h ceiling
    EnforceAbsenceConflicts(days);         // No work on full-day leave
    EnforceTimeGranularity(days);          // 0.25h increments
    EnforceRequiredDailyHours(days);       // Work + Leave = 8h on weekdays
    EnforceHolidayRestrictions(days);      // Overtime only on holidays

    if (_validationErrors.Any())
        throw new CompositeValidationException(_validationErrors);
}

Key Validation Rules

Daily Hour Enforcement: On regular weekdays, the sum of non-overtime work hours and leave hours must equal the company's required daily hours (typically 8). The system distinguishes between regular tasks and overtime tasks, applying different rules to each.

Time Granularity: Work hours must be in multiples of 0.25 (15-minute increments), while leave hours must be whole numbers. This is validated using modular arithmetic: (hour * 100) % 25 == 0.

Budget Validation: Each project allocation can have a TotalHours cap or a MonthlyHoursCap. The system queries all existing TimeRecord entries for the allocation and checks whether adding the submitted hours would exceed the limit.

private async Task<ValidationError> ValidateBudgetLimits(
    SubmitWeeklyTimeCommand request, IWeeklyTimesheet sheet)
{
    var allocations = sheet.ActiveAllocations;

    foreach (var alloc in allocations.Where(a => a.TotalHoursCap != null))
    {
        var usedHours = await _dbContext.TimeRecords
            .Where(r => r.WorkItem.ProjectAllocationId == alloc.Id
                     && r.RecordDate <= sundayDate)
            .SumAsync(r => r.Hours);

        if (alloc.TotalHoursCap - usedHours < 0)
            return new ValidationError("Budget exceeded for " + alloc.Name);
    }

    return null;
}

Phase 3: Status Transition

The heart of the submission process is the status transition logic. Only entries in Draft or Rejected status transition to Pending. Already submitted or approved entries remain unchanged.

public void ProcessSubmission(List<DayOfWeek> selectedDays)
{
    // Remove items with zero hours in submitted days
    var activeItems = _workItems
        .Where(t => t.TimeSlot.TotalHoursInDays(selectedDays) > 0)
        .ToList();

    // Change status for each selected day
    var allDaySlots = activeItems
        .SelectMany(t => t.TimeSlot.GetDayEntries())
        .ToList();

    foreach (var day in selectedDays)
    {
        allDaySlots
            .Where(d => d.RecordDate.DayOfWeek == day)
            .ToList()
            .ForEach(TransitionToPending);
    }

    // Sync domain changes back to EF-tracked entities
    SyncWorkItemEntities();
    SyncAbsenceEntities();
    SyncSupplementaryEntities();

    // Raise domain events
    RaiseSubmissionEvents(selectedDays);
}

private void TransitionToPending(DayEntry entry)
{
    switch (entry.Status)
    {
        case EntryStatus.Draft:
        case EntryStatus.Rejected:
            entry.SetStatus(EntryStatus.Pending);
            break;
        // Pending and Approved entries are unchanged
    }
}

Entity Change Tracking

A crucial architectural pattern here is the two-layer entity model. Domain objects (WorkTaskLeaveTask) hold the business logic and are manipulated in memory. EF Core tracked entities (TaskLeavePermission) are the persistence layer. After domain operations complete, a sync method copies changes from domain objects back to EF entities:

private void SyncWorkItemEntities()
{
    var now = DateTime.Now;
    var timestamp = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0);

    _trackedEntities.ForEach(entity =>
    {
        var domainItem = _domainItems.First(d => d.Id == entity.Id);

        entity.Title = domainItem.Title;
        entity.ApproverId = domainItem.ApproverId;
        entity.SortOrder = domainItem.Order;

        entity.TimeRecords.ToList().ForEach(record =>
        {
            var dayEntry = domainItem.TimeSlot.GetDay(record.Id);

            record.ApprovalStatus = dayEntry.StatusValue;  // Status change persisted here
            var hours = Convert.ToDecimal(dayEntry.Hours);
            if (record.Hours != hours)
                record.LastModifiedAt = timestamp;
            record.Hours = hours;
        });
    });
}

This pattern ensures that EF Core's change tracker detects only the fields that actually changed, generating minimal UPDATE statements.

Phase 4: Event-Driven Notifications

After status transitions, the system raises domain events. These events are collected during the domain operation and published via MediatR after the main logic completes but before SaveChangesAsync().

// Domain event raised when tasks are submitted
public class TimeSubmittedEvent : INotification
{
    public Guid EmployeeId { get; }
    public IEnumerable<string> SubmittedDays { get; }
    public DateTime WeekStartDate { get; }
    public IEnumerable<Guid> AffectedItemIds { get; }
}

Event handlers transform these domain events into message queue payloads. The system uses a pipeline behavior that runs after the main handler, pushing messages to a message queue for asynchronous email delivery:

public class MessageQueuePipeline<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
{
    public async Task<TResponse> Handle(TRequest request,
        RequestHandlerDelegate<TResponse> next, CancellationToken ct)
    {
        var response = await next();

        try
        {
            foreach (var message in _pendingMessages.EmailMessages)
            {
                await _producer.SendAsync(_settings.EmailTopic, message);
                MarkAsQueued(message.NotificationId);
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to queue notification");
            // Swallow - main operation already succeeded
        }

        return response;
    }
}

This design ensures that a notification delivery failure never rolls back a successful timesheet submission.

Approval Workflow

Once submitted, entries follow a well-defined approval lifecycle:

Draft (null) ──[Submit]──> Pending (0) ──[Approve]──> Approved (1)
                                        ──[Reject]──> Rejected (2)
                                                       ──[Re-submit]──> Pending (0)

Approved (1) ──[Reopen]──> Draft (null)

For overtime tasks with a two-tier approval requirement:

Pending (0) ──[PM Approves]──> OvertimePending (5) ──[PMO Approves]──> Approved (1)

The approval is granular at the per-day level, not per-task. A single task can have Monday as Approved while Friday remains Draft if only Monday through Thursday were submitted.

Key Design Decisions

Save-then-validate pattern: By persisting data before validation, the system ensures no user input is lost even when validation fails. The user sees their saved data on the next page load.

Per-day status granularity: Rather than a single status per task, each day has its own ApprovalStatus. This enables partial-week submissions and mixed-status scenarios.

Domain-entity separation: Business logic operates on rich domain objects while persistence uses flat EF entities. The sync step bridges the two, keeping concerns cleanly separated.

Event-driven notifications: Email delivery is fully decoupled from the submission transaction. Failed notifications don't affect the core operation, and messages can be retried independently.

Composite validation errors: Rather than failing on the first validation error, the system collects all errors and returns them grouped by day, enabling the frontend to highlight specific problem areas in the weekly grid.

Conclusion

A well-architected timesheet submission system goes beyond simple CRUD operations. By separating the flow into save, validate, submit, and notify phases, the system remains maintainable and extensible. The domain-entity separation pattern ensures business rules stay clean and testable, while the event-driven notification pipeline provides reliability without coupling. Whether you are building a time tracking tool from scratch or refactoring an existing one, these architectural patterns provide a solid foundation for handling the complexity of enterprise timesheet workflows.

← Quay lαΊ‘i Blog