Modernizing a Legacy ASP.NET 3.5 App to React + .NET 10
When I first opened the codebase of DigiFlow, I was staring at an ASP.NET 3.5 WebForms application that had been in production for over a decade. Hundreds of .aspx pages, inline SQL queries scattered across code-behind files, ViewState blobs measured in megabytes, and a deployment process that involved manually copying DLLs to a Windows Server. The application was critical — it powered the entire document workflow for thousands of government employees — but it was buckling under its own weight.
This is the story of how I led the migration from that legacy monolith to a modern React 19 + .NET 10 stack, and the architectural decisions that made it possible without a single day of downtime.
Why Not a Rewrite?
The first instinct was to rewrite everything from scratch. Management wanted it, developers wanted it, and honestly, I wanted it too. But I had seen enough "big bang" rewrites fail to know better. Instead, I proposed a strangler fig pattern: we would build the new system around the old one, gradually replacing functionality until the legacy app could be safely decommissioned.
The key insight was to start with the API layer. The old app had no API — everything was tightly coupled server-rendered pages. By building a clean API first, we could serve both the legacy frontend and the new React frontend simultaneously.
The API: Clean Architecture from Day One
I chose .NET 10 with Clean Architecture and CQRS using MediatR. The folder structure reflects the separation of concerns:
DigiFlow.API/
Controllers/ # 35 controllers, ~200 endpoints
DigiFlow.Application/
Commands/ # Write operations via MediatR
Queries/ # Read operations via MediatR
Validators/ # FluentValidation rules
Mappings/ # AutoMapper profiles
DigiFlow.Domain/
Entities/
Enums/
Interfaces/
DigiFlow.Infrastructure/
Persistence/ # Dapper + Oracle DB
Services/
Middleware/ # 16 security middlewares
Every request flows through a predictable pipeline: Controller -> MediatR Handler -> Validation -> Business Logic -> Data Access. No shortcuts, no exceptions.
The Data Layer Challenge: Oracle DB
One of the hardest constraints was the existing Oracle database. We could not migrate away from it — too many other systems depended on those tables and stored procedures. I chose Dapper over Entity Framework for a simple reason: performance and control. When you are dealing with complex Oracle queries, stored procedures with cursor output parameters, and tables with 100+ columns, you need to write the SQL yourself.
csharppublic class GetDocumentByIdQueryHandler
: IRequestHandler<GetDocumentByIdQuery, DocumentDto>
{
private readonly IOracleConnectionFactory _connectionFactory;
private readonly IMapper _mapper;
public async Task<DocumentDto> Handle(
GetDocumentByIdQuery request, CancellationToken ct)
{
using var connection = _connectionFactory.Create();
var parameters = new OracleDynamicParameters();
parameters.Add("p_doc_id", request.DocumentId);
parameters.Add("p_cursor", dbType: OracleDbType.RefCursor,
direction: ParameterDirection.Output);
var document = await connection.QueryFirstOrDefaultAsync<Document>(
"PKG_DOCUMENT.GET_BY_ID",
parameters,
commandType: CommandType.StoredProcedure);
return _mapper.Map<DocumentDto>(document);
}
}
Authentication: The Hybrid Nightmare (That Worked)
Government networks are complicated. Some users are on domain-joined machines with Windows Authentication. Others access the system externally via Azure AD. And there is a legacy session-cookie flow that could not be killed overnight. I built what I call a Smart Auth Handler — a custom authentication scheme that inspects the incoming request and delegates to the appropriate handler:
csharppublic class SmartAuthHandler : AuthenticationHandler<SmartAuthOptions>
{
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
// 1. Check for Azure AD JWT Bearer token
if (Context.Request.Headers.ContainsKey("Authorization"))
return await AuthenticateWithJwt();
// 2. Check for Windows Authentication (Negotiate/NTLM)
if (Context.User?.Identity?.IsAuthenticated == true
&& Context.User.Identity is WindowsIdentity)
return AuthenticateWithWindows();
// 3. Fall back to session cookie
if (Context.Request.Cookies.ContainsKey(".DigiFlow.Session"))
return await AuthenticateWithSession();
return AuthenticateResult.NoResult();
}
}
This approach let us migrate users gradually. As departments moved to Azure AD, they automatically started using JWT auth without any frontend changes.
The React Frontend
For the frontend, I chose React 19 with Vite 7 and TypeScript 5.9. We built on top of MUI 7 with our custom component library called WFace, which provides government-themed UI components with built-in accessibility compliance.
State management was a deliberate choice: Zustand for global client state, TanStack React Query for server state, and React Context for theme and localization. No Redux — I have seen too many enterprise React apps drown in Redux boilerplate.
typescript// Zustand store for UI state - lean and predictable
interface WorkflowStore {
activeModule: WorkflowModule | null;
sidebarCollapsed: boolean;
setActiveModule: (module: WorkflowModule) => void;
toggleSidebar: () => void;
}
export const useWorkflowStore = create<WorkflowStore>((set) => ({
activeModule: null,
sidebarCollapsed: false,
setActiveModule: (module) => set({ activeModule: module }),
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
}));
The Migration Strategy
We migrated module by module over 8 months. Each module followed the same process:
- Build the API endpoints for the module with full test coverage
- Build the React pages consuming those endpoints
- Run both old and new in parallel for 2 weeks with feature flags
- Cut over by updating the reverse proxy routing
- Monitor error rates and performance for a week before moving on
The reverse proxy (IIS ARR) was our best friend. We could route /api/* to the new .NET 10 backend while keeping /legacy/* pointed at the old WebForms app. Users did not even notice the transition.
Results
After 10 months of incremental migration:
- Page load times dropped from 8-12 seconds to under 1.5 seconds
- API response times averaged 45ms (down from 2-3 seconds for equivalent WebForms postbacks)
- Developer productivity tripled — new features that took weeks now take days
- Zero downtime during the entire migration
- 131 test files covering critical business logic
The legacy ASP.NET 3.5 app still runs for two rarely-used modules. It will be decommissioned by Q2 2026. But the important thing is that it no longer blocks progress.
Lessons Learned
- Never rewrite, always migrate. The strangler fig pattern saved us from the "second system effect."
- Start with the API. A clean API layer is the foundation everything else builds on.
- Hybrid auth is messy but necessary. Enterprise environments rarely have the luxury of a single auth mechanism.
- Dapper over EF for legacy databases. When you cannot control the schema, you need full SQL control.
- Feature flags are non-negotiable. Every module migration was gated behind a flag.
If you are facing a similar legacy modernization challenge, my advice is simple: be patient, be incremental, and build the right abstractions from the start. The architecture you choose in week one will either save you or haunt you for years.