The Architecture Decisions Behind DotShare's Draft System — What I Got Wrong First

Every decision in DotShare v3.2.5's Universal Drafts System — the union types, the globalState choice, the upsert pattern — and the wrong versions I tried before landing on these.
Good architecture is invisible. You read the final code and it feels obvious — of course it should work this way. What you don't see are the three versions that came before it.
This is the story of building DotShare's Universal Drafts System: the wrong decisions, why they were wrong, and what replaced them.
The Problem
DotShare is a VS Code extension that lets you publish posts and articles to nine platforms — LinkedIn, X, Bluesky, Dev.to, Medium, and more — without leaving the editor. It uses a WebView panel for the UI.
WebViews reset. That's a fact of VS Code's architecture. They get suspended when hidden and wiped on restart. Before v3.2.5, every piece of state you hadn't explicitly saved was gone the moment you looked away.
For a LinkedIn post that's mildly annoying. For a Dev.to article with YAML frontmatter, tags, a cover image URL, canonical URL, series name, and 2,000 words of body — it's a real problem.
Wrong Decision #1: Two Separate Systems
My first instinct was to build two separate systems — SocialDraftsService for posts and ArticleDraftsService for blog content. Different storage keys, different interfaces, different load/save logic.
This lasted about two hours before I realized the problem: loading a draft should be a single operation regardless of where it came from. If the WebView has to know whether a draft is "social" or "article" before it knows which service to call, you've already lost. Every feature that touches drafts — the draft grid, the Load button, the Delete action — now has to branch on type.
The fix was obvious once I saw it: one interface, union type on data:
export interface Draft {
id: string;
type: 'social' | 'article';
timestamp: string;
platforms: SocialPlatform[];
data: PostData | BlogPost;
title?: string;
isRemote?: boolean;
remoteId?: string;
}
type is the discriminant. Code that needs to branch switches on it. Everything else treats a Draft as a Draft — load, list, delete, render. One service, one storage key, one message command per operation.
Wrong Decision #2: File-Based Storage
Before globalState, I tried writing drafts to files. The logic seemed reasonable: files are persistent, human-readable, work with git, and the user has full control.
The problems appeared immediately:
No workspace, no drafts. DotShare works even without a folder open. File storage requires a path. If there's no workspace, there's no path.
Which folder? If the user has three projects open in separate VS Code windows, which one gets the drafts file? The extension has no reliable way to know which workspace "owns" a draft.
Git noise. A drafts.json file in the root of your project is going to show up in every git status. Most users don't want their work-in-progress LinkedIn posts committed to their code repository.
Social post drafts have no file home. A Dev.to article has a natural .md file. A LinkedIn update does not. Putting it in a JSON file alongside your source code feels wrong because it is wrong.
globalState solves all of these. It's VS Code's own key-value store — encrypted at rest on macOS, survives restarts, works without a workspace, and is completely invisible to git. The only real limitation is it's not designed for large datasets, but a list of text drafts will never get close to that limit.
const DRAFTS_KEY = 'dotshare_drafts_v1';
export class DraftsService {
constructor(private globalState: vscode.Memento) {}
getDrafts(): Draft[] {
return this.globalState.get<Draft[]>(DRAFTS_KEY, []);
}
saveDraft(data: Omit<Draft, 'id' | 'timestamp'>): Draft {
const draft: Draft = {
...data,
id: `draft_\({Date.now()}_\){Math.random().toString(36).slice(2, 9)}`,
timestamp: new Date().toISOString(),
};
this.globalState.update(DRAFTS_KEY, [draft, ...this.getDrafts()]);
return draft;
}
}
The _v1 suffix on the key is a convention I now use on every globalState key. When the schema changes, you read _v1, transform, write _v2, delete _v1. No breaking changes, no migration scripts.
Omit<Draft, 'id' | 'timestamp'> at the saveDraft parameter is a compile-time guard. Callers cannot pass stale IDs or hand-crafted timestamps — those are always generated inside the service.
Wrong Decision #3: Insert on Every Save
The first version of the save handler did a blind insert every time. Press Ctrl+S three times while writing an article, get three draft entries with slightly different body content.
The fix was an upsert. The first save generates an ID and sends it back to the WebView. Every subsequent save passes that ID in the message, and the handler updates in place:
const existingId = message.draftId as string | undefined;
if (existingId) {
const updated = this.draftsService.updateDraft(existingId, draft);
this.view.webview.postMessage({ command: 'draftLoaded', draft: updated });
} else {
const saved = this.draftsService.saveDraft(draft);
this.view.webview.postMessage({ command: 'draftLoaded', draft: saved });
}
The WebView stores the returned draftId in component state. From that point on, every save is an update. The draft list stays clean regardless of how many times you save during a session.
Wrong Decision #4: No Guard on Remote Drafts
DotShare can fetch your existing Dev.to articles and display them alongside local drafts in the same grid. Early on, I forgot to distinguish them in the save handler. A user could click "Save Locally" on a remote Dev.to draft and create a local copy that immediately diverged from the server version.
The isRemote flag fixes this at the handler level:
if ((draft as Draft).isRemote) {
this.sendError('Cannot save a remote draft locally. Use "Update" instead.');
return;
}
The WebView also checks isRemote before rendering the Save button — so the user never even sees the option on remote drafts. But the backend guard exists independently. Defense in depth.
The Handler Chain
DraftsService is instantiated once in MessageHandler and injected into PostHandler. One instance, one source of truth:
export class MessageHandler {
constructor(...) {
this.draftsService = new DraftsService(context.globalState);
this.postHandler = new PostHandler(
view, context, historyService,
analyticsService, mediaService,
this.draftsService // ← single injection point
);
}
}
Routing is a string check — any command containing Draft goes to PostHandler:
if (cmd.includes('Draft') || cmd.startsWith('share') || ...) {
await this.postHandler.handleMessage(message);
}
Adding a new draft command in the future requires zero changes to MessageHandler. The routing rule generalizes automatically.
What the Final Design Looks Like
WebView
│
│ { command: 'saveLocalDraft', draft: {...}, draftId?: 'existing' }
▼
MessageHandler → cmd.includes('Draft') → PostHandler
│
isRemote? → reject
draftId? → updateDraft
else → saveDraft
│
DraftsService
(globalState)
│
postMessage: draftsLoaded
postMessage: draftLoaded
Clean. Predictable. Every path has one entry point and one exit.
Part 2
The storage and type decisions are done. In Part 2, I cover the implementation details:
The Two-Way Markdown Sync — loading a draft rewrites the active
.mdeditor file simultaneously so the WebView and editor never disagreeRemote Dev.to draft fetching and the
updateDevToArticleflowThe WebView draft card UI with VS Code CSS variables
The Split-Editor workflow that opens a named
.mdfile beside the panel on launch
Try It
DotShare is free and open source under Apache 2.0:
VS Code Marketplace: Download Extension (or search
ext install freerave.dotshare)Open VSX Registry: Download for VSCodium
GitHub: github.com/kareem2099/DotShare
If this saved you time, a ⭐ on the repo means a lot!
Built— FreeRave





