ProjectMVCVSAAugust 2026 · 12 min read

MVC Project Manager Architecture: VSA, CQRS, and Resource Allocation Engine

TL;DR

MVC Project Manager uses Vertical Slice Architecture with CQRS handlers organized by feature. Resource allocation enforces a strict 100% rule per employee, approval workflows follow a state machine pattern (Submitted→Approved→Rejected), Minimal APIs serve a Vue 3 dashboard, and role-based access gates every operation.

Architecture decisions compound over the life of a project. MVC Project Manager is built on Vertical Slice Architecture (VSA) with CQRS — not as an academic exercise, but because resource management is inherently feature-centric: allocating a resource, approving a timesheet, and building a skill matrix are distinct vertical concerns that benefit from independent handlers, validation, and endpoints. This article walks through the architecture layer by layer, from folder structure to the allocation engine's 100% rule.

Vertical Slice Architecture: Folder Structure

Every feature lives in its own folder under Features/. There is no shared service layer, no generic repository, and no cross-cutting business logic class. Each slice contains everything it needs — command, handler, validator, DTOs, and endpoint registration:

Features/
├── Project/
│   ├── CreateProject/
│   │   ├── CreateProjectCommand.cs
│   │   ├── CreateProjectHandler.cs
│   │   ├── CreateProjectValidator.cs
│   │   └── CreateProjectEndpoint.cs
│   ├── GetProjectList/
│   └── UpdateProjectCharter/
├── ResourceAllocation/
│   ├── AllocateResource/
│   │   ├── AllocateResourceCommand.cs
│   │   ├── AllocateResourceHandler.cs
│   │   ├── AllocationValidator.cs
│   │   └── AllocateResourceEndpoint.cs
│   └── GetResourceUtilization/
├── Timesheet/
│   ├── SubmitTimesheet/
│   ├── ApproveTimesheet/
│   └── GetTimesheetHistory/
├── SkillMatrix/
│   └── GetSkillMatrix/
└── ExpertDirectory/
    └── SearchExperts/

This structure eliminates the "where does this code go?" problem. When a developer needs to modify resource allocation logic, they open Features/ResourceAllocation/AllocateResource/ and find everything in one place. Cross-slice concerns like authorization and logging are handled by middleware and pipeline behaviors, not shared base classes.

CQRS Handlers for Project and Resource Operations

Every user action maps to a command or query. Commands mutate state (create project, allocate resource, approve timesheet). Queries read state (list projects, get utilization report, search expert directory). The separation is enforced by the handler interface — command handlers return Result or Result<T>, query handlers return data:

public class CreateProjectHandler : IRequestHandler<CreateProjectCommand, Result<int>>
{
    private readonly AppDbContext _context;

    public async Task<Result<int>> Handle(CreateProjectCommand cmd, CancellationToken ct)
    {
        var project = new Project
        {
            Name = cmd.Name,
            Code = cmd.Code,
            ClientName = cmd.ClientName,
            StartDate = cmd.StartDate,
            EndDate = cmd.EndDate,
            Budget = cmd.Budget,
            ProjectManagerId = cmd.ProjectManagerId,
            Status = ProjectStatus.Draft
        };

        _context.Projects.Add(project);
        await _context.SaveChangesAsync(ct);

        return Result<int>.Success(project.Id);
    }
}

Handlers are registered via MediatR, which also enables pipeline behaviors for validation, logging, and transaction management. Each handler owns its database interaction directly through AppDbContext — no repository abstraction stands between the handler and the data it needs.

Resource Allocation Validation: The 100% Rule

The allocation engine's core constraint is simple but critical: no employee can be allocated more than 100% across concurrent projects at any point in time. The validation handler checks every new allocation against existing overlapping assignments before persisting:

public class AllocateResourceHandler : IRequestHandler<AllocateResourceCommand, Result>
{
    private readonly AppDbContext _context;

    public async Task<Result> Handle(AllocateResourceCommand cmd, CancellationToken ct)
    {
        var overlappingAllocations = await _context.ResourceAssignments
            .Where(a => a.EmployeeId == cmd.EmployeeId)
            .Where(a => a.StartDate < cmd.EndDate && a.EndDate > cmd.StartDate)
            .ToListAsync(ct);

        var totalAllocation = overlappingAllocations.Sum(a => a.AllocationPercent);

        if (totalAllocation + cmd.AllocationPercent > 100)
            return Result.Failure(new AllocationConflictError(
                cmd.EmployeeId,
                totalAllocation,
                cmd.AllocationPercent,
                overlappingAllocations.Select(a => a.ProjectId).ToList()));

        var assignment = new ResourceAssignment
        {
            EmployeeId = cmd.EmployeeId,
            ProjectId = cmd.ProjectId,
            AllocationPercent = cmd.AllocationPercent,
            StartDate = cmd.StartDate,
            EndDate = cmd.EndDate,
            Role = cmd.Role
        };

        _context.ResourceAssignments.Add(assignment);
        await _context.SaveChangesAsync(ct);

        return Result.Success();
    }
}

The query uses date-range overlap logic (StartDate < cmd.EndDate && EndDate > cmd.StartDate) to find conflicting assignments. When a conflict is detected, the handler returns a structured error containing the employee ID, current allocation total, requested percentage, and the list of conflicting project IDs — giving the caller enough context to display a meaningful message or suggest adjustments.

