Business logic is the reason your application exists. A Todo app's business logic includes: progress can only be 0-100, due dates must be in the future, a todo can be assigned to exactly one owner, and tags are comma-separated labels. The central question in VSA is where this logic lives. The answer, demonstrated by the MVC Project Manager, is simple: business logic belongs in handlers — never in controllers, never in views, and rarely in entities.
Why handlers? Because the handler is the single choke point through which all write operations flow. Whether the request comes from an API call, an MVC form post, a background job, or a test, the handler executes the same business rules. Put logic in a controller and you've coupled it to HTTP. Put logic in a view and it runs client-side only. Put logic in a handler and it's guaranteed, testable, and reusable.
Progress Tracking: The 0-100 Rule
The Todo feature tracks progress as an integer from 0 to 100, displayed as a slider in the UI and a progress bar in lists. The rule is enforced in two places: FluentValidation for incoming requests, and the entity's configuration for database integrity:
RuleFor(x => x.Progress)
.InclusiveBetween(0, 100).WithMessage("Progress must be between 0 and 100");
// In CreateTodoHandler
entity.Progress = request.Progress;
entity.IsCompleted = request.Progress >= 100;
Note the business rule: a todo with 100% progress is automatically completed. This logic lives in the handler, so it runs consistently no matter how the todo is updated. The UI slider enforces the same range client-side for UX, but the handler is the authority.
Due Date Validation
Due dates and due times are captured separately in the MVC form (using flatpickr) and combined into a single DueDateTime in the handler. The validator ensures the combined value is sensible:
// CreateTodoValidator
RuleFor(x => x.DueDate)
.GreaterThanOrEqualTo(DateTime.Today)
.When(x => x.DueDate.HasValue)
.WithMessage("Due date cannot be in the past");
// CreateTodoHandler — combine date and time
if (request.DueDate.HasValue)
{
var dueTime = request.DueTime ?? TimeSpan.Zero;
entity.DueDate = request.DueDate.Value.Date.Add(dueTime);
}
The handler combines the separate date/time inputs into a single stored value. This is a classic example of business logic that shouldn't leak into the UI: the Vue form collects date and time separately (better UX), but the handler owns the rule for how they combine.
Owner Assignment with User Lookup
Todos can be assigned to a user as the owner. The create/edit forms populate the owner dropdown from the GetTodoUserLookupHandler we built in Part 3, and the handler stores the owner's user ID:
// GetTodoUserLookupHandler
var users = await _context.Users
.AsNoTracking()
.Where(u => u.IsActive)
.Select(u => new UserLookupDto { Id = u.Id, Text = u.Email })
.ToListAsync(ct);
// CreateTodoHandler
if (!string.IsNullOrEmpty(request.OwnerUserId))
{
entity.OwnerUserId = request.OwnerUserId;
}
The owner assignment rule is: only active users can be assigned. The lookup handler filters for active users, so the dropdown only shows valid owners. The handler stores the ID; the IHasAuditDisplay pattern (Part 9) resolves it to an email for display. Business logic stays in the handler, and the query stays efficient.
Tags: Comma-Separated Management
Tags are stored as a single comma-separated string on the entity — simple, searchable, and flexible. The handler owns the formatting rules: splitting input, trimming whitespace, and limiting length:
// CreateTodoHandler — normalize tags
if (!string.IsNullOrEmpty(request.Tags))
{
var tags = request.Tags
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
entity.Tags = string.Join(",", tags);
}
// CreateTodoValidator
RuleFor(x => x.Tags)
.MaximumLength(500).WithMessage("Tags cannot exceed 500 characters");
Normalizing tags in the handler guarantees consistent storage — no leading/trailing spaces, no empty entries, no duplicate commas. The list handler searches across tags (x.Tags.Contains(search)), and the detail view splits them back into chips for display. All tag logic lives in the feature slice.
Keeping Handlers Focused
The discipline is to keep every rule inside the handler and resist the temptation to sprinkle validation into controllers or views. When a new business rule arrives — say, "High-priority todos can't be deleted" — you add it to the handler in one place. Controllers stay thin (delegating to handlers), views stay presentational, and the business rules live exactly where they're guaranteed to run. This is the VSA contract: one feature, one folder, one source of truth for its business logic.