How We’re Surviving 20+ Domains and 100+ SQL Tables While Migrating Our Legacy .NET Backend to GraphQL

Published: (February 27, 2026 at 05:47 PM EST)
7 min read
Source: Dev.to

Source: Dev.to

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 only 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 modernisation 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 35+ domain‑specific task groups, hundreds of Lambda functions, and 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—retrieving an entity by ID—directly uses Entity Framework Core to include related entities and return a DTO:

public class GetEntityByIdFunction
{
    private readonly AppDbContext _dbContext;

    public GetEntityByIdFunction()
    {
        _dbContext = new AppDbContext();
    }

    public async Task 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 inter‑dependencies presented several challenges:

  • Domains were tightly coupled.
  • Endpoints were task‑oriented rather than resource‑oriented.
  • 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 via 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:

ServiceRole
AWS LambdaHundreds of functions across multiple domains, running in VPCs for secure RDS access
API GatewayExposes REST endpoints with dual JWT & Cognito authorization
Amazon CognitoSupports legacy and new user pools, enabling seamless migration
Amazon RDS (SQL Server)Primary relational datastore
Amazon S3Stores templates, uploads, and deployment artifacts
Amazon SES / SNSHandles email and SMS notifications
Amazon CloudWatchStructured JSON logging & distributed tracing
AWS Parameter StoreCentralised environment‑specific configuration

Together, these services ensure the backend operates securely, reliably, and efficiently.


Legacy Patterns and Technical Debt

The backend contains several legacy patterns that increase risk and complexity:

  • Stored procedures & raw SQL executed via StoreProcedureHelper bypass EF Core features and dependency injection, making code harder to test and maintain.
  • Mixing EF Core with legacy helpers creates inconsistencies and potential performance issues.
  • .NET 6 has reached end‑of‑support, necessitating an upgrade to .NET 8 for continued security.

Recommended migration path

  1. Replace StoreProcedureHelper calls with EF Core FromSqlRaw() where appropriate.
  2. Gradually convert stored procedures to LINQ queries.
  3. Remove all legacy patterns, achieving a consistent, maintainable codebase.

Example – Replacing a stored‑procedure call

// Legacy
var users = StoreProcedureHelper.DatabaseExecuteReader(
    "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 analysing Lambda handlers and EF models, AI can:

  • Summarise large files.
  • Explain business logic.
  • Highlight cross‑domain dependencies.
  • 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.

  • Shared packages (authentication, logging, database access, web components) are used across services.
  • The monorepo enables end‑to‑end TypeScript usage, faster builds with caching, and coordinated pull requests across frontend and backend teams.
  • Unifying tooling and code management improves developer experience and facilitates incremental migration.

End of cleaned markdown segment.

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.
  • GraphQL enables flexible and efficient data queries.

This modern stack improves maintainability, enhances type safety, and lets developers 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 for unit testing
  • Dockerized test databases

Benefits:

  • Full‑stack local development with hot reload for frontend and backend services.
  • Automated versioning via Changesets.
  • 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 streamlined dependency management.
  • Transitioning from a YAML‑based Serverless Framework to AWS CDK adds type safety and easier testing for infrastructure.

All of these changes further streamline 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.

0 views
Back to Blog

Related posts

Read more »

Every service I build will die

And that's exactly the point. I'm a senior software engineer at Ontime Payments, a fintech startup enabling direct‑from‑salary bill payments. We've deliberately...