Handling Validation and Error Display with Cotomy

How to handle client-side field checks, server validation responses, and API failures in Cotomy without mixing business rules into the UI layer.

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/ .

Learn Cotomy

Cotomy is a DOM-first UI runtime for long-lived business applications.