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.