Skip to content
Legacy Modernisation

Modernising SQL Server Stored Procedures with AI: From T-SQL to a Tested C# Service Layer

14 min read

Large SQL Server estates often carry a second application inside the database, in the form of hundreds of stored procedures holding business rules in procedural T-SQL. Modernising them starts with classifying each procedure rather than translating the lot. Pure data access stays as parameterised procedures behind the repository layer, set-based bulk work stays in the database, and procedural business rules move into a tested C# service layer. AI changes the cost of the work, because agents can inventory the estate, map dependencies, draft behaviour-preserving translations, and generate the parity tests that prove equivalence.

  • Classify before moving anything, because data access, business rules, set-based batch, and orchestration each need different treatment.
  • Prove behaviour parity with tests that run old and new against the same data, rather than assuming it from a clean compile.
  • T-SQL and C# treat NULLs, implicit conversions, and rounding differently, and those differences tend to hide in edge cases.
  • If Azure SQL is the destination, run the compatibility assessment early, because the findings reshape the plan.

Our legacy application modernisation service covers database-logic modernisation as part of wider .NET programmes, and our data migration service covers the platform move itself.

Your database is a second application

We often see the same pattern in legacy .NET estates. The application code went through code review and source control from day one, but the database logic grew differently. A procedure was added to fix a support incident, a trigger for an integration, and business rules went wherever the data was, because that was the fastest place to put them.

Over a couple of decades the totals add up. Estates with several hundred procedures and very large volumes of T-SQL are common, and that logic often implements the most important rules the business has, such as pricing, customer credit limits, eligibility, and invoicing. It is also the least tested and least reviewed layer of the whole system, and the hardest to hire for.

Any serious modernisation programme has to deal with this layer. As our .NET modernisation playbook sets out, rebuilding the application while leaving the rules in T-SQL leaves the hardest dependency untouched.

Should business logic live in stored procedures at all?

Some of it should. The engineering case for moving procedural business rules out of T-SQL is strong, but it does not extend to emptying the database.

Why business rules are better in C#:

  • Testability. A C# service gets unit tests, mocks, and CI. Testing a stored procedure means standing up a database with the right seeded state, which is why most procedure estates have effectively no tests.
  • Version control and review. Application code goes through pull requests. In many organisations, procedure changes are still applied by hand and reconstructed later from backups.
  • Talent and tooling. The pool of engineers fluent in large procedural T-SQL estates is shrinking, and most modern quality tooling (analysers, mutation testing, coding agents) works better against C#.

Why set-based work should stay:

  • The database engine is the right tool for operations over large sets, such as bulk updates, aggregations, and reporting joins. Translating a set-based UPDATE over a million rows into row-by-row application code makes the system slower and the cloud bill bigger.
  • Transactional integrity for multi-table batch operations is simpler and safer close to the data.

Each procedure needs its own decision, so classification comes before any translation.

How do you classify a large procedure estate?

Four categories cover almost everything, and each has a different destination:

CategoryWhat it looks likeWhat happens to it
Pure data accessSingle-statement reads and writes, parameterised CRUDStays as stored procedures, called through the repository layer and simplified where duplicated
Procedural business rulesIF/ELSE ladders, loops, per-row decisions, cursors implementing policyThe modernisation target: extract to a tested C# service layer
Set-based batchBulk updates, aggregations, reporting transforms over large setsStays in the database, and is reviewed, documented, and put under source control
Orchestration and glueProcedures calling procedures, SQL Agent job bodies, integration plumbingRedesigned, usually as an application-layer workflow or a background service

The first category shapes the whole plan, so it needs a note. We recommend keeping pure data access in stored procedures on its merits, and we do not see it as a compromise. Parameterised procedures behind a repository layer keep query plans and set logic close to the engine. They also let the application identity hold EXECUTE permissions only, with no direct table access, so modernisation leaves this access pattern in place and targets the business logic that grew inside procedural T-SQL.

Inventories tend to turn up two things worth planning for. The first is dead weight: estates often carry procedures with no remaining call sites, and identifying them safely (including dynamic SQL call sites) removes work before it is estimated. The second is duplication with variations, where the same rule is implemented three ways in three procedures. That needs a business decision about which version is correct, rather than three separate translations.

How do agents inventory and map the estate?

Reading an estate of that size by hand is not economically viable, which is why this layer survives most modernisation attempts. It is also work that coding agents do well. The workflow follows the assess, map, extract-rules, transform, and validate stages of Anthropic’s code modernisation playbook.

