
ujjaIntroduction If you've worked on a long-lived frontend, you already know the story. The...
If you've worked on a long-lived frontend, you already know the story. The app grows, features pile up, deadlines keep coming, and suddenly you're sitting on a mountain of technical debt.
That's exactly where we were.
We had a large Angular 14 application with 600+ components, a
monolithic structure, and increasing complexity that was slowing down
development. A full rewrite sounded tempting, but also risky,
expensive, and disruptive to the business.
So instead of going for a big-bang rewrite, we designed a migration
strategy that let us incrementally replace the legacy code using:
This post walks through the architecture, the migration approach, and
the lessons we've learned so far.
Our Angular application had been the backbone of our business for years.
It handled:
It worked. It delivered value. But it had started to show its age.
Monolithic architecture
One root module with 637 declared components made the codebase
hard to reason about.
Manual dependency injection
A custom HTTP service was manually instantiated in 60+ places,
bypassing Angular's DI.
Tight coupling
Components were directly tied to specific API shapes.
Limited reusability
UI components were Angular-specific and couldn't be reused
elsewhere.
Slow builds
Build times kept growing with the app.
Technology debt
- Angular 14
- Bootstrap 4
- jQuery dependencies
The app had grown organically across multiple environments (dev, test,
uat, prod). A full rewrite would likely take 12-18 months and carry
serious business risk.
So we needed a safer approach.
At a high level:
This lets us replace features one at a time without disrupting the
business.
We adopted the Strangler Fig pattern, gradually replacing parts of the system while the old one keeps running.
Our approach had three core pillars:
We built a monorepo using pnpm and Turborepo.
monorepo/
├── apps/
│ ├── auth-service/
│ ├── graphs/
│ ├── services/
│ └── notification-service/
├── packages/
│ ├── design-system/
│ ├── authentication/
│ ├── logger/
│ ├── database/
│ └── web-components/
Instead of a monolithic REST API, we created a domain‑based GraphQL
services.
type Business {
id: ID!
name: String!
subscriptionPlans: [Plan!]!
defaultPlanId: Int
}
type Query {
searchBusinesses(country: String!, searchTerm: String!): [Business!]!
}
We used web components to embed React features into the Angular app.
import { r2wc } from '@r2wc/react-to-web-component';
import { UserRegistrationWithApollo } from './UserRegistration';
const UserRegistrationWC = r2wc(UserRegistrationWithApollo, {
props: {
businessGraphApiUrl: 'string',
providerGraphApiUrl: 'string',
},
});
customElements.define('user-registration', UserRegistrationWC);
<user-registration
[businessGraphApiUrl]="businessApiUrl"
[providerGraphApiUrl]="providerApiUrl">
</user-registration>
export const createApolloClients = (
businessUri: string,
providerUri: string
) => {
const businessClient = new ApolloClient({
link: authLink.concat(httpLink(businessUri)),
cache: new InMemoryCache(),
});
const providerClient = new ApolloClient({
link: authLink.concat(httpLink(providerUri)),
cache: new InMemoryCache(),
});
return { businessClient, providerClient };
};
export async function POST(request: Request) {
const { refreshToken } = await request.json();
const newTokens = await refreshCognitoToken(refreshToken);
return Response.json({
accessToken: newTokens.accessToken,
idToken: newTokens.idToken
});
}
@Injectable()
export class DataService {
constructor(private prisma: PrismaClient) {}
async getBusiness(id: number) {
return this.prisma.business.findUnique({
where: { id },
include: {
subscriptionPlans: true,
locations: true
}
});
}
}
export const Feature = () => {
const { data, loading } = useQuery(GET_DATA_QUERY);
if (loading) return <Spinner />;
return (
<Card>
<CardHeader>
<CardTitle>{data.title}</CardTitle>
</CardHeader>
<CardContent>
{/* Feature implementation */}
</CardContent>
</Card>
);
};
const FeatureWC = r2wc(FeatureWithApollo, {
props: {
apiUrl: 'string',
userId: 'string'
}
});
customElements.define('app-feature', FeatureWC);
import '@company/wc-feature';
<app-feature
[apiUrl]="apiUrl"
[userId]="currentUser.id">
</app-feature>
<app-feature *ngIf="featureFlags.useNewFeature"></app-feature>
<legacy-feature *ngIf="!featureFlags.useNewFeature"></legacy-feature>
Sunsetting a legacy app doesn't have to mean a risky rewrite.
By combining:
...we've been able to modernise one feature at a time while keeping the business running smoothly and making life easier for developers.
The strangler fig pattern really works. It is a framework for thinking about incremental migration instead of forcing a “big bang” rewrite or living with legacy forever. Each new feature in React, each GraphQL service, each shared package brings more value immediately while paving the way for the next step.
What we’ve realised is that modernisation is not just about technology. It’s about workflow, confidence, and developer experience. You can gradually introduce modern tools and patterns, reduce duplicate code, improve type safety, and simplify builds, all while delivering the features your business actually needs.
At the end of the day, you do not have to choose between chaos and stagnation. Incremental migration lets you move forward with clarity, modernise without drama, and build a foundation that can grow with your team and your users for years to come.