Modernizing a Legacy ASP.NET 3.5 App to React + .NET 10
When I first opened the codebase of an internal workflow platform, I was staring at an ASP.NET 3.5 WebForms application that had been in production for years. A large collection of .aspx pages, inline SQL queries scattered across code-behind files, heavy ViewState usage, and a deployment process that involved manually copying DLLs to a Windows Server. The application was critical to internal document workflows, 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:
Workflow.API/
Controllers/ # Controllers and API endpoints
Workflow.Application/
Commands/ # Write operations via MediatR
Queries/ # Read operations via MediatR
Validators/ # FluentValidation rules
Mappings/ # AutoMapper profiles
Workflow.Domain/
Entities/
Enums/
Interfaces/
Workflow.Infrastructure/
Persistence/ # Dapper + Oracle DB
Services/
Middleware/ # Security middleware
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 very wide tables, 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)
Hybrid enterprise 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(".App.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 accessible enterprise 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 in a phased rollout. 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 with feature flags
- Cut over by updating the reverse proxy routing
- Monitor error rates and performance 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 a sustained incremental migration:
- Page load times dropped dramatically
- API response times improved substantially compared with equivalent legacy postbacks
- Developer productivity improved because the team could deliver changes with less friction
- No disruptive cutover was required during the migration
- Automated coverage protected the most critical business logic
The legacy ASP.NET 3.5 app still runs for a small number of 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.