VSATutorialSeptember 2026 · 8 min read

VSA Todo App — Part 16: Business Logic in VSA Handlers — Progress, Due Dates & Owner Assignment

TL;DR

Part 16 shows where business logic belongs in VSA — inside handlers, never in controllers or views. Using the MVC Project Manager's Todo feature, we cover progress tracking (0-100 slider), due date validation, owner assignment with the user lookup handler, and comma-separated tag management, all implemented within the handler boundary.

Part 16 of 20 in the VSA Todo App Tutorial Series|← Previous: Part 15|Next: Part 17 →

Help Us Grow

Love this tutorial? Explore our ready-to-use enterprise starter kits built with VSA in .NET 10.

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.

Key Takeaways

  • Business logic belongs in handlers — the single choke point for all write operations, guaranteed to run everywhere
  • Progress (0-100), due dates, owner assignment, and tags are all enforced inside the handler boundary
  • Combine separate date/time inputs into a single stored value in the handler — keep UX fields and storage decoupled
  • Only active users are assignable — the user lookup handler filters them before the dropdown renders
  • Normalize comma-separated tags in the handler for consistent storage, search, and display

Frequently Asked Questions

Q: Where should business logic go in VSA?

In handlers. The handler is the single point through which all writes flow, so business rules placed there are guaranteed to run whether the request comes from an API, an MVC form, a background job, or a test. Controllers stay thin, views stay presentational, and entities stay mostly data holders.

Q: How to validate due dates in VSA?

Use FluentValidation's GreaterThanOrEqualTo with .When(x => x.DueDate.HasValue) for the rule, then combine separate date/time inputs into a single stored value inside the handler. Keep the UI fields (date picker + time picker) separate for UX; the handler owns the combination rule.

Q: How to assign owners in VSA?

Expose a user lookup handler that returns active users as UserLookupDto (Id + Email). The form dropdown loads from it. The create/update handler stores the selected user ID. Resolve IDs to emails for display using the IHasAuditDisplay pattern from Part 9.

Q: How to handle tags as comma-separated strings in VSA?

Store tags as a single string on the entity. In the handler, split on commas, trim each entry, remove empties, and re-join — normalizing storage. Search with Contains in the list handler, and split back into chips for display. Validate length (500) in FluentValidation.

Part 16 of 20 in the VSA Todo App Tutorial Series|← Previous: Part 15|Next: Part 17 →

Ready to study real VSA code?

Every Indotalent product is a complete .NET 10 application built with Vertical Slice Architecture. Complete source code — $21 each.

Explore Products

Looking for a ready-to-use traditional monolithic multilayered clean architecture?

Monolithic Clean Architecture — 1,300+ devs, 500+ forks. Clean Arch + CQRS + Repository Pattern. Free & open source for commercial use.

Star on GitHub