If the Frontend Ends Up Handling Domain Logic
What refactoring the paste feature of a browser-based, real-time document editor taught me about designing domain logic on the frontend.
When people say "domain logic," they usually picture something the backend handles. Validation, state transitions — those judgment calls belong to the server, and the frontend just renders whatever comes back. That's how I thought about it too.
Then I refactored the paste feature of a real-time document editor, and the experience didn't match that picture at all.
Why the domain judgment had to live in the browser
A real-time text editor can't send a network request every time you press a key or paste something, asking the server "is this structure valid?" or "which model should represent this text?" If every keystroke went over the wire, you'd lose real-time responsiveness and undo/redo would fall apart.
So the domain judgment itself — the document's structure, its state, the rules for transforming it — has to live entirely inside the browser. The server's job becomes storing and syncing an already-finished domain model; the logic that decides "what structure is this" has to belong to the client.
I ran into this directly while refactoring paste. Going in, I assumed it would be simple: "just take the copied HTML and show it." Once I actually built it, I realized I was designing domain logic itself on the frontend.
Paste turned out to be less simple than it looked
Before I started, I thought this would be straightforward — grab the HTML string with clipboardData.getData('text/html') and hand it to the editor. But once I looked at what actually gets copied from MS Word, Excel, web pages, and other editors, reality looked different from what I expected.
1. HTML tags that only MS Office produces
HTML copied from MS Office carries vendor-specific style attributes like mso-padding-alt.
2. Tag nesting that breaks the spec
Non-standard tag nesting is common. A <table> sitting inside a <p> actually happens. Images, shapes (Office's VML shapes), tables, lists, and text formatting can all nest to arbitrary depth.
All of this has to be converted — not into a browser DOM tree, but into the domain model tree that the editor I was building understands. (It gets converted back to DOM at render time, but that's outside the scope of this post.)
Turning a string into a structured DOM tree is something the browser already does for you via DOMParser. But that only gets you "string to structured tree" — it says nothing about what that structure means to your specific editor. Designing the converter that turns an already-built DOM tree into a domain model tree — that judgment call — was the domain logic I had to build myself.
How I structured that domain logic — three patterns
1) Fix the algorithm's skeleton — Template Method
The first thing I nailed down was the conversion order. No matter what an HTML node looked like, the sequence of steps had to stay identical:
- Create the domain model that corresponds to this node.
- Walk the child nodes and convert them recursively.
- Run post-processing once conversion finishes.
That sequence never changes, whether the tag is <div> or <table>. What changes is only "what model gets created in step 1" and "what post-processing happens in step 3." So I fixed the skeleton in a parent class and let subclasses fill in the specifics.
// An abstract class that fixes the algorithm's skeleton
abstract class TagConverter {
protected node?: EditorNode;
protected converterContext?: ConverterContext;
convert(html: Node): void {
this.node = this.createNode(html);
html.childNodes.forEach((child) => {
const childConverter = this.createChildConverter(child.nodeName);
childConverter?.setConverterContext(this.converterContext);
childConverter?.convert(child);
});
this.onConvert();
}
protected abstract createNode(html: Node): EditorNode | undefined;
protected createChildConverter(tagName: string): TagConverter | undefined {
return this.converterContext?.createConverter(tagName);
}
protected onConvert(): void {}
}This is the GoF Template Method pattern. If you've written backend code in Java or Kotlin, this will look familiar — Spring's JdbcTemplate or a servlet's doGet()/doPost() work on the same principle. The framework (the parent) decides when and in what order things run; the user (the subclass) only fills in what runs. That's when it clicked for me that the underlying principle of structuring domain logic doesn't change between backend and frontend.
2) Once you pass 30 tag types — Factory Method and Abstract Factory
The next problem was volume. Paragraphs (<p>), bold (<b>), links (<a>), tables (<table>, <tr>, <td>), and even the VML shape tags MS Office spits out (<v:shape>, <v:group>, <v:textbox>...) added up to more than 30 tag types. Factor in general web page compatibility and the list only grew.
// Wrong approach: every new tag means reopening this function
function convertTag(tagName: string, html: Node) {
switch (tagName) {
case 'P':
/* ... */ break;
case 'B':
/* ... */ break;
case 'TABLE':
/* ... */ break;
// 30 tags means 30 cases, and multiple people editing this
// function at once means constant merge conflicts
}
}With this approach, adding one tag meant reopening the same switch statement every time. So I split it into one converter class per tag, and separated out the responsibility of deciding "which converter should handle this tag" as well. That responsibility is the Factory Method.
// Fixed: one converter class per tag
class ParagraphTagConverter extends TagConverter {
protected createNode(html: Node) {
return isParagraphLike(html) ? new ParagraphNode() : undefined;
}
// This converter decides for itself which converter handles each child tag
protected createChildConverter(tagName: string): TagConverter | undefined {
switch (tagName) {
case 'SPAN':
return this.converterContext?.getFactory().createSpanConverter();
case 'TABLE':
return this.converterContext?.getFactory().createTableConverter();
default:
return this.converterContext?.getFactory().createDefaultConverter();
}
}
}Then I grouped the responsibility of creating these converters behind a single factory interface — the Abstract Factory pattern, which produces a whole family of related objects (the converters) from one place.
abstract class TagConverterFactory {
abstract createParagraphConverter(): ParagraphTagConverter;
abstract createTableConverter(): TableTagConverter;
abstract createImageConverter(): ImageTagConverter;
// ... one per tag type
}Once this was in place, supporting a new tag no longer meant touching existing code. Add a converter class, register it on the factory, done. This was the first time the open-closed principle stopped being a textbook phrase and became something I felt in practice.
It wasn't perfect
Up to this point, the story reads like a clean win: domain logic, neatly structured with well-known patterns. But while testing the feature, I found a case where all of the above still broke.
As I mentioned earlier, copying from MS Office can actually produce a <table> nested inside a <p>. It's invalid per the HTML spec, but Word and PowerPoint really do put it on the clipboard that way. Handling this required an exception: "if the paragraph converter encounters a table as a child, the table shouldn't attach to the paragraph — it should attach to the paragraph's parent instead."
The problem was that this logic couldn't live inside a hook (createChildConverter, onConvert, and so on). It required changing the child traversal mechanism itself. So I had to reach back in and modify the base method after all.
class ParagraphTagConverter extends TagConverter {
// Ideally only the hooks should be overridden, but this ends up
// overriding convertChildNode, part of the base skeleton itself
protected convertChildNode(childHtml: Node): void {
const converter = this.createChildConverter(childHtml.nodeName);
if (converter instanceof TableTagConverter) {
const parentNode = this.node?.getParent();
// Attach the table to the paragraph's parent, not the paragraph
// ...
} else {
// ...
}
}
}The whole premise of Template Method is "the algorithm's skeleton is fixed; only the details are up to the subclass." But from this point on, this class started reaching into the skeleton itself. This is the classic structural weakness of Template Method: the Fragile Base Class problem. Inheritance-based design makes subclasses depend on the parent's implementation details, and once that boundary cracks once, it keeps cracking.
How did other projects design frontend paste logic?
Curious how other projects had solved the same problem, I looked into how ProseMirror — a widely used open-source rich text editor framework that many editor products are reportedly built on top of — handles paste.
ProseMirror takes a completely different route. No per-tag converter classes, no switch-case, no lookup table function. Instead, it declares "what node or mark this DOM structure corresponds to" as data in a schema.
// Example mark definition from ProseMirror's schema (from the official docs)
const emphasis = {
parseDOM: [{ tag: 'em' }, { tag: 'i' }, { style: 'font-style=italic' }],
toDOM() {
return ['em', 0];
},
};On paste, DOMParser.fromSchema(schema) collects every parseDOM rule declared across the schema, sorts them by priority, walks the DOM tree, and reconstructs the document by matching rules as it goes. Adding a new node type requires touching zero conversion code — you just add one parseDOM entry to the schema. Tree traversal and priority resolution are entirely owned by a single shared DOMParser engine the library provides; the developer only ever declares what should match what.
Two projects solved the exact same problem — "the frontend has to design domain logic" — in opposite ways. The editor I worked on solved it with a class hierarchy and Template Method. ProseMirror solved it by writing no imperative code at all, using a declarative rule table instead. To be fair, ProseMirror can do this because it's a general-purpose editor framework with a schema system built in at the framework level — it's not a direct apples-to-apples comparison with a converter built for one specific product.
Wrapping up
Looking back, the paste converter in this post is the clearest example I've hit of "the frontend is also a layer where you have to design domain logic."
- Because of the real-time editing requirement, the domain judgment that interprets and transforms document structure had to live in the browser, not the server.
- Deciding to structure that domain logic as a recursively extensible tree was the right call — covering images, shapes, and nested tables inevitably meant branching by tag and recursing down.
- Whether that structure had to be built with class inheritance and three GoF patterns is a separate question. A function-based registry mapping tag names to handlers, or a declarative rule table like ProseMirror's, could probably have reached the same goal.
This experience confirmed for me that "domain logic belongs on the backend" no longer holds, at least not for frontend apps that manage state in real time. The underlying principles of designing domain logic — fixing an algorithm's skeleton, delegating the details, separating out creation responsibility — don't differ between backend and frontend. The only thing that differs is which layer that logic ends up living in.