Introduction
In Part 1 – How We’re Surviving 600+ Legacy Angular Components While Migrating to Next.js, GraphQL, and a Monorepo, we explored how we are modernising a large Angular frontend incrementally. We are doing this by using Next.js for modern React rendering, GraphQL for flexible data querying, a monorepo to coordinate code across teams, web components for reusable UI, and generative AI to help understand complex legacy code.
This approach avoided a risky “big bang” rewrite while enabling continuous delivery of new features. However, the frontend is just half the picture. Behind it runs a large .NET 6 serverless backend, responsible for critical workflows like user registration, subscription management, and reporting. Migrating this backend required careful planning to reduce risk and ensure business continuity, so we applied a similar philosophy: incremental modernization using a monorepo, GraphQL, and AI assistance.
The Legacy Backend We Started With
Our backend is a mature .NET 6 serverless system hosted on AWS Lambda. Over the years, it evolved into a complex system supporting over 35 domain-specific task groups, hundreds of Lambda functions, and more than 100 SQL tables.
Each Lambda function often combined database access, business logic, and API responses, tightly coupling components and making incremental migration challenging. A typical handler, such as retrieving an entity by ID, directly uses Entity Framework Core to include related entities and return a data transfer object.
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
};
}
}
While functional, this approach makes it difficult to separate logic and update systems gradually without introducing errors. The system’s size and interdependencies presented several challenges. Domains were tightly coupled, endpoints were task-oriented, and understanding the relationships between Lambda functions, EF models, and database tables required substantial onboarding effort.
Legacy patterns, such as mixing LINQ queries with stored procedures and a custom StoreProcedureHelper class, further complicated maintainability, testing, and version control.
AWS & Legacy System Overview
The backend heavily relies on AWS services to function at scale.
AWS Lambda powers hundreds of functions across multiple domains, with VPC configuration ensuring secure access to an RDS SQL Server database.
API Gateway exposes REST endpoints with dual JWT and Cognito authorization. Cognito supports both legacy and new user pools, enabling migration without disrupting user access.
S3 stores templates, uploads, and deployment artifacts, while SES and SNS handle email and SMS notifications.
CloudWatch provides structured JSON logging and distributed tracing for operational visibility.
Configuration is centralized in AWS Parameter Store for environment-specific settings. Together, these services ensure the backend operates securely, reliably, and efficiently.
Legacy Patterns and Technical Debt
The backend contains several legacy patterns that increased risk and complexity. Stored procedures and raw SQL, often executed via the StoreProcedureHelper class, bypassed EF Core features and dependency injection, making code harder to test and maintain.
The mix of EF Core and legacy helpers created inconsistencies and potential performance issues. Additionally, .NET 6 has reached end-of-support, necessitating an upgrade to .NET 8 for continued security.
Our recommended migration path involves replacing the StoreProcedureHelper with EF Core FromSqlRaw() calls, gradually converting stored procedures to LINQ queries, and eventually removing all legacy patterns to create a consistent, maintainable codebase.
Example of replacing a stored procedure call:
// Legacy
var users = StoreProcedureHelper.DatabaseExecuteReader<User>("SearchUserPagingByOrg @OrgId", new { OrgId = orgId });
// Modern EF Core approach
var users = await _dbContext.Users
.Where(u => u.OrganizationId == orgId)
.ToListAsync();
Leveraging Generative AI
Generative AI played a key role in helping our team navigate the complexity of the legacy backend.
By analyzing Lambda handlers and EF models, AI can summarize large files, explain business logic, highlight cross-domain dependencies, and even spot hidden bugs.
This dramatically reduced the time required to understand legacy code and helped us plan safe migrations. AI assistance allowed our team to confidently decompose large modules and anticipate potential issues before introducing new services.
Monorepo with Turborepo
We consolidated all frontend and backend services in a monorepo managed with Turborepo.
This structure allows shared packages, such as authentication, logging, database access, and web components, to be used across services.
The monorepo approach enables end-to-end TypeScript usage, faster builds with caching, and coordinated pull requests across frontend and backend teams.
By unifying tooling and code management, the monorepo improved developer experience and facilitated the incremental migration strategy.
Modern Backend Stack
Our new backend leverages TypeScript, Node.js, NestJS, and Prisma, replacing the legacy .NET Lambda functions with federated GraphQL services.
Each service handles a specific business domain and accesses the same SQL Server database, allowing old and new systems to coexist. Prisma provides type-safe database access and simplifies dependency management, while GraphQL enables flexible and efficient data queries.
This modern stack improves maintainability, enhances type safety, and allows developers to adopt new practices gradually without disrupting ongoing operations.
Example GraphQL resolver:
@Resolver(() => Entity)
export class EntityResolver {
constructor(private readonly entityService: EntityService) {}
@Query(() => Entity)
async entity(@Args('id') id: string) {
return this.entityService.getEntityById(id);
}
}
Prisma service example:
@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,
};
}
}
Strangler Fig Pattern for the Backend
We used the Strangler Fig pattern to replace legacy functionality incrementally. Legacy Lambda endpoints remain operational while new GraphQL services are developed for each domain.
The frontend gradually switches to calling GraphQL instead of REST. Once a domain is fully migrated, the corresponding Lambda functions can be retired. Sharing the same SQL database ensures zero downtime, provides opportunities for A/B testing, and allows safe rollbacks when necessary.
This pattern enabled us to modernize in stages while maintaining business continuity.
CI/CD & Testing Improvements
Our modern pipeline replaces the legacy .NET build and xUnit tests with Turborepo-managed builds, Vitest, and Dockerized test databases.
This allows full-stack local development, hot reload for frontend and backend services, automated versioning via Changesets, and publishing of web components to GitHub Packages.
The improved CI/CD pipeline reduces build times, increases deployment confidence, and enables the team to iterate quickly and safely.
Developer Experience Improvements
Developers now work entirely in TypeScript with Node.js and NestJS, replacing the older .NET 6 SDK workflow. Local development allows running all services simultaneously with hot reload.
Database interactions are type-safe via Prisma, eliminating manual connection string management. Turborepo and pnpm workspaces provide fast rebuilds and dependency management.
The transition from a YAML-based Serverless Framework to AWS CDK adds type safety and easier testing for infrastructure, further streamlining development.
Final Thoughts
Modernizing a backend does not require rewriting everything at once.
By using a monorepo, adopting GraphQL for domain-by-domain migration, and leveraging generative AI for code understanding, we achieved safe incremental progress. The legacy backend remains operational during migration, ensuring zero downtime and providing a predictable path forward.
This approach reduces risk, enhances developer experience, and supports continuous delivery. Ultimately, modernization is about building confidence, improving workflow, and creating a maintainable, resilient system that can evolve alongside business needs.
Top comments (0)