Previous article: The Birth of the Page Controller
Why I had to think so much about forms at all
I have mentioned form architecture many times in other articles, but I have not properly written down why it grew into several abstraction layers.
That structure did not come from a desire to make forms academically elegant. It came from the fact that web application structure felt deeply unnatural to me for a long time.
My own development background began with Visual C++ and Visual Basic 6, then continued through C# and Java Swing before serious web work became central. Because of that background, the difference between desktop applications and web applications was not a small implementation detail. It was a structural shock.
In desktop development, a form is usually a stable screen object. The screen exists, events are attached to it, and the code around it has one visible owner. In the web, especially in the years when I was building many business systems without a comfortable framework base, the structure felt fragmented. HTML existed in one place, JavaScript in another, request handling somewhere else, and the real screen flow emerged only after those pieces happened to cooperate.
That was the part I found difficult to accept.
Even now, in the range of teams I personally know, desktop-oriented teams often do not move to the web quickly. And when they do, many of them buy expensive third-party control suites that let them treat web screens somewhat like desktop screens. I understand that reaction very well.
Why ASP.NET still mattered to me even when I could not use it
Looking back, ASP.NET had a very strong appeal for exactly this reason. The idea that you could define events in a way that felt much closer to desktop development, and have the surrounding wiring largely standardized, was genuinely innovative. What I valued there was not the idea of reviving Web Forms as-is, but the fact that event flow had an explicit owner instead of being left as scattered page convention.
I should say this clearly: I am not a Microsoft partisan. I have simply spent many years in environments where Microsoft products were practical and useful.
If I had been in a position where I could choose ASP.NET and C# freely from the beginning, I might not have struggled with web architecture in the same way. But in reality, I was often in situations where even adopting C# itself was difficult. That meant I could not rely on the strong architectural assistance those products offered.
At the same time, I also suspect that if I had received too much support too early, I might have understood the web less deeply. And if that had happened, Cotomy probably would not exist.
The first web frustration was not the DOM itself
When I first started struggling with web development, the biggest irritation was not HTML. It was JavaScript.
JavaScript certainly supported object-oriented programming in some sense, but for a long time it did not feel like a language that naturally encouraged the kind of structured object-oriented work I wanted. jQuery made DOM operations much easier, and I depended on it heavily for a period, but it did not give me the kind of application structure I was looking for.
The source of my dissatisfaction was simple. There was no class-based structure that I could trust as the real shape of the screen.
That changed when I adopted TypeScript. Once I could define stable classes, I built ElementWrap, which later became CotomyElement, and then extended that foundation into several specialized directions.
At that point, the question stopped being how to manipulate the DOM more conveniently. The question became how to represent a screen as a consistent unit at all.
Why form inheritance looked wrong at first
When I began to shape the form layer, I hesitated.
In desktop application development, I rarely saw form classes deeply inherited in a meaningful way. Perhaps some teams did that, but I did not personally encounter it often, and I was not eager to do it myself. At most, an application might define a shared base form for a common toolbar or status bar. Even that kind of inheritance can be controversial, so if I had only been thinking with desktop habits, I probably would not have designed a multi-layer form hierarchy.
But the web changed the problem.
The real difference was not visual controls. It was the client-server relationship. Once that difference became the center of the problem, forms stopped being one thing.
The split was not only about transport. It was about three different axes that kept colliding in real screens: transport, meaning GET navigation versus POST submission versus API calls; identity, meaning whether the screen already owned an entity key; and lifecycle, meaning whether the screen navigated away or updated itself in place.
A form could navigate with GET. A form could submit data with POST. A form could call an API through Ajax and remain on the same screen.
Those were not cosmetic variations. They were different runtime behaviors.
A single form abstraction breaks down because GET navigation, POST submission, and API-driven updates do not share the same lifecycle. Treating them as one leads to duplicated logic, inconsistent state transitions, and unclear ownership.
In practice, this means the same screen starts breaking in ordinary ways. After one submit, reload may behave like GET navigation while the latest save happened through Ajax, the URL no longer explains the current state, and the screen ends up with two state sources: what the DOM currently shows and what the last API response implied. This is the point where a single form abstraction stops being a simplification and becomes a hiding place for contradictory behavior.
That is why I ended up classifying forms around transport and lifecycle rather than only around screen appearance. And that is also why the result became a hierarchy rather than a loose set of composable helpers. An execution path is not an optional decoration on top of a form. It is the form’s structural meaning. If that meaning is assembled too freely at runtime, informal paths reappear and the same instability returns under a different style.
Why one base form was still necessary
Even after that classification, I still wanted every one of those paths to remain recognizably a form.
That was important to me. If GET-based search, POST submission, and Ajax submission were all treated as unrelated ad hoc techniques, the architecture would split again immediately.
So I introduced one base class whose role was simply to make form ownership explicit. In the current Cotomy source, that base is CotomyForm. It extends CotomyElement, keeps the screen rooted in an actual form element, standardizes initialization, and defines submitAsync as the one required boundary.
From there, specialized subclasses diverge by behavior.
The current class structure in Cotomy
The current hierarchy in src/form.ts looks like this. What matters in this diagram is not visual form type. It shows responsibility separation through runtime behavior.
classDiagram
class CotomyElement
class CotomyForm {
#method: string
+actionUrl: string
+autoReload: boolean
+initialize() this
+reloadAsync() Promise~void~
+submitAsync() Promise~void~
}
class CotomyQueryForm {
#method: string = "get"
+submitAsync() Promise~void~
}
class CotomyApiForm {
+actionUrl: string
#method: string = "post"
+formData() FormData
+submitAsync() Promise~void~
}
class CotomyEntityApiForm {
+entityKey: string | undefined
+actionUrl: string
#method: string
}
class CotomyEntityFillApiForm {
+reloadAsync() Promise~void~
#loadActionUrl: string
#canLoad: boolean
#bindNameGenerator() ICotomyBindNameGenerator
+renderer() CotomyViewRenderer
}
class CotomyApi
class CotomyViewRenderer
CotomyElement <|-- CotomyForm
CotomyForm <|-- CotomyQueryForm
CotomyForm <|-- CotomyApiForm
CotomyApiForm <|-- CotomyEntityApiForm
CotomyEntityApiForm <|-- CotomyEntityFillApiForm
CotomyApiForm ..> CotomyApi : submits
CotomyEntityFillApiForm ..> CotomyViewRenderer : renders
This is not a speculative diagram. It reflects the classes that currently exist in Cotomy.
The key point is that the hierarchy is not trying to model visual form types. It is modeling runtime responsibilities. CotomyQueryForm fixes the transport path for navigation, CotomyApiForm fixes the submission path, CotomyEntityApiForm fixes how identity changes the operation, and CotomyEntityFillApiForm fixes the load and render path on top of that. That is how the three axes of transport, identity, and lifecycle are turned into explicit runtime boundaries.
Each form layer owns exactly one responsibility: navigation through GET, submission through POST or PUT, entity identity, or load and render. Mixing these responsibilities inside one abstraction was the source of instability.
Why the hierarchy had to keep growing
The first major split was simple.
CotomyQueryForm exists because query navigation should stay a GET concern. It rebuilds query parameters and navigates with the resulting URL.
CotomyApiForm exists because API submission has a different lifecycle. It gathers FormData, normalizes datetime-local inputs, submits through CotomyApi, and exposes failure events.
CotomyEntityApiForm adds another distinction that became essential in business CRUD screens. Creation and update often target the same logical resource, but they differ by whether the screen already has an entity key. That is why the current implementation switches between POST and PUT automatically based on the data-cotomy-entity-key attribute.
Older web systems often pushed everything through POST, and I worked in periods where that was still common enough. But I do not think that is where the design should stop. If create and update are different operations, it is better to use them in a form the runtime can understand explicitly. That also matches the more natural business expectation that an existing record keeps its identity rather than pretending every save is the same kind of operation.
<form action="/api/users" data-cotomy-entity-key="42">
<input name="name" />
</form>
With that attribute present, CotomyEntityApiForm treats the form as an update and builds the action URL by appending the key. Without it, the same form goes through the create path.
That behavior matters because it keeps one CRUD screen inside one structural model. I did not want create screens and edit screens to become different species of frontend code. In business applications, it is normal to want one screen model for both create and edit. But at the HTTP level they are still different operations. CotomyEntityApiForm exists because I wanted that difference to stay explicit without splitting the whole screen into separate architectures.
Without that boundary, create and update flows tend to split in quiet ways. Submit handling diverges, reload paths follow different assumptions, and the same screen starts carrying multiple informal rules about which endpoint shape applies in which state.
Why loading created another abstraction layer
Even that was still not enough.
The next problem was loading.
In desktop software, it is natural for a form to load and hold data directly in a way that feels local to that screen. Web applications are more awkward. If the frontend starts defining its own parallel data shape too aggressively, the same practical data contract gets restated in too many places.
I understand perfectly well why DTOs exist. I use them too. My objection was never to an intermediate layer itself. The thing I disliked was defining the same effective shape again and again across server load handling, screen expansion, and submit flow when the screen was still functionally one vertical slice of the same business feature. The practical problem is that the same screen can easily turn into three overlapping definitions: a load DTO, a submit DTO, and a UI state model that has to keep the other two aligned.
That concern is weaker in large SPA systems, because those systems are often genuinely organized around explicit API contracts between more independent frontend and backend layers. But many of the systems I build are not like that. They are made of many business features that remain vertically sliced and operationally local.
That is why CotomyEntityFillApiForm appeared. It adds loadAsync, fillAsync, and renderer-based reflection so one form can both submit and refill itself through one consistent path.
This was the concrete failure pattern I wanted to remove. When submit and reload travel through separate routes, DTO assumptions, DOM state, and form state start drifting apart. The same business screen then has to be re-understood through several overlapping mechanisms instead of one execution model.
It also means the framework can say something very explicit: if a screen is fundamentally an entity-oriented form, then load, fill, submit, and UI reflection belong to one family of behavior.
The override points were part of the design from the beginning
Another important point is that I did not want this form model to become rigid. Business systems always contain exceptions.
So the hierarchy did not only classify behavior. It also exposed stable override points.
The goal was not to create more abstractions, but to remove informal paths. Each form type exists to make one execution path explicit and predictable.
One example is when the load path should not exactly match the submit path. CotomyEntityFillApiForm currently defaults loadActionUrl to actionUrl, and canLoad to whether an entity key exists. But both are designed to be overridden.
import { CotomyEntityFillApiForm } from "cotomy";
class CustomerCodeForm extends CotomyEntityFillApiForm {
protected override get canLoad(): boolean {
return !!this.attribute("data-customer-code");
}
protected override get loadActionUrl(): string {
const code = this.attribute("data-customer-code");
return code
? `/api/customers/by-code/${encodeURIComponent(code)}`
: this.attribute("action")!;
}
}
That pattern is important when an existing endpoint uses a natural identifier or another route convention that does not match the default surrogate-key path.
Another real extension point is the bind name generator. CotomyEntityFillApiForm uses CotomyViewRenderer internally, and the current implementation exposes bindNameGenerator for exactly that reason.
import {
CotomyDotBindNameGenerator,
CotomyEntityFillApiForm,
ICotomyBindNameGenerator
} from "cotomy";
class DotNameForm extends CotomyEntityFillApiForm {
protected override bindNameGenerator(): ICotomyBindNameGenerator {
return new CotomyDotBindNameGenerator();
}
}
And when submission itself needs a different identifier path, the entity-level action and method can also be overridden.
import { CotomyEntityApiForm } from "cotomy";
class LegacyCustomerForm extends CotomyEntityApiForm {
public override get actionUrl(): string {
const code = this.attribute("data-customer-code");
const base = this.attribute("action")!;
return code
? `${base}/by-code/${encodeURIComponent(code)}`
: base;
}
protected override get method(): string {
return this.attribute("data-customer-code") ? "put" : "post";
}
}
The important part here is not just flexibility. It is that flexibility stays inside a known class boundary instead of escaping into per-screen improvisation.
That is also where Cotomy’s value becomes practical. It removes some freedom on purpose. Screens are not encouraged to invent their own submit route, their own reload contract, or their own entity identification rule every time. Those choices are narrowed into a small number of regular paths. Cotomy did not gain value by increasing the number of form classes. It gained value by reducing the number of execution paths a screen is allowed to have.
In concrete terms, submit is routed through one form-owned path, load and submit stay inside the same form family, entity identity is attached to the form through DOM attributes, and render reflection is tied to the same load boundary instead of being scattered. That loss of freedom is what makes the screen model harder to break. State transitions become easier to predict, and debugging cost goes down because fewer informal routes are available. It also reduces state inconsistency, because one form keeps one execution boundary and structurally limits how mutation paths can diverge.
Why this architecture is not trying to fit every frontend style
I do not think the form classes prepared in Cotomy are a natural fit for very large SPA applications.
That is not false modesty. It is simply a matter of target shape.
What I usually build is a large number of CRUD-oriented screens for many entity types, plus surrounding screens that still mostly show one coherent slice of information at a time. In that kind of system, the architectural question is not how to make one giant client state graph elegant. The question is how to keep a large system locally understandable.
In many frontend approaches, these concerns are distributed across components, hooks, and API layers. Cotomy instead centralizes them into a small number of form types to reduce coordination cost.
That is what the form hierarchy was for.
It let me keep GET navigation, API submit, entity identification, loading, filling, and rendering inside a small number of repeatable structures. That reduced the number of ways a screen could become strange.
My own conclusion now
Looking back, I think this part of Cotomy was not born from theory. It was born from discomfort.
I had too much desktop instinct to be satisfied with loose frontend scripting. But I also had too much direct web experience by that point to pretend the browser behaved like Windows Forms.
So the only realistic option was to define a web-native structure that still gave me explicit class ownership. That is what the form abstraction became.
In my view, this was the right decision for the kind of business systems I build. It gave me one more way to make each screen understandable as a local unit instead of a loose agreement between markup, JavaScript, and network calls. That is also why it does not fully resemble typical SPA design. It was built for server-led business screens that still need explicit client-side structure, not for a frontend architecture that assumes the client owns everything.
Development Backstory
This article is part of the Cotomy Development Backstory, which traces how Cotomy’s architecture emerged through real project constraints.
Series articles: Building Systems Alone , Early Architecture Attempts , The First DOM Wrapper and Scoped CSS , API Standardization and the Birth of Form Architecture , Page Representation and the Birth of a Controller Structure , The CotomyElement Constructor Is the Core , Dynamic HTML Boundaries in CotomyElement , Reaching Closures to Remove Event Handlers Later , The Birth of the Page Controller , and The Birth of the Form Abstraction.
Next article: Data Binding as a Structural Problem is currently being drafted.