Event-Driven Resume Analysis on Azure — What I Built, How It Works, and What Broke Along the Way
Pravin Kshirsagar · Azure & .NET Developer · 2026 · 8 min read
This article is a companion to my YouTube walkthrough of ResumePulse.
Watch the full video here
Why I Built This
A year into working as a .NET developer, I kept hitting the same wall in interviews. I could talk about Azure — Service Bus, Event Grid, Key Vault — but I had nothing deployed. No project. Just theory.
That's a hard place to be when you're sitting across from someone with five years of real-world experience and a GitHub full of shipped projects.
So I stopped reading docs and started building. ResumePulse came out of that — an AI-powered resume analyzer running entirely on Azure's free tier. You upload a resume, paste a job description, and within 30 seconds you get a score, matched and missing skills, a hiring recommendation, and a detailed report straight to your inbox.
Simple problem. Interesting architecture underneath.
The Architecture — Why Event-Driven
The core decision I made early: the API should never wait for AI.
Gemini calls take 20–40 seconds depending on model load. If the API blocked on that, users would stare at a spinner. If the connection dropped mid-analysis, the result would vanish. Fragile.
Instead — the API does two quick things when you hit upload: stores the PDF, drops a message in a queue, then immediately returns a job ID. Your browser polls every 5 seconds. An Azure Function picks up that queue message, handles the AI processing, saves the report, and fires an event when done. The next poll finds it.
Nothing is coupled directly to anything else. The API never calls Gemini. The Function never sends emails. Each piece does one job and communicates through contracts — queue message format and event schema.
Full Flow, Step by Step
- Blazor WASM frontend loads from Azure Static Web Apps (global CDN)
- User submits email, job description, and PDF resume
POST /api/resume/upload hits the ASP.NET Core API on Azure App Service
- API reads all secrets from Azure Key Vault via Managed Identity — zero passwords in code
- PDF uploads to Blob Storage (
resumes container), message queued in Service Bus
- API returns
{ JobId, Status: "Queued" } — done in under a second
- Azure Function wakes on
ServiceBusTrigger → downloads PDF → calls Gemini AI
- Gemini returns structured JSON: score, matched skills, missing skills, recommendation
- Function saves
report.json to Blob Storage (reports container)
- Function publishes
ResumeAnalysisCompleted event to Event Grid Topic
- Event Grid routes event to Logic App → Logic App sends formatted email via SendGrid
- Blazor's polling loop hits
GET /api/resume/report/{jobId} and renders the result
The 9 Azure Services — Plain English
| Service | What It Does in This Project | Free Tier |
| Blob Storage | Stores uploaded PDFs and generated JSON reports | 5 GB (12 months) |
| Service Bus | Queues analysis jobs — decouples API from AI processing | 10M messages/month |
| Key Vault | Holds all 4 secrets — connection strings, API keys | 10K ops/month |
| App Service | Hosts the ASP.NET Core Web API | F1 — always free |
| App Service Plan | The compute resource App Service runs on | F1 — zero cost |
| Azure Functions | Background processor triggered by Service Bus | 1M executions/month |
| Event Grid | Routes "analysis complete" events to Logic App | 100K ops/month |
| Logic Apps | Sends the result email via SendGrid — no email code written | 4K actions/month |
| Static Web Apps | Hosts Blazor WASM on a global CDN | Always free |
The Code That Actually Matters
Skipping the boilerplate. Here are the four pieces worth understanding.
1. Key Vault + Managed Identity
var kvUrl = builder.Configuration["KeyVault__Url"];
builder.Configuration.AddAzureKeyVault(new Uri(kvUrl), new DefaultAzureCredential());
Three lines. Every secret in Key Vault is now accessible via Configuration["SecretName"] — exactly like appsettings.json, except nothing sensitive ever touches your code or repo.
DefaultAzureCredential uses your local Azure CLI in development and automatically switches to Managed Identity in Azure. Same code, both environments, no changes needed.
2. Why the Upload Endpoint Returns in Under a Second
var jobId = Guid.NewGuid().ToString();
// Store the PDF
var blobUrl = await _blob.UploadResumeAsync(request.ResumeFile, jobId);
// Queue the job — fire and move on
await _serviceBus.SendMessageAsync(new {
JobId = jobId,
BlobUrl = blobUrl,
Email = request.Email,
JobDescription = request.JobDescription
});
// Return immediately — no waiting for AI
return Ok(new { JobId = jobId, Status = "Queued" });
The frontend gets this in under a second. It starts polling in the background. The Function is doing the heavy work in parallel. When the report is ready, the next poll finds it.
3. The Blazor File Upload Bug That Cost Me an Hour
In Blazor WASM, file references go null after any await call. No clear error — just a silent crash.
// ❌ Wrong — crashes silently after first await
private async Task OnFileChanged(InputFileChangeEventArgs e) {
await SomeOtherAsyncThing();
var stream = e.File.OpenReadStream(); // NullReferenceException
}
// ✅ Correct — read into memory before any await
private async Task OnFileChanged(InputFileChangeEventArgs e) {
using var ms = new MemoryStream();
await e.File.OpenReadStream(10 * 1024 * 1024).CopyToAsync(ms);
_fileBytes = ms.ToArray(); // safe — stored in memory now
}
Read the file into a byte array immediately when the input changes. Use those bytes later. Simple fix once you know the cause.
4. Getting Reliable JSON from Gemini
var config = new GenerateContentConfig {
ResponseMimeType = "application/json", // critical
Temperature = 0.1f // low = consistent, predictable output
};
Without ResponseMimeType, Gemini sometimes wraps output in markdown code blocks. Your JSON parser throws. You spend 20 minutes confused about why. Setting this tells the model to return raw JSON only.
Also added retry logic for free tier quota limits — up to 3 attempts with delay. All JSON parsing uses TryGetProperty instead of GetProperty to handle missing optional fields cleanly.
Azure Setup — 12 Resources in the Right Order
| Step | Resource | Name | Key Setting |
| 1 | Resource Group | rg-resumepulse-pk | Region: East US |
| 2 | Storage Account | stresumepulsepk | LRS redundancy |
| 3 | Blob Containers | resumes + reports | Access: Private |
| 4 | Service Bus Namespace | sb-resumepulse-pk | Basic tier |
| 5 | Service Bus Queue | resume-analysis-queue | Default settings |
| 6 | Key Vault | kv-resumepulse-pk | RBAC access |
| 7 | App Service Plan | asp-resumepulse-pk | F1 Free tier |
| 8 | App Service | app-resumepulse-pk | .NET 9, Linux |
| 9 | Function App | func-resumepulse-pk | Consumption, .NET 9 Isolated |
| 10 | Event Grid Topic | egt-resumepulse-pk | Schema: Event Grid |
| 11 | Logic App | logic-resumepulse-pk | Consumption plan |
| 12 | Static Web App | swa-resumepulse-pk | Free plan |
Key Vault Secrets to Add
After creating Key Vault, add these four secrets manually:
- StorageConnectionString — Storage Account → Access Keys → Connection String
- ServiceBusConnectionString — Service Bus Namespace → Shared Access Policies → Primary
- GeminiApiKey — aistudio.google.com → Get API Key
- EventGridTopicKey — Event Grid Topic → Access Keys → Key 1
⚠️ Critical: After creating App Service and Function App, go to each one's Identity tab and turn on System Assigned Managed Identity. Then go to Key Vault → Access Control (IAM) → Add Role Assignment → Key Vault Secrets User → assign to both. Without this, both apps will fail silently with a vague 403.
Project Structure
ResumePulse/
├── ResumePulse.sln
├── ResumePulse.Api/ ← ASP.NET Core Web API
│ ├── Controllers/ResumeController.cs
│ ├── Services/
│ │ ├── BlobStorageService.cs
│ │ └── ServiceBusService.cs
│ └── Program.cs
├── ResumePulse.Client/ ← Blazor WASM Frontend
│ ├── Pages/Home.razor
│ ├── Services/ResumeApiService.cs
│ └── Program.cs
└── ResumePulse.Functions/ ← Azure Functions
├── Functions/ResumeAnalysisFunction.cs
├── Services/GeminiService.cs
├── host.json
└── local.settings.json ← ⚠️ Never commit this
NuGet Packages
API:
dotnet add package Azure.Storage.Blobs
dotnet add package Azure.Messaging.ServiceBus
dotnet add package Azure.Security.KeyVault.Secrets
dotnet add package Azure.Identity
dotnet add package Azure.Extensions.AspNetCore.Configuration.Secrets
Functions:
dotnet add package Microsoft.Azure.Functions.Worker
dotnet add package Microsoft.Azure.Functions.Worker.Extensions.ServiceBus
dotnet add package Azure.Storage.Blobs
dotnet add package Azure.Messaging.EventGrid
dotnet add package Google.Cloud.AIPlatform.V1
dotnet add package UglyToad.PdfPig
Deployment — Three Steps, This Order
Deploy API first, Functions second, Blazor third. The frontend needs the real API URL before publishing.
API to App Service:
cd ResumePulse.Api
dotnet publish -c Release -o ./publish
cd publish && tar -a -c -f ../api-deploy.zip *
az webapp deploy \
--resource-group rg-resumepulse-pk \
--name app-resumepulse-pk \
--src-path ../api-deploy.zip \
--type zip
Azure Functions:
cd ResumePulse.Functions
dotnet publish -c Release -o ./publish
cd publish && tar -a -c -f ../func-deploy.zip *
az functionapp deployment source config-zip \
--resource-group rg-resumepulse-pk \
--name func-resumepulse-pk \
--src ../func-deploy.zip
Blazor WASM to Static Web Apps:
Update Program.cs with the real API URL first:
var apiBaseUrl = builder.HostEnvironment.IsProduction()
? "https://app-resumepulse-pk.azurewebsites.net/"
: "http://localhost:5257/";
Then deploy:
cd ResumePulse.Client
dotnet publish -c Release -o ./publish
npm install -g @azure/static-web-apps-cli
swa deploy ./publish/wwwroot \
--deployment-token YOUR-TOKEN-HERE \
--env production
⚠️ After getting your Static Web App URL, update the CORS policy in your API's Program.cs to include it, then redeploy the API. Without this, the browser blocks every API call — and the console error is easy to misread if you're not expecting it.
What It Actually Costs
For 100 resume analyses: roughly ₹8, almost entirely Gemini (~₹0.08 per call). Every Azure service stays within free tier limits.
| Service | Free Limit | Cost |
| Static Web Apps | 100 GB bandwidth/month | ₹0 |
| App Service F1 | 60 CPU min/day | ₹0 |
| Blob Storage | 5 GB (12 months) | ₹0 |
| Azure Functions | 1M executions/month | ₹0 |
| Service Bus | 10M messages/month | ₹0 |
| Key Vault | 10K ops/month | ₹0 |
| Event Grid | 100K ops/month | ₹0 |
| Logic Apps | 4K actions/month | ₹0 |
| Gemini AI | Free credits on signup | ~₹0.08/analysis |
One caveat — the F1 App Service plan has no Always-On. First request after inactivity takes ~5 seconds to wake up. Fine for a portfolio project. Upgrade to B1 (~$13/month) if you need Always-On for production.
What I Actually Learned
Managed Identity is trickier than the docs suggest. The concept is clean — no passwords, just an Azure AD identity. The reality: enable it on the resource, copy the Object ID, assign the right IAM role on each target service, then wait a few minutes for propagation. Miss any step, you get a vague 403 with no helpful message. I hit this twice.
Event-driven forces a different mental model. Synchronous thinking is linear — request in, process, response out. Event-driven isn't. The API has no idea what the Function is doing. The Function doesn't know the Logic App exists. Once that mental model clicks, the architecture feels natural. But it takes a moment to actually click.
The bugs are the most valuable part. The Blazor file null reference. The Managed Identity propagation delay. The Gemini markdown wrapping. Every one of them frustrated me at the time. Every one of them is now something I can explain clearly in an interview — because I actually lived through it, not just read about it.
How This Helps in Interviews
There are two types of developers in any Azure conversation. One says "I know Service Bus and Event Grid." The other says "I used Service Bus to decouple my API from AI processing — I needed the endpoint to return in under a second without blocking on a 30-second Gemini call."
After building ResumePulse, you're the second developer. You can speak to real decisions and trade-offs, not just service names.
About the Author
Pravin Kshirsagar — .NET developer at YASH Technologies, focused on Azure-native development and working toward AZ-204. I write about the real implementation experience — the messy parts included — so other developers can skip what I wasted time on.
Full source code and CLI commands are in the YouTube video description.
#Azure #DotNET #Blazor #AzureFunctions #GeminiAI #EventDriven #AZ204 #CSharp