The inventory pass produces three artefacts:

  1. A procedure catalogue. Every procedure classified into the four categories above, with size, complexity signals (cursor use, dynamic SQL, nesting depth), and a plain-English summary of what it appears to do.
  2. A dependency map. Which procedures call which, which tables each reads and writes, and which application call sites invoke each procedure. The map turns ‘we think this is safe to move’ into evidence, and it is how dead procedures are found.
  3. A rules extract for the business-logic category. The policy each procedure implements, written in plain English for review. Finding out that a rule is obsolete or contested costs least before anyone rebuilds it.

Long procedures put the most pressure on model context. A procedure running to several thousand lines, with nested temp-table pipelines, needs the whole procedure, its dependencies, and the relevant schema in view at once. The context strategies in our scaling coding agents guide apply directly. We recommend giving the agent a scoped, read-only schema source rather than connecting it to the whole database, and our MCP patterns guide covers how to build one.

People review the catalogue with the business before anything is translated. Agents produce the inventory, but the decisions that follow from it are made by people.

How do you translate a procedure with behaviour parity?

Work through the estate one procedure at a time: pin its behaviour, translate it, prove parity, then cut over its call sites. We would not batch-translate a whole estate in one pass.

Consider a typical business-rule procedure:

SQL
CREATE PROCEDURE dbo.ApproveOrderCredit
    @CustomerId INT,
    @OrderTotal DECIMAL(18,2),
    @Approved BIT OUTPUT,
    @Reason NVARCHAR(200) OUTPUT
AS
BEGIN
    DECLARE @CreditLimit DECIMAL(18,2), @Outstanding DECIMAL(18,2);

    SELECT @CreditLimit = CreditLimit FROM dbo.Customers
    WHERE CustomerId = @CustomerId;

    SELECT @Outstanding = ISNULL(SUM(Total), 0) FROM dbo.Orders
    WHERE CustomerId = @CustomerId AND Status IN ('Open', 'Invoiced');

    IF @CreditLimit IS NULL
    BEGIN
        SET @Approved = 0;
        SET @Reason = 'No credit limit on file';
        RETURN;
    END

    IF @Outstanding + @OrderTotal > @CreditLimit
    BEGIN
        SET @Approved = 0;
        SET @Reason = 'Order would exceed credit limit';
        RETURN;
    END

    SET @Approved = 1;
    SET @Reason = NULL;
END

The C# translation makes the rule visible, injectable, and testable:

C#
public class CreditApprovalService(IOrderRepository orders, ICustomerRepository customers)
{
    public async Task<CreditDecision> ApproveAsync(
        int customerId,
        decimal orderTotal,
        CancellationToken cancellationToken = default)
    {
        var creditLimit = await customers.GetCreditLimitAsync(customerId, cancellationToken);
        if (creditLimit is null)
            return CreditDecision.Declined("No credit limit on file");

        var outstanding = await orders.GetOutstandingTotalAsync(customerId, cancellationToken);
        if (outstanding + orderTotal > creditLimit.Value)
            return CreditDecision.Declined("Order would exceed credit limit");

        return CreditDecision.Approved();
    }
}

A clean compile says nothing about behaviour. Parity comes from a test that runs both implementations against the same seeded database and compares the outcomes, including the edge cases:

C#
[Theory]
[InlineData(1001, 500.00)]   // well under limit
[InlineData(1001, 9999.99)]  // exactly at limit boundary
[InlineData(1002, 100.00)]   // customer with NULL credit limit
[InlineData(1003, 250.00)]   // customer with no orders (SUM returns NULL path)
public async Task NewService_Matches_LegacyProcedure(int customerId, double orderTotalValue)
{
    var orderTotal = (decimal)orderTotalValue; // attribute arguments cannot be decimal literals
    var legacy = await RunLegacyProcedureAsync(customerId, orderTotal);
    var modern = await creditService.ApproveAsync(customerId, orderTotal);

    modern.Approved.Should().Be(legacy.Approved);
    modern.Reason.Should().Be(legacy.Reason);
}

The NULL-handling cases are the most important. In the original, ISNULL(SUM(Total), 0) encodes a decision about customers with no orders, and a translation that misses it will approve or decline the wrong customers. Agents draft these tests well when the rules extract names the edge cases. Mutation testing, covered in our AI test quality guide, then checks that the tests would catch a divergence.

Cutover happens per call site. Application callers move to the service, the procedure is marked deprecated, and it is dropped after a monitored period. The dependency map from the inventory shows when a procedure has no remaining callers.

What changes when the target is Azure SQL?

