Retro: Shipping the Document Engine console rewrite

Retro: Shipping the Document Engine console rewrite

A quarter-long console rewrite, 3,400 lines deleted, bundle nearly halved. What I kept, what I tossed, what I would do differently.

March 15, 2026Phuong Tran
EssayENProcess
Retro: Shipping the Document Engine console rewrite

Last quarter I rewrote the console for the Document Engine project end-to-end. This is the retro — what I kept, what I tossed, and what I would do differently next time.

The starting state#

The original console was four years old. It had grown organically: a Forms page nobody understood, a Templates page that overlapped with it, a Documents page that was actually the only one anyone used. The codebase was Angular 14 with a hand-rolled Material clone and a state library nobody on the team had touched in six months.

What I kept#

The data shape. The backend had a sensible domain model — TemplateFormSubmissionDocument — and the API contracts were stable. There was no reason to touch that. I rebuilt the UI against the same endpoints.

The workflow. Users navigated the console in a roughly linear shape: pick a template, configure a form, send it out, collect submissions, render documents. The new UI made that path more visible (left rail, breadcrumbs that mean something) but did not invent a new flow.

What I tossed#

The state library. The codebase had a custom store that predated signals, predated NgRx component-store, and predated good judgment. I tore it out and replaced it with a thin signals-based store per feature. The diff was -3,400 lines.

// before — 80 lines of boilerplate per slice
@Injectable()
export class TemplatesStore extends EntityStore<Template> { ... }

// after — 12 lines, signal-based
@Injectable({ providedIn: 'root' })
export class TemplatesStore {
  private readonly api = inject(TemplatesApi);
  readonly templates = signal<Template[]>([]);
  readonly loading = signal(false);
  async load() { this.loading.set(true); this.templates.set(await this.api.list()); this.loading.set(false); }
}

What I would do differently#

I shipped the rewrite as one big PR. It worked, but the review took two weeks because there was no incremental story. Next time I would land the design system first, then one page at a time behind a feature flag.

Numbers#

Bundle: 412kb → 268kb. Initial render: 1.8s → 0.9s on a mid-tier laptop. Lighthouse perf score: 71 → 94. The biggest single win was deleting the custom Material clone; Angular Material is good now and I do not need to maintain a parallel set of components.

More importantly, the team can read the code again. That doesn't show up in any metric, but it shows up in how fast tickets close.

On rollout discipline#

The one thing I want to single out: rollout discipline matters more than the rewrite itself. When I finally merged the new console, I kept the old one mounted behind a ?legacy=1 query param for two weeks. Anyone who hit a problem could fall back, and I had a clean signal — if the legacy hit count stayed at zero, the new console was at least as good as the old one. Three users used the legacy escape hatch in the first week. All three reported bugs that I fixed within the day. By week two, the hatch was unused.

Without that safety net, I would have shipped on a Friday, broken something invisible on a Monday, and lost a week of trust. The escape hatch cost me one extra route definition. It bought me confidence I could not have bought any other way.

What I learned about my own habits#

I tend to over-design the abstractions on the first pass. The original signals store I wrote was generic — typed slices, a base class, lifecycle hooks. Two weeks in, I realized every concrete store was overriding the same three methods and the base class was just ceremony. I deleted it. The final shape is a flat per-feature store with zero inheritance, and the call sites read like English. I keep relearning this lesson and I will probably keep relearning it.

© 2026 Phuong Tran