In the first post, Sunsetting Legacy Angular with Generative AI, I explained how we modernised a large Angular frontend incrementally using:
- Next.js
- GraphQL
- A monorepo
- Web components
- Generative AI
That approach avoided a risky “big bang” rewrite while letting us ship new features safely.
The front-end is just half the picture. Behind it runs a large, serverless .NET backend, powering workflows like user registration, subscription management, and reporting.
Rewriting this backend from scratch would be risky, slow, and expensive. So we applied the same philosophy: Incremental modernisation using a monorepo, GraphQL, and generative AI.
The legacy backend we started with
Our backend is a .NET 6 serverless system running on AWS Lambda. Over time, it grew into a large production system with:
- 20+ domains
- Hundreds of Lambda handlers
- 100+ SQL tables
- Entity Framework Core for data access
- Cognito-based authentication
A typical Lambda handler looks like this :
public class GetEntityByIdFunction
{
private readonly AppDbContext _dbContext;
public GetEntityByIdFunction()
{
_dbContext = new AppDbContext();
}
public async Task<EntityDto> FunctionHandler(int entityId, ILambdaContext context)
{
var entity = await _dbContext.Entities
.Include(e => e.RelatedEntity)
.FirstOrDefaultAsync(e => e.Id == entityId);
if (entity == null)
{
throw new Exception("Entity not found");
}
return new EntityDto
{
Id = entity.Id,
Name = entity.Name,
RelatedName = entity.RelatedEntity.Name
};
}
}
This Lambda function fetches an entity using EF Core, but tightly couples database access, business logic, and API response. While it works, this structure makes incremental migration, testing, and reuse harder because changes in one part can break others.
Key challenges with the legacy backend
- Tightly coupled domains: Multiple business areas were mixed in the same codebase, making it hard to reason about changes or isolate functionality.
- Task-oriented endpoints: Handlers were shaped around Lambda functions, not business domains, which broke the principle of Separation of Concerns (SoC).
- High onboarding cost: Understanding relationships between handlers, EF models, and database tables took significant time for new developers.
- Legacy patterns: The codebase used a mix of LINQ queries, raw SQL, and stored procedures, creating inconsistency and hidden complexity.
- Difficult testing: Because logic, data access, and API shapes were tightly coupled, writing automated tests was tricky, especially for individual domains or workflows.
- Repetition of code: Similar logic and data access patterns were duplicated across handlers, increasing maintenance burden and risk of bugs.
A full rewrite would have been slow and risky, so we adopted incremental migration, like we did with the frontend.
Leveraging generative AI
We use AI to navigate legacy backend complexity. A typical prompt looks like:
Here's a legacy .NET Lambda handler and EF model.
Explain the business logic, dependencies, and risks if we migrate this to a new GraphQL service.
AI helps with:
- Summarizing large files
- Explaining logic
- Highlighting cross-domain dependencies
- Fixing hidden bugs
It reduces the time to understand legacy code and plan safe migrations.
How the pieces fit together
High-level architecture:
- Legacy .NET Lambdas keep running
- New features use GraphQL services
- Both access the same SQL database
- Frontend switches to GraphQL gradually
This enables safe, incremental migration.
Monorepo with Turborepo
We host all frontend and backend services in a monorepo:
monorepo/
├── apps/
│ ├── infra/
│ ├── graphs/
│ ├── services/
├── packages/
│ ├── authentication/
│ └── web-components/
Benefits:
- Shared code across services
- End-to-end TypeScript
- Faster builds
- Coordinated PRs across frontend and backend
GraphQL as the new backend layer
We replaced new REST endpoints with domain-focused GraphQL services.
Example schema
@ObjectType()
export class Entity {
@Field(() => ID)
id: string;
@Field()
name: string;
@Field()
relatedName: string;
}
@Resolver(() => Entity)
export class EntityResolver {
constructor(private readonly entityService: EntityService) {}
@Query(() => Entity)
async entity(@Args('id') id: string) {
return this.entityService.getEntityById(id);
}
}
The GraphQL schema defines exposed fields, and resolvers delegate business logic to services. This enforces separation of concerns, decouples API from data access, and allows incremental replacement of REST endpoints.
Prisma for database access
To simplify database access and improve type safety, our new GraphQL services use Prisma instead of Entity Framework Core. Prisma provides:
- Type-safe database queries: Ensures that the fields you request exist, reducing runtime errors.
- Simpler, declarative models: Your data model is defined in the schema.prisma file, making relationships and constraints explicit.
- Easier migrations: Schema changes are tracked and applied incrementally, which fits our incremental modernisation strategy.
- Better DX: Autocompletion and type inference in editors like VS Code make development faster and less error-prone.
Prisma model
model Entity {
id Int @id @default(autoincrement())
name String
relatedId Int
related RelatedEntity @relation(fields: [relatedId], references: [id])
}
Service logic
@Injectable()
export class EntityService {
constructor(private prisma: PrismaService) {}
async getEntityById(id: string) {
const entity = await this.prisma.entity.findUnique({
where: { id: Number(id) },
include: { related: true },
});
return {
id: entity.id,
name: entity.name,
relatedName: entity.related.name,
};
}
}
By using Prisma, each GraphQL resolver can focus on business logic rather tha
Strangler fig pattern for the backend
Migration flow:
- Legacy Lambda endpoint keeps running
- New GraphQL service is developed
- Frontend calls GraphQL instead of REST
- Legacy endpoint is retired
Old REST call
fetch(`/api/entities/${id}`)
.then(res => res.json())
.then(data => setEntity(data));
New GraphQL call
const GET_ENTITY = gql`
query GetEntity($id: ID!) {
entity(id: $id) {
id
name
relatedName
}
}
`;
Legacy REST endpoints continue running while new GraphQL services are developed. The frontend gradually switches to GraphQL, enabling safe, step-by-step migration without disrupting production.
Shared database strategy
Both systems currently share a SQL Server database:
- Legacy backend → EF Core
- New services → Prisma
Schema changes are coordinated to avoid breaking either system.
@Injectable()
export class PrismaService extends PrismaClient {
async enableShutdownHooks(app: INestApplication) {
this.$on('beforeExit', async () => {
await app.close();
});
}
}
This ensures that database connections are closed cleanly when the app stops, which is important in serverless environments like AWS Lambda. It also improves stability when running multiple services in the same monorepo.
Practical benefits so far
- Lower risk: Legacy backend continues to run critical workflows
- Faster delivery: New features built without touching old code
- Better DX: Monorepo unifies tooling across teams
- Clear migration path: Move domain by domain
Final thoughts
Modernising a backend doesn’t have to mean rewriting everything at once. Building on the frontend migration, we applied the same incremental, domain-by-domain approach to our legacy .NET backend. The strangler fig pattern really works, by gradually replacing features and services, we can modernise safely while keeping the business running.
Leveraging a monorepo and GraphQL, we unified services, reduced duplication, and enabled coordinated development across frontend and backend. Generative AI helped us understand legacy code, uncover hidden dependencies, and plan safe migrations, accelerating development and reducing risk.
Each backend domain, like each frontend feature, is replaced step by step. This creates a predictable, maintainable path to a modern full-stack system, improves developer experience, and allows the team to deliver value continuously without fear of breaking production.
What I’ve realised is that modernisation is not just about technology. It’s about workflow, confidence, and developer experience. Gradual improvements reduce duplicated code, improve type safety, and simplify builds, all while delivering features the business actually needs. By taking a step-by-step approach across both frontend and backend, we’ve built a resilient, maintainable platform ready for future growth, while keeping the business running smoothly throughout the migration.