Architecture decisions in an asset management system have real consequences: a poorly designed assignment rule can let two employees claim the same laptop, and a missing lifecycle guard can corrupt audit data. MVC Asset Manager addresses these risks with Vertical Slice Architecture (VSA), explicit CQRS handlers, and a rules engine that enforces business invariants at the handler level — before any database write occurs. This article walks through each architectural layer in detail.
Vertical Slice Architecture: Feature-First Organization
MVC Asset Manager organizes code around features, not technical layers. Instead of top-level Controllers/, Services/, and Models/ folders, each asset operation lives inside its own vertical slice under Features/Asset/:
Features/
Asset/
AssignAsset/
AssignAssetCommand.cs
AssignAssetHandler.cs
AssignAssetEndpoint.cs
CreateAsset/
CreateAssetCommand.cs
CreateAssetHandler.cs
CreateAssetEndpoint.cs
UpdateAssetStatus/
UpdateAssetStatusCommand.cs
UpdateAssetStatusHandler.cs
UpdateAssetStatusEndpoint.cs
GetAssetById/
GetAssetByIdQuery.cs
GetAssetByIdHandler.cs
GetAssetByIdEndpoint.cs
Each slice is self-contained: the command or query DTO, the handler with business logic, and the Minimal API endpoint that wires it to HTTP. This structure keeps related code together, reduces merge conflicts, and makes it trivial to locate every piece of a feature — from request shape to database write — in a single folder.
CQRS Handlers for Asset Operations
Every asset operation flows through a CQRS handler. Commands (mutations) and queries (reads) are separated, with MediatR dispatching each request to its handler. The handler owns the business logic, validation, and persistence in one place. This pattern eliminates service-layer sprawl and keeps business rules close to the data they govern.
Handlers receive a request DTO, validate preconditions, execute domain logic, and return a Result or Result<T> — a discriminated union that carries either success data or a list of errors. No exceptions for control flow; every failure path is explicit and traceable.
Asset Assignment Rules: No Double Assignment
The most critical business rule in asset management is that one asset cannot be assigned to two employees at the same time. MVC Asset Manager enforces this at the handler level with a guard clause that checks for any active (unreturned) assignment before allowing a new one:
public async Task<Result> Handle(AssignAssetCommand request, CancellationToken ct)
{
var asset = await _context.Assets
.Include(a => a.Assignments)
.FirstOrDefaultAsync(a => a.Id == request.AssetId, ct);
if (asset is null)
return Result.Failure("Asset not found.");
if (asset.Status != AssetStatus.Ready)
return Result.Failure("Only assets in Ready status can be assigned.");
var activeAssignment = asset.Assignments
.Any(a => a.ReturnDate == null);
if (activeAssignment)
return Result.Failure("Asset is already assigned to an employee. Return it first.");
asset.Status = AssetStatus.Assigned;
asset.Assignments.Add(new AssetAssignment
{
EmployeeId = request.EmployeeId,
AssignmentDate = DateTime.UtcNow,
Notes = request.Notes
});
await _context.SaveChangesAsync(ct);
return Result.Success();
}
The check a.ReturnDate == null is the invariant: as long as an assignment has no return date, the asset is considered actively assigned. This single guard prevents every double-assignment scenario — whether from concurrent requests, UI bugs, or API misuse.
Depreciation Calculation Engine
MVC Asset Manager supports three depreciation methods, each implemented as a separate calculation strategy. The engine computes monthly depreciation and updates accumulated depreciation and current book value for every asset on a scheduled run:
public static decimal CalculateMonthlyDepreciation(
DepreciationMethod method, decimal cost, decimal salvage, int usefulLifeMonths)
{
return method switch
{
DepreciationMethod.StraightLine =>
(cost - salvage) / usefulLifeMonths,
DepreciationMethod.DecliningBalance =>
(cost - salvage) * (2m / usefulLifeMonths),
DepreciationMethod.SumOfYearsDigits =>
{
var sumOfYears = usefulLifeMonths * (usefulLifeMonths + 1) / 2;
var remainingLife = usefulLifeMonths; // tracked per period
return (cost - salvage) * remainingLife / sumOfYears;
},
_ => throw new ArgumentOutOfRangeException(nameof(method))
};
}
The depreciation run is idempotent: it calculates the amount for each period and records it in a DepreciationEntry table with a period identifier, so re-running the same period never double-counts. Accumulated depreciation and book value on the asset record are updated atomically within the same transaction.
Lifecycle Stage Enforcement: Valid Transitions
Not all status transitions are valid. An asset in Repair cannot jump directly to Assigned — it must first return to Ready. A quarantined asset cannot be assigned until quarantine is cleared. These rules are enforced by a transition guard that checks the current status against a permitted-next-states map:
private static readonly Dictionary<AssetStatus, AssetStatus[]> AllowedTransitions = new()
{
[AssetStatus.Ready] = new[] { AssetStatus.Assigned, AssetStatus.Quarantine, AssetStatus.Missing },
[AssetStatus.Assigned] = new[] { AssetStatus.Ready, AssetStatus.Repair, AssetStatus.Missing },
[AssetStatus.Repair] = new[] { AssetStatus.Ready, AssetStatus.Quarantine },
[AssetStatus.Quarantine] = new[] { AssetStatus.Ready, AssetStatus.Missing },
[AssetStatus.Missing] = new[] { AssetStatus.Ready }
};
public static bool IsValidTransition(AssetStatus current, AssetStatus next)
{
return AllowedTransitions.TryGetValue(current, out var allowed)
&& allowed.Contains(next);
}
This guard runs inside every status-change handler. If the transition is invalid, the handler returns a descriptive error before any database work occurs. The transition map is centralized — adding a new status or changing allowed paths means updating one dictionary, not hunting through scattered if-statements.
Minimal API Endpoints
All asset operations are exposed through Minimal API endpoints defined alongside their handlers. Each endpoint calls MediatR to dispatch the command or query and maps the result to an HTTP response:
GET /api/assets/{id}— fetch a single asset with full assignment historyPOST /api/assets— create a new asset from a modelPOST /api/assets/{id}/assign— assign an asset to an employeePOST /api/assets/{id}/return— return an asset to inventoryPATCH /api/assets/{id}/status— transition asset to a new lifecycle stageGET /api/assets/report/depreciation— depreciation schedule report
Endpoints are grouped under /api/assets with authorization policies applied per group, ensuring that only authorized roles can mutate asset state.
Vue 3 Frontend: Asset Dashboard
The frontend is built with Vue 3 and consumes the Minimal API endpoints. The asset dashboard provides a real-time view of all assets with filtering by status, department, and branch. Key frontend features include:
- Asset grid with sortable columns (tag, model, status, assigned employee, department)
- Lifecycle stage filter that highlights assets by current state with color-coded badges
- QR and barcode integration — scan an asset tag to pull up its full history and current assignment
- Assignment modal with employee search, validation feedback, and confirmation
- Depreciation report view with per-asset schedules and aggregate summaries by department
The Vue 3 app is bundled with Vite and served as static files from the ASP.NET Core backend, keeping deployment simple while delivering a reactive SPA experience for asset management workflows.