Back to blog

Decomposing a Monolith into Smart Surfaces

the problem with rewrites

so here's the thing about monolithic frontend apps — they work great until they don't. at Upwork we had this massive Nuxt application that handled basically everything. job posting, proposals, contracts, messaging, payments, settings. all of it living in one repo, one build, one deploy pipeline. and every time someone touched one page they'd hold their breath hoping nothing else broke.

the classic answer is "let's rewrite it" but rewrites are where engineering teams go to die. you spend 18 months rebuilding what already exists, the old app keeps evolving while you're rewriting, and by the time you ship v2 it's already behind v1 in features. i've seen this play out enough times to know better.

so instead of a rewrite we landed on something different: smart surfaces.

what's a smart surface

the idea is pretty straightforward. take the monolith and decompose it into ~25 independent micro-apps. each one handles exactly one user workflow. applying to a job? that's a surface. reviewing proposals? another surface. managing your payment methods? surface.

each surface is a self-contained SPA. it owns its own routes, its own state, its own components. it can be developed, tested, and deployed independently. here's what a typical surface looks like:

<!-- Smart Surface: Job Application Review -->
<template>
  <FluidPageLayout>
    <ApplicationHeader :job="job" />
    <ProposalList :proposals="proposals" @select="reviewProposal" />
    <ReviewPanel v-if="selected" :proposal="selected" />
  </FluidPageLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useJobApplications } from '@/composables/useJobApplications'
import FluidPageLayout from '@upwork/design-components/FluidPageLayout'
 
const { job, proposals } = useJobApplications()
const selected = ref(null)
 
function reviewProposal(proposal) {
  selected.value = proposal
}
</script>

notice how small that is. one layout, three components, one composable for data. that's the whole page. no router config shared with 50 other pages, no global vuex store mutations leaking across features. just a focused piece of UI that does one thing.

the 4-stage pipeline

the interesting part isn't the surfaces themselves — it's how we get there. we built a pipeline with four stages:

catalog

first you have to figure out what surfaces even exist in the monolith. we crawl the existing Nuxt app, identify distinct user workflows, and catalog them. each route or group of routes becomes a candidate surface. this is mostly automated — we scan the pages directory, extract route metadata, and map out what talks to what.

spec

once you've identified a surface you need to define it precisely. what data does it need? what components does it render? what user actions does it support? the spec captures all of this in a structured format. think of it like a contract — the surface promises to handle these inputs and produce these outputs.

prototype

this is where it gets fun. we use AI to generate a working implementation from the spec. not production code, but a functional prototype that renders real components with real data shapes. it's good enough to validate the spec, catch missing requirements, and get feedback from designers before anyone writes production code by hand.

surface

the final stage. take the prototype, harden it, add proper error handling and tests, wire up the real API layer, and deploy it. at this point you have a production-ready micro-app that can replace the corresponding piece of the monolith.

why this works better than a rewrite

the key insight is that you're never throwing away the old app. you're peeling surfaces off one at a time. the monolith keeps running, keeps getting bug fixes, keeps serving users. but every week there's one less page in it and one more independent surface handling that workflow.

it also means you can parallelize the work. five teams can build five surfaces simultaneously without stepping on each other. no merge conflicts across feature boundaries. no "wait for the core team to finish the shared layout component." each surface is its own world.

and if a surface turns out wrong? you throw it away and try again. you haven't bet the whole product on getting the architecture right upfront. you're making 25 small bets instead of one massive one.

the pipeline approach also means we're building institutional knowledge as we go. every surface that moves through catalog → spec → prototype → surface teaches us something about the next one. the tooling gets better, the specs get tighter, the prototypes get closer to production quality.

i'm still figuring out some of the edges — how surfaces communicate when they need to, how shared state works across boundaries, what happens when a surface needs data that another surface owns. but the core pattern feels right. decompose, don't rewrite.