This continues from Building Business Screens with Cotomy .
The previous article separated ordinary business screens into search screens, detail and edit screens, and read-only screens. This article continues with the next practical problem in that same flow: how to handle field validation, submit errors, and API failures without breaking the screen model.
Validation is where business screens often become inconsistent. One screen shows field messages beside inputs. Another writes one message at the top. Another treats every API failure as a generic alert. After enough screens, users cannot predict where to look, and developers cannot predict where the error path lives.
Cotomy does not provide a complete validation framework. That is intentional. The framework owns the UI lifecycle, form submission path, API response wrapping, and failure events. The application still owns validation rules, message shape, and business meaning.
The practical pattern is to keep three layers separate.
Field validation belongs close to the form because it is immediate screen feedback. Submit errors belong to the API contract because the server is still the authority. API failures belong to the screen flow because they decide what the user can do next.
The Error Flow
The implementation path is simple. CotomyApiForm builds FormData, submits it through CotomyApi, and raises failure events when CotomyApi throws a CotomyApiException. CotomyApi maps HTTP 400 and 422 to CotomyRequestInvalidException. CotomyApiForm then triggers both apiFailed and submitFailed for submit failures.
sequenceDiagram
participant User
participant Form as CotomyApiForm
participant API as CotomyApi
participant Server
participant UI as Screen UI
User->>Form: submit
Form->>Form: build FormData
Form->>API: submitAsync
API->>Server: request
Server-->>API: 422 validation response
API-->>Form: throw CotomyRequestInvalidException
Form->>Form: trigger cotomy:apifailed
Form->>Form: trigger cotomy:submitfailed
Form->>UI: screen renders messages
That flow is important because Cotomy does not guess how validation should look. The response object is available, but the screen decides how to display it.
Razor Page Markup
This example continues the edit-screen style from the previous article. The form is still plain HTML. The error display is also plain HTML: one summary area and one field message target per input.
@page "/sample-entities/edit/{id?}"
@model SampleEntityEditModel
@{
var entityKey = string.IsNullOrWhiteSpace(Model.EntityKey)
? (RouteData.Values["id"]?.ToString() ?? string.Empty)
: Model.EntityKey;
}
<h1>Sample Entity Detail</h1>
<form id="sample-entity-edit-form"
action="/api/sample-entities"
data-cotomy-entity-key="@entityKey"
novalidate>
<div>
<a href="/sample-entities">Back to List</a>
<button type="submit">Save</button>
</div>
<div id="edit-status" role="status"></div>
<div id="edit-error-summary" role="alert"></div>
<table>
<tbody>
<tr>
<th>
<label for="sample-entity-id">@nameof(SampleEntitySaveRequest.Id)</label>
</th>
<td>
<input id="sample-entity-id"
name="@nameof(SampleEntitySaveRequest.Id)"
value="@entityKey"
aria-describedby="sample-entity-id-error" />
<div id="sample-entity-id-error"
data-validation-for="@nameof(SampleEntitySaveRequest.Id)"></div>
</td>
</tr>
<tr>
<th>
<label for="sample-entity-name">@nameof(SampleEntitySaveRequest.Name)</label>
</th>
<td>
<input id="sample-entity-name"
name="@nameof(SampleEntitySaveRequest.Name)"
aria-describedby="sample-entity-name-error" />
<div id="sample-entity-name-error"
data-validation-for="@nameof(SampleEntitySaveRequest.Name)"></div>
</td>
</tr>
</tbody>
</table>
</form>
The important part is not the exact HTML shape. The important part is that the screen has stable message targets. CotomyElement can find them by data-validation-for, and the API response can use the same field names as the form.
API Contract
The server remains the authority for business validation. The client can check simple field presence, but uniqueness, existence, permissions, and business constraints belong on the server side.
This sample uses a small response shape that is easy for the screen to render.
using Microsoft.AspNetCore.Mvc;
using System.Text.Json.Serialization;
[ApiController]
[Route("api/sample-entities")]
public class SampleEntitiesApiController : ControllerBase
{
private readonly ISampleEntityRepository _repository;
public SampleEntitiesApiController(ISampleEntityRepository repository)
{
_repository = repository;
}
[HttpGet("{id}")]
public async Task<ActionResult<SampleEntityResponse>> Get(string id)
{
var entity = await _repository.GetByIdAsync(id);
if (entity is null)
{
return NotFound();
}
return Ok(new SampleEntityResponse
{
Id = entity.Id,
Name = entity.Name
});
}
[HttpPost]
public async Task<ActionResult<SampleEntityResponse>> Post([FromForm] SampleEntitySaveRequest request)
{
var validation = Validate(request);
if (validation.Errors.Count > 0)
{
return UnprocessableEntity(validation);
}
if (await _repository.ExistsAsync(request.Id.Trim()))
{
return Conflict(new ValidationErrorResponse
{
Message = "The sample entity already exists.",
Errors =
{
[nameof(SampleEntitySaveRequest.Id)] = ["This ID is already used."]
}
});
}
var entity = new SampleEntity
{
Id = request.Id.Trim(),
Name = request.Name.Trim()
};
var saved = await _repository.InsertAsync(entity);
return CreatedAtAction(nameof(Get), new { id = saved.Id }, new SampleEntityResponse
{
Id = saved.Id,
Name = saved.Name
});
}
[HttpPut("{id}")]
public async Task<ActionResult<SampleEntityResponse>> Put(string id, [FromForm] SampleEntitySaveRequest request)
{
var validation = Validate(request);
if (validation.Errors.Count > 0)
{
return UnprocessableEntity(validation);
}
var entity = await _repository.GetByIdAsync(id);
if (entity is null)
{
return NotFound();
}
entity.Name = request.Name.Trim();
var saved = await _repository.UpdateAsync(entity);
return Ok(new SampleEntityResponse
{
Id = saved.Id,
Name = saved.Name
});
}
private static ValidationErrorResponse Validate(SampleEntitySaveRequest request)
{
var response = new ValidationErrorResponse();
if (string.IsNullOrWhiteSpace(request.Id))
{
response.Errors[nameof(SampleEntitySaveRequest.Id)] = ["ID is required."];
}
if (string.IsNullOrWhiteSpace(request.Name))
{
response.Errors[nameof(SampleEntitySaveRequest.Name)] = ["Name is required."];
}
return response;
}
}
public class ValidationErrorResponse
{
[JsonPropertyName("message")]
public string Message { get; set; } = "Please check the highlighted fields.";
[JsonPropertyName("errors")]
public Dictionary<string, string[]> Errors { get; set; } = [];
}
public class SampleEntitySaveRequest
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
}
public class SampleEntityResponse
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
}
public class SampleEntity
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
}
public interface ISampleEntityRepository
{
Task<SampleEntity?> GetByIdAsync(string id);
Task<bool> ExistsAsync(string id);
Task<SampleEntity> InsertAsync(SampleEntity entity);
Task<SampleEntity> UpdateAsync(SampleEntity entity);
}
The server can choose 422 for validation errors and 409 for conflicts. Cotomy maps them to different exception types, but both still reach the submit failure path in a form submission. Cotomy does not require that exact JSON shape, but the screen needs one stable contract. That is an application decision, not a Cotomy rule.
Form-Side Validation
On the TypeScript side, the form subclass owns screen-level feedback. The page controller still owns page initialization, and the API still owns server authority.
import {
CotomyApiException,
CotomyApiResponse,
CotomyElement,
CotomyEntityFillApiForm,
CotomyPageController
} from "cotomy";
type ValidationErrorResponse = {
message?: string;
errors?: Record<string, string[]>;
};
class SampleEntityEditForm extends CotomyEntityFillApiForm {
private readonly status: CotomyElement;
private readonly summary: CotomyElement;
public constructor(element: HTMLElement, status: CotomyElement, summary: CotomyElement) {
super(element);
this.status = status;
this.summary = summary;
}
public override initialize(): this {
if (this.initialized) {
return this;
}
super.initialize();
this.submitFailed(async (event) => {
await this.renderSubmitFailureAsync(event.response);
});
this.apiFailed((event) => {
if (event.response.status >= 500 || event.response.status === 0) {
this.summary.text = "The operation could not be completed. Please try again later.";
}
});
return this;
}
public override async submitAsync(): Promise<void> {
this.clearMessages();
if (!this.validateFields()) {
return;
}
try {
await super.submitAsync();
} catch (error) {
if (!(error instanceof CotomyApiException)) {
throw error;
}
}
}
protected override async submitToApiAsync(formData: FormData): Promise<CotomyApiResponse> {
const response = await super.submitToApiAsync(formData);
if (response.ok) {
this.status.text = response.status === 201 ? "Created." : "Saved.";
}
return response;
}
private validateFields(): boolean {
let valid = true;
const id = this.first('input[name="Id"]');
if (id && id.value.trim() === "") {
this.setFieldError("Id", "ID is required.");
valid = false;
}
const name = this.first('input[name="Name"]');
if (name && name.value.trim() === "") {
this.setFieldError("Name", "Name is required.");
valid = false;
}
if (!valid) {
this.summary.text = "Please check the highlighted fields.";
}
return valid;
}
private async renderSubmitFailureAsync(response: CotomyApiResponse): Promise<void> {
if (response.status !== 400 && response.status !== 409 && response.status !== 422) {
this.summary.text = `Submit failed. status: ${response.status}`;
return;
}
const body = await response.objectAsync<ValidationErrorResponse>({
message: "Please check the highlighted fields.",
errors: {}
});
this.summary.text = body.message ?? "Please check the highlighted fields.";
for (const [field, messages] of Object.entries(body.errors ?? {})) {
this.setFieldError(field, messages.join(" "));
}
}
private setFieldError(fieldName: string, message: string): void {
const input = this.first(`[name="${fieldName}"]`);
const error = this.first(`[data-validation-for="${fieldName}"]`);
input?.attribute("aria-invalid", "true");
input?.addClass("is-invalid");
if (error) {
error.text = message;
}
}
private clearMessages(): void {
this.status.text = "";
this.summary.text = "";
this.find("[aria-invalid]").forEach(input => {
input.attribute("aria-invalid", null);
input.removeClass("is-invalid");
});
this.find("[data-validation-for]").forEach(error => {
error.text = "";
});
}
}
CotomyPageController.set(class extends CotomyPageController {
protected override async initializeAsync(): Promise<void> {
await super.initializeAsync();
const form = this.body.first("#sample-entity-edit-form");
const status = this.body.first("#edit-status");
const summary = this.body.first("#edit-error-summary");
if (!form || !status || !summary) {
return;
}
this.setForm(new SampleEntityEditForm(form.element as HTMLFormElement, status, summary));
}
});
This example deliberately keeps the rule simple. The client checks whether required fields are empty. The server still validates the same fields and handles conflicts.
The duplicate validation is not a contradiction. The client path is only immediate feedback. The server path is authority.
Why submitFailed and apiFailed Are Separate
CotomyApiForm exposes apiFailed and submitFailed. For a submit request that fails with a CotomyApiException, the current implementation triggers both.
The useful distinction is how the screen treats the failure. submitFailed is the natural place to render validation and conflict messages because the failed operation was the user’s save action. apiFailed is useful for broader API failure handling, such as a session problem, service outage, or infrastructure-level status message.
The response object is the same kind of CotomyApiResponse in both handlers. The separation is not a different transport mechanism. It is a screen-design boundary.
Architectural Boundary
The page controller should not become a validation rule container. It should create the page flow, locate the form, and register it through setForm.
The form subclass can own UI feedback because it is directly attached to the form and its message targets. That keeps field display, summary display, and submit timing close together.
The API layer owns validation authority. It decides whether the request is accepted, rejected as invalid, rejected as a conflict, or rejected for another reason. CotomyApi maps that HTTP result into a consistent exception path, but it does not decide the business meaning of the response.
That separation is the main practical point. Validation UI can be standardized without moving business rules into the browser.
Practical Guide
This article is part of the Cotomy Practical Guide, which focuses on hands-on usage patterns for the framework.
Series articles: Working with CotomyElement , CotomyPageController in Practice , Standardizing CotomyPageController for Shared Screen Flows , Building Business Screens with Cotomy , and Handling Validation and Error Display with Cotomy.
Next
Next article: Building Rich CRUD Screens with Cotomy is planned, focusing on how to add richer UI behavior without losing the same screen boundary.
Links
Previous: Building Business Screens with Cotomy . More posts: /posts/ .