Approval Workflow State Machine

Timesheet and project charter approvals follow a defined state machine with three states: Submitted, Approved, and Rejected. The transition logic is encapsulated in the handler, not scattered across controllers:

public class ApproveTimesheetHandler : IRequestHandler<ApproveTimesheetCommand, Result>
{
    public async Task<Result> Handle(ApproveTimesheetCommand cmd, CancellationToken ct)
    {
        var timesheet = await _context.Timesheets
            .Include(t => t.Entries)
            .FirstOrDefaultAsync(t => t.Id == cmd.TimesheetId, ct);

        if (timesheet is null)
            return Result.Failure("Timesheet not found.");

        if (timesheet.Status != TimesheetStatus.Submitted)
            return Result.Failure(
                $"Cannot approve a timesheet in '{timesheet.Status}' status. " +
                "Only 'Submitted' timesheets can be approved.");

        timesheet.Status = TimesheetStatus.Approved;
        timesheet.ApprovedBy = cmd.ApproverId;
        timesheet.ApprovedAt = DateTime.UtcNow;
        timesheet.ApproverComments = cmd.Comments;

        await _context.SaveChangesAsync(ct);
        return Result.Success();
    }
}

The handler guards against invalid state transitions — a timesheet that is already approved or rejected cannot be approved again. The same pattern applies to rejection and project charter workflows, ensuring consistency regardless of which UI or API endpoint triggers the operation.

Minimal API Endpoints for Vue 3 Consumption

The frontend is a Vue 3 single-page application that consumes data through Minimal API endpoints. Each feature slice registers its own endpoints, keeping the API surface close to the handler logic:

public static class ResourceAllocationEndpoints
{
    public static void MapResourceAllocationEndpoints(this WebApplication app)
    {
        var group = app.MapGroup("/api/resources")
            .RequireAuthorization();

        group.MapGet("/utilization", async (IMediator mediator, CancellationToken ct) =>
        {
            var result = await mediator.Send(new GetResourceUtilizationQuery(), ct);
            return Results.Ok(result.Value);
        }).RequireAuthorization("Admin", "ProjectManager");

        group.MapPost("/allocate", async (
            AllocateResourceCommand cmd,
            IMediator mediator,
            CancellationToken ct) =>
        {
            var result = await mediator.Send(cmd, ct);
            return result.IsSuccess
                ? Results.Created($"/api/resources/{cmd.EmployeeId}", null)
                : Results.BadRequest(result.Errors);
        }).RequireAuthorization("Admin", "ProjectManager");
    }
}

Each endpoint group is authorization-gated. The Vue 3 frontend calls these endpoints using Axios, with JWT tokens passed in the Authorization header. The dashboard renders DataTables for project lists, Chart.js for utilization trends, and interactive allocation calendars built with Vue 3 composition API components.

Vue 3 Dashboard with DataTables and Charts

The dashboard is organized into role-specific views. Administrators see organization-wide utilization charts and allocation heatmaps. Project managers see their projects with drill-down into team assignments and timesheet status. Team members see their personal assignment calendar, timesheet submission form, and skill profile editor.

DataTables provide sortable, searchable, paginated lists for projects, employees, and timesheets — all powered by server-side processing through the Minimal API endpoints. Chart.js renders bar charts for monthly utilization, doughnut charts for allocation breakdown by project, and line charts for historical trends. The entire dashboard is responsive and works on tablets for on-the-go project managers.

Role-Based Access Control

Three roles govern access: Admin (full access to master data, all projects, and system configuration), Project Manager (manage assigned projects, approve timesheets, view team allocation), and Team Member (view personal assignments, submit timesheets, update skills). Role checks are applied at the endpoint level via .RequireAuthorization() and at the handler level through MediatR pipeline behaviors, providing defense in depth.

The self-service portal uses the same API but scopes data to the authenticated user. A team member calling /api/timesheets/my sees only their own timesheets, while a project manager calling /api/timesheets/project/{id} sees all timesheets for their project. The authorization behavior ensures data isolation without duplicating endpoint logic.

FAQ

What is the 100% allocation rule?

The 100% rule ensures no employee is assigned beyond full-time capacity across overlapping projects. The allocation engine sums all allocation percentages for date ranges that overlap with a new assignment. If the total exceeds 100%, the assignment is rejected with a detailed conflict report showing which projects are causing the overallocation.

How does the approval workflow work?

Approvals follow a state machine with three states: Submitted, Approved, and Rejected. Only items in Submitted status can transition to Approved or Rejected. The handler enforces these transitions server-side, preventing double-approval or approval of already-rejected items. Each transition records the approver, timestamp, and optional comments.

Can it handle fractional allocations?

Yes. Allocation percentages can be any integer from 1 to 100. A developer could be allocated 60% to Project A and 40% to Project B simultaneously. The validation engine checks all overlapping assignments regardless of percentage size, ensuring the sum never exceeds 100% at any point on the calendar.

Ready to build with VSA and CQRS in ASP.NET Core MVC?

MVC EDevKit Basic gives you a complete VSA scaffold with CQRS handlers, MediatR pipeline behaviors, and Minimal API endpoints. $21.

View MVC EDevKit Details