Decomposing a Monolith into Smart Surfaces
Back to blog

Decomposing a Monolith into Smart Surfaces

·Dan Castrillo

the problem with rewrites

monolithic frontend apps 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

take the monolith and decompose it into ~25 independent micro-apps. each one handles 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>

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.

the 4-stage pipeline

we built a pipeline with four stages:

catalog

figure out what surfaces 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

define the surface 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. it's a contract: the surface promises to handle these inputs and produce these outputs.

prototype

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

take the prototype, harden it, add 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

you never throw away the old app. you peel 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.

five teams can build five surfaces at the same time 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.

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. decompose, don't rewrite.

Related Posts