If the platform move to Azure SQL is part of the programme, we recommend running Microsoft’s assessment rules against the estate at the start rather than the end. The findings affect the modernisation plan in three ways:

  • Unsupported features (certain system stored procedures, cross-database references, server-scoped objects) have to be remediated anyway. If a procedure needs rework for compatibility, that is often a good time to classify and modernise it rather than patch it.
  • SQL Agent jobs do not exist in Azure SQL Database. Job bodies are orchestration-category logic, and choosing their replacement (Azure Functions, container-based background services, or elastic jobs) is an application-architecture decision.
  • Sequencing. Keeping the schema and the surviving set-based procedures stable while the application layer is modernised gives you a behavioural baseline. The platform migration then moves a known, tested estate, instead of rewriting and relocating it at the same time.

For the operational side of running a large SQL Server estate day to day, including AI-augmented query tuning and index management, see our automated DBA guide.

What to watch for

NULL semantics. T-SQL uses three-valued logic, which does not map directly to C#. Every WHERE clause comparison involving a nullable column, and every ISNULL and COALESCE, encodes behaviour that the translation has to preserve on purpose.

Implicit conversions and rounding. T-SQL converts between types silently and rounds DECIMAL arithmetic by its own rules. Financial calculations translated to C# decimal can differ in the last penny, and parity tests are there to catch that kind of difference.

Transaction boundaries. A procedure often assumes it runs inside the caller’s transaction, or it starts its own. The C# service needs a transaction design that someone has thought through, rather than one that happens by accident.

Collation and case sensitivity. String comparisons in the database follow collation rules, but C# comparisons do not follow the database collation. Sorting and equality can therefore diverge without any error.

Error handling. TRY/CATCH with RAISERROR conventions, return codes, and output parameters all need an explicit mapping to exceptions and result types. Callers may also depend on the old convention.

Temp table pipelines. Long procedures that stage data through temp tables are usually several logical steps fused together. Translate the steps rather than the table plumbing, and expect the rules extract to show that some steps exist only to work around a limitation that C# does not have.

Cursors. A cursor is sometimes a business rule, making per-row decisions that belong in C#. Sometimes it is a performance workaround, and then it should become a set-based statement that stays in the database rather than moving to C#. Each one needs classifying.

Next steps

We start with the inventory, because it turns an estate nobody can size into a measurable plan: how many procedures there are, in which categories, with which dependencies, and how much of it is dead. It is a short, bounded piece of work that ends with an artefact, and it gives every later decision an evidence base.

Related guides in this cluster:

If you want to size the problem, book a free consultation and we will start with the procedure inventory.

Frequently asked questions

Should all stored procedures be moved to C#?
No. Classify before moving anything. Business rules written as procedural T-SQL usually belong in a tested C# service layer, and set-based bulk operations usually belong in the database, where they perform best. Pure data access procedures stay, because parameterised procedures called through the repository layer remain the right access pattern for performance and security. Moving every procedure into C# creates new performance and security problems without fixing the original one.
Can AI convert T-SQL stored procedures to C# automatically?
AI can draft the translation, and on a large estate that drafting is often what makes the work affordable at all. It cannot guarantee behavioural equivalence. T-SQL and C# differ on NULL handling, implicit conversions, rounding, and transaction semantics. Every translated procedure therefore needs characterisation tests that run both versions against the same data and compare the results before cutover.
What should stay in the database?
Set-based operations on large data volumes should stay, such as bulk updates, aggregations over millions of rows, and reporting queries that would become row-by-row processing in application code. The database engine is the right tool for these. Modernisation should target procedural business logic (branching, loops, and per-row decisions), which T-SQL was never the best home for.
How do you prove the C# version behaves identically?
With parity tests, which run the legacy procedure and the new service against the same seeded data and assert identical outcomes. They should cover edge cases involving NULLs, empty sets, and boundary values. Mutation testing then checks that the tests would catch a behavioural change. Any difference that surfaces is either a defect to fix or an intended improvement for the business to sign off.
Do stored procedures work on Azure SQL?
Most do, but not all. Azure SQL Database does not support certain system stored procedures, cross-database references, SQL Agent jobs, and some server-scoped features. We recommend running Microsoft's migration assessment early to surface what your estate uses, because the findings change the modernisation plan as well as the migration checklist.
Will moving logic to C# hurt performance?
It can, if set-based work is translated naively into row-by-row application code, which is why classification comes first. Logic that makes per-entity decisions usually performs well in C#, calling the database through parameterised procedures, and it gains testability and version control. Logic that transforms large sets should stay in the database. We recommend measuring both paths during the proof of concept rather than arguing from principle.
How long does stored procedure modernisation take?
It depends on the number of procedures, how entangled they are, and how much of the estate is still in use. Estates often carry procedures that nothing calls any more. We start with an agent-assisted inventory that maps dependencies and call sites, which replaces a guess with an evidence-based plan and a measurable scope.

Ready to transform your software?

Let's talk about your project. Contact us for a free consultation and see how we can deliver a business-critical solution at startup